Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
536 changes: 536 additions & 0 deletions .github/workflows/translate.yml

Large diffs are not rendered by default.

154 changes: 139 additions & 15 deletions README.md

Large diffs are not rendered by default.

19 changes: 12 additions & 7 deletions build-docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,9 @@ def prepare_localized_content(lang: str, sync: bool = False) -> int:
For English: always copies all source content (required for docfx)
For other languages:
sync=True: full English fallback sync (hash comparison, copy missing/outdated)
sync=False: only sync shared directories (assets, api) — Crowdin manages translations
sync=False: only sync shared directories (assets, api) — translations are
committed by the automated translation workflow
(translate-content.py / translate.yml)
"""
if lang == "en":
# English always needs full sync
Expand All @@ -193,8 +195,10 @@ def prepare_localized_content(lang: str, sync: bool = False) -> int:
if result != 0:
return result

# Repair Crowdin-collapsed DocFX alerts (e.g. "> [!NOTE]> text") before docfx
# builds this language, so alerts render as styled boxes instead of plain quotes.
# Repair collapsed DocFX alerts in translator output (e.g. "> [!NOTE]> text")
# before docfx builds this language, so alerts render as styled boxes instead
# of plain quotes. A safety net: the translation script verifies markers, but
# older translations and hand edits can still carry the collapsed form.
result = run_command(
[sys.executable, "build_scripts/normalize-localized-alerts.py", lang],
f"Normalizing DocFX alerts for {lang}"
Expand Down Expand Up @@ -226,9 +230,10 @@ def build_language(lang: str, sync: bool = False, skip_api: bool = False, permis
return result

# Build the documentation — fail on DocFX warnings only for English (the
# authored source). Localized content is Crowdin-managed and may carry
# translation warnings that must not block deployment. `permissive` lifts the
# English gate too, for local iteration where transient warnings are expected
# authored source). Localized content comes from the automated translation
# workflow and may carry translation warnings that must not block deployment.
# `permissive` lifts the English gate too, for local iteration where transient
# warnings are expected
# (warnings are still printed, just not fatal); full/CI builds leave it off.
#
# `docfx build` skips API metadata regeneration and reuses the existing
Expand Down Expand Up @@ -348,7 +353,7 @@ def main() -> int:
parser.add_argument("--no-api-copy", action="store_true", help="Skip copying API docs to localized sites")
parser.add_argument("--skip-api", action="store_true", help="LOCAL markdown iteration only (requires --serve/--lang): reuse existing content/api, ~30-40%% faster. NEVER for testing/CI/CD/releases")
parser.add_argument("--permissive", action="store_true", help="Don't treat English DocFX warnings as build failures (for local iteration; keep full/CI builds strict)")
parser.add_argument("--sync", action="store_true", help="Sync English fallback for missing/outdated translations (for local dev)")
parser.add_argument("--sync", action="store_true", help="Copy English over missing/outdated translations for a local build (fallback copies only; never commit them)")

args = parser.parse_args()

Expand Down
8 changes: 5 additions & 3 deletions build_scripts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@ This document covers new build tools:
- `te_script_runner.py`: standalone runner and module for other tools that need to execute C# scripts
- `csharp_doctest.py`: compiles and runs annotated `csharp` code blocks in markdown files
- `check_links.py`: dead link checker for built site
- `translate-content.py`: submits changed English content to Translated (TranslationOS), then repairs, verifies and writes the delivered translations; `python build_scripts/translate-content.py --self-test` runs its offline test suite (no key, no network; the `pull_request` dry-run job runs it on PRs that touch the script or its config, and every `translate` run starts with it). CLI, environment variables and runbook: [Translating Content](../README.md#translating-content)

Existing docfx and localization orchestration can be found in [../README.md](../README.md)

## Prerequisites

Required on PATH:

- `python3` -- 3.10+ (the scripts use 3.10 syntax; validated on 3.14).
- `python3` -- 3.11+ (`pyproject.toml` targets py311 and CI runs 3.11; validated on 3.14).
- `uv` -- provides `uvx`, for lint and type-check.
- `te` -- the Tabular Editor CLI, for the doc-validation scripts; you should use a build aligned with TE3 release for checking docs.
- `docfx` -- or `dotnet` with the pinned local docfx tool, to build the site.
Expand All @@ -22,10 +23,11 @@ paths like `_site` and `content/` resolve relative to the current directory.
## Contributing and development notes

Scripts have no build phase.
All Python build scripts are linted and type-checked:
The Python build scripts listed in `pyproject.toml` (`check_links.py`, `csharp_doctest.py`, `te_script_runner.py`, `translate-content.py`, `config_loader.py`) are linted, format-checked and type-checked by `./run scripts check`; add a script to both lists there when it meets the bar:

```shell
$ uvx ruff check --select F,B,SIM,I,UP <python_sources>
$ uvx ruff check --select F,B,SIM,I,UP --line-length 120 --target-version py311 <python_sources>
$ uvx ruff format --check --line-length 120 <python_sources>
$ uvx mypy --strict <python_sources>
```

Expand Down
80 changes: 36 additions & 44 deletions build_scripts/config_loader.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Shared configuration loader for build scripts.

Expand All @@ -12,7 +11,6 @@
from pathlib import Path
from typing import Any


# Default paths (relative to project root)
BUILD_CONFIG_PATH = "metadata/build-config.json"
REDIRECTS_CONFIG_PATH = "metadata/redirects.json"
Expand All @@ -25,60 +23,54 @@

def load_build_config(config_path: Path | str | None = None) -> dict[str, Any]:
"""Load the build configuration from JSON file.

Returns the full config dict with the following keys:
- contentDirectories: directories with translatable content
- sharedDirectories: assets/api that aren't translated
- rootFiles: root-level files (index.md, toc.yml, etc.)
"""
global _build_config

if _build_config is not None and config_path is None:
return _build_config

if config_path is None:
config_path = Path(BUILD_CONFIG_PATH)
else:
config_path = Path(config_path)


config_path = Path(BUILD_CONFIG_PATH) if config_path is None else Path(config_path)

if not config_path.exists():
raise FileNotFoundError(f"Build config not found: {config_path}")

with open(config_path, encoding="utf-8") as f:
config: dict[str, Any] = json.load(f)

if config_path == Path(BUILD_CONFIG_PATH):
_build_config = config

return config


def load_redirects_config(config_path: Path | str | None = None) -> dict[str, Any]:
"""Load the redirects configuration from JSON file.

Returns the full config dict with the following keys:
- serverRedirects: 301 redirects handled by Azure SWA
- clientRedirects: meta-refresh HTML redirects
"""
global _redirects_config

if _redirects_config is not None and config_path is None:
return _redirects_config

if config_path is None:
config_path = Path(REDIRECTS_CONFIG_PATH)
else:
config_path = Path(config_path)


config_path = Path(REDIRECTS_CONFIG_PATH) if config_path is None else Path(config_path)

if not config_path.exists():
raise FileNotFoundError(f"Redirects config not found: {config_path}")

with open(config_path, encoding="utf-8") as f:
config: dict[str, Any] = json.load(f)

if config_path == Path(REDIRECTS_CONFIG_PATH):
_redirects_config = config

return config


Expand Down Expand Up @@ -108,7 +100,7 @@ def get_root_files(config: dict[str, Any] | None = None) -> list[str]:

def get_legacy_shortcuts(config: dict[str, Any] | None = None) -> dict[str, str]:
"""Get legacy shortcut redirects (old URL → new URL).

These are server-side 301 redirects for high-priority/vanity URLs.
Filters out keys starting with '_' which are used for comments.
"""
Expand All @@ -121,7 +113,7 @@ def get_legacy_shortcuts(config: dict[str, Any] | None = None) -> dict[str, str]

def get_client_redirects(config: dict[str, Any] | None = None) -> dict[str, str]:
"""Get client-side redirects for legacy content URLs.

These are meta-refresh HTML redirects for content migration.
Keys are paths like '/te2/Getting-Started.html', values are target paths.
Filters out keys starting with '_' which are used for comments.
Expand All @@ -135,13 +127,13 @@ def get_client_redirects(config: dict[str, Any] | None = None) -> dict[str, str]

def get_all_redirects(config: dict[str, Any] | None = None) -> dict[str, str]:
"""Get all redirects (both server and client) merged together.

Returns a combined dict of all redirects. Server redirects take precedence
if there are any duplicates (though there shouldn't be).
"""
if config is None:
config = load_redirects_config()

all_redirects = {}
all_redirects.update(get_client_redirects(config))
all_redirects.update(get_legacy_shortcuts(config))
Expand All @@ -166,9 +158,7 @@ def get_base_url(config: dict[str, Any] | None = None) -> str:
config = load_build_config()
base_url: str | None = config.get("baseUrl")
if not base_url:
raise KeyError(
'"baseUrl" is missing from metadata/build-config.json'
)
raise KeyError('"baseUrl" is missing from metadata/build-config.json')
return base_url.rstrip("/")


Expand Down Expand Up @@ -198,36 +188,38 @@ def get_sitemap_exclude(config: dict[str, Any] | None = None) -> list[dict[str,

def compute_file_hash(file_path: Path | str) -> str:
"""Compute SHA256 hash of a file's contents.


CRLF line endings are normalized to LF before hashing so the hash is the same
for a Windows checkout (core.autocrlf), a Linux CI checkout and the blob in
git. The translation status files compare these hashes across all three.

Returns a hex string prefixed with 'sha256:' for clarity.
Returns empty string if file doesn't exist.
"""
file_path = Path(file_path)

if not file_path.exists():
return ""

sha256_hash = hashlib.sha256()

with open(file_path, "rb") as f:
# Read in chunks for large files
for chunk in iter(lambda: f.read(8192), b""):
sha256_hash.update(chunk)

return f"sha256:{sha256_hash.hexdigest()}"
data = f.read()

normalized = data.replace(b"\r\n", b"\n")
return f"sha256:{hashlib.sha256(normalized).hexdigest()}"


def get_all_content_files(content_dir: Path | str) -> list[Path]:
"""Get all content files (markdown, yaml) from a content directory.

Returns list of paths relative to the content directory.
"""
content_dir = Path(content_dir)

if not content_dir.exists():
return []

files: list[Path] = []
for pattern in ["**/*.md", "**/*.yml", "**/*.yaml"]:
files.extend(content_dir.glob(pattern))

return sorted(files)
16 changes: 10 additions & 6 deletions build_scripts/normalize-localized-alerts.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Normalize DocFX alerts in Crowdin-translated content.
Normalize DocFX alerts in translated content.

Crowdin collapses DocFX/GitHub-style alerts that are nested inside list items,
joining the marker line and the first content line. This:
Some translation exports (machine translation included) collapse DocFX/GitHub-style
alerts that are nested inside list items, joining the marker line and the first
content line. This:

> [!NOTE]
> text

comes back from Crowdin as:
comes back from the translator as:

> [!NOTE]> text

Expand All @@ -20,7 +21,10 @@
This script finds the collapsed form and splits it back into two lines,
preserving the original indentation so the alert stays inside its list item.
It is idempotent and only rewrites the exact collapsed pattern, so it is safe
to run after every Crowdin pull. Lines inside fenced code blocks are skipped so
to run after merging every translation PR; the build runs it on every non-English
language as a safety net for translator output (build_scripts/translate-content.py
restores alert markers from English, but older translations and hand edits may
still carry the collapsed form). Lines inside fenced code blocks are skipped so
documentation that shows alert syntax verbatim is never altered.

Usage:
Expand Down Expand Up @@ -117,7 +121,7 @@ def iter_markdown_files(lang: str | None):

def main() -> int:
parser = argparse.ArgumentParser(
description="Split Crowdin-collapsed DocFX alerts back into two lines."
description="Split collapsed DocFX alerts in translated content back into two lines."
)
parser.add_argument(
"lang", nargs="?",
Expand Down
14 changes: 8 additions & 6 deletions build_scripts/normalize-localized-heading-anchors.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,14 @@
This neutralizes the whole class of warning for current and future pages without
touching translations.

Headings are aligned to the English source positionally (Crowdin preserves
heading structure). If the heading count differs (a translation added/removed a
heading, or is stale), the file is skipped and reported rather than risk a
misaligned anchor. Frontmatter and fenced code blocks are skipped. Injected
anchors are tagged `data-loc-xref` so the script is idempotent: it strips its own
prior anchors before recomputing.
Headings are aligned to the English source positionally: the translation script
(build_scripts/translate-content.py) rejects deliveries whose heading count
differs from the English source, so positional alignment is safe for anything it
wrote. If the heading count differs anyway (a hand-edited or stale translation),
the file is skipped and reported rather than risk a misaligned anchor.
Frontmatter and fenced code blocks are skipped. Injected anchors are tagged
`data-loc-xref` so the script is idempotent: it strips its own prior anchors
before recomputing.

English (`en`) is never modified - it is the source of the slugs.

Expand Down
Loading
Loading