From 6d3a84077965e5fb8665d83addff44f61a600d81 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Fri, 31 Jul 2026 17:47:22 +0000 Subject: [PATCH 1/3] Add tooling and build support for translated docs sites A manually-run tool under scripts/docs/i18n translates prose pages and navigation labels into the languages registered in i18n/languages.yml, guided by per-language instruction files and glossaries, with structural and meaning checks before anything is written. The docs build stages each language into its own site under // (English pages overlaid with the available translations plus status banners) with translated navigation and a language switcher. Human-authored inputs and generated output both live under i18n/; docs/translations.md explains the model to readers. --- .gitattributes | 3 + .github/ISSUE_TEMPLATE/translation.yaml | 57 +++ .github/workflows/deploy-docs.yml | 2 + .github/workflows/docs-preview.yml | 12 +- .github/workflows/shared.yml | 4 +- .gitignore | 9 +- .pre-commit-config.yaml | 7 + CONTRIBUTING.md | 4 + docs/translations.md | 24 ++ i18n/README.md | 59 +++ i18n/banners/en/disclosure.md | 4 + i18n/banners/en/english-only.md | 2 + i18n/banners/en/outdated.md | 2 + i18n/banners/en/stale.md | 2 + i18n/banners/en/untranslated.md | 2 + i18n/banners/ja/disclosure.md | 4 + i18n/banners/ja/english-only.md | 2 + i18n/banners/ja/outdated.md | 2 + i18n/banners/ja/untranslated.md | 2 + i18n/banners/ko/disclosure.md | 4 + i18n/banners/ko/english-only.md | 2 + i18n/banners/ko/outdated.md | 2 + i18n/banners/ko/untranslated.md | 2 + i18n/banners/pt-BR/disclosure.md | 4 + i18n/banners/pt-BR/english-only.md | 2 + i18n/banners/pt-BR/outdated.md | 2 + i18n/banners/pt-BR/untranslated.md | 2 + i18n/banners/zh-CN/disclosure.md | 4 + i18n/banners/zh-CN/english-only.md | 2 + i18n/banners/zh-CN/outdated.md | 2 + i18n/banners/zh-CN/untranslated.md | 2 + i18n/general-prompt.md | 59 +++ i18n/languages.yml | 45 ++ i18n/languages/ja/glossary.json | 295 +++++++++++++ i18n/languages/ja/instructions.md | 168 ++++++++ i18n/languages/ko/glossary.json | 225 ++++++++++ i18n/languages/ko/instructions.md | 140 ++++++ i18n/languages/pt-BR/glossary.json | 232 ++++++++++ i18n/languages/pt-BR/instructions.md | 190 +++++++++ i18n/languages/zh-CN/glossary.json | 232 ++++++++++ i18n/languages/zh-CN/instructions.md | 146 +++++++ mkdocs.yml | 1 + pyproject.toml | 19 + scripts/docs/build.sh | 93 +++- scripts/docs/build_config.py | 216 ++++++++-- scripts/docs/check_anchors.py | 248 +++++++++++ scripts/docs/check_crossrefs.py | 21 +- scripts/docs/gen_ref_pages.py | 17 +- scripts/docs/i18n/__init__.py | 8 + scripts/docs/i18n/__main__.py | 18 + scripts/docs/i18n/cli.py | 543 ++++++++++++++++++++++++ scripts/docs/i18n/client.py | 144 +++++++ scripts/docs/i18n/config.py | 243 +++++++++++ scripts/docs/i18n/files.py | 30 ++ scripts/docs/i18n/glossary.py | 119 ++++++ scripts/docs/i18n/inputs.py | 85 ++++ scripts/docs/i18n/markdown.py | 322 ++++++++++++++ scripts/docs/i18n/nav.py | 167 ++++++++ scripts/docs/i18n/pages.py | 127 ++++++ scripts/docs/i18n/prompts.py | 245 +++++++++++ scripts/docs/i18n/stage.py | 276 ++++++++++++ scripts/docs/i18n/state.py | 171 ++++++++ scripts/docs/i18n/status.py | 104 +++++ scripts/docs/i18n/translate.py | 437 +++++++++++++++++++ scripts/docs/i18n/validate.py | 420 ++++++++++++++++++ scripts/docs/llms_txt.py | 91 ++-- scripts/docs/navigation.py | 100 +++++ scripts/serve-docs.sh | 8 +- tests/docs_i18n/__init__.py | 0 tests/docs_i18n/_repo.py | 183 ++++++++ tests/docs_i18n/test_build_config.py | 120 ++++++ tests/docs_i18n/test_check_anchors.py | 55 +++ tests/docs_i18n/test_cli.py | 454 ++++++++++++++++++++ tests/docs_i18n/test_client.py | 99 +++++ tests/docs_i18n/test_config.py | 39 ++ tests/docs_i18n/test_files.py | 35 ++ tests/docs_i18n/test_glossary.py | 91 ++++ tests/docs_i18n/test_markdown.py | 196 +++++++++ tests/docs_i18n/test_nav.py | 86 ++++ tests/docs_i18n/test_navigation.py | 40 ++ tests/docs_i18n/test_pages.py | 98 +++++ tests/docs_i18n/test_prompts.py | 137 ++++++ tests/docs_i18n/test_stage.py | 211 +++++++++ tests/docs_i18n/test_state.py | 86 ++++ tests/docs_i18n/test_translate.py | 323 ++++++++++++++ tests/docs_i18n/test_validate.py | 271 ++++++++++++ uv.lock | 166 ++++++++ 87 files changed, 8802 insertions(+), 126 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/translation.yaml create mode 100644 docs/translations.md create mode 100644 i18n/README.md create mode 100644 i18n/banners/en/disclosure.md create mode 100644 i18n/banners/en/english-only.md create mode 100644 i18n/banners/en/outdated.md create mode 100644 i18n/banners/en/stale.md create mode 100644 i18n/banners/en/untranslated.md create mode 100644 i18n/banners/ja/disclosure.md create mode 100644 i18n/banners/ja/english-only.md create mode 100644 i18n/banners/ja/outdated.md create mode 100644 i18n/banners/ja/untranslated.md create mode 100644 i18n/banners/ko/disclosure.md create mode 100644 i18n/banners/ko/english-only.md create mode 100644 i18n/banners/ko/outdated.md create mode 100644 i18n/banners/ko/untranslated.md create mode 100644 i18n/banners/pt-BR/disclosure.md create mode 100644 i18n/banners/pt-BR/english-only.md create mode 100644 i18n/banners/pt-BR/outdated.md create mode 100644 i18n/banners/pt-BR/untranslated.md create mode 100644 i18n/banners/zh-CN/disclosure.md create mode 100644 i18n/banners/zh-CN/english-only.md create mode 100644 i18n/banners/zh-CN/outdated.md create mode 100644 i18n/banners/zh-CN/untranslated.md create mode 100644 i18n/general-prompt.md create mode 100644 i18n/languages.yml create mode 100644 i18n/languages/ja/glossary.json create mode 100644 i18n/languages/ja/instructions.md create mode 100644 i18n/languages/ko/glossary.json create mode 100644 i18n/languages/ko/instructions.md create mode 100644 i18n/languages/pt-BR/glossary.json create mode 100644 i18n/languages/pt-BR/instructions.md create mode 100644 i18n/languages/zh-CN/glossary.json create mode 100644 i18n/languages/zh-CN/instructions.md create mode 100644 scripts/docs/check_anchors.py create mode 100644 scripts/docs/i18n/__init__.py create mode 100644 scripts/docs/i18n/__main__.py create mode 100644 scripts/docs/i18n/cli.py create mode 100644 scripts/docs/i18n/client.py create mode 100644 scripts/docs/i18n/config.py create mode 100644 scripts/docs/i18n/files.py create mode 100644 scripts/docs/i18n/glossary.py create mode 100644 scripts/docs/i18n/inputs.py create mode 100644 scripts/docs/i18n/markdown.py create mode 100644 scripts/docs/i18n/nav.py create mode 100644 scripts/docs/i18n/pages.py create mode 100644 scripts/docs/i18n/prompts.py create mode 100644 scripts/docs/i18n/stage.py create mode 100644 scripts/docs/i18n/state.py create mode 100644 scripts/docs/i18n/status.py create mode 100644 scripts/docs/i18n/translate.py create mode 100644 scripts/docs/i18n/validate.py create mode 100644 scripts/docs/navigation.py create mode 100644 tests/docs_i18n/__init__.py create mode 100644 tests/docs_i18n/_repo.py create mode 100644 tests/docs_i18n/test_build_config.py create mode 100644 tests/docs_i18n/test_check_anchors.py create mode 100644 tests/docs_i18n/test_cli.py create mode 100644 tests/docs_i18n/test_client.py create mode 100644 tests/docs_i18n/test_config.py create mode 100644 tests/docs_i18n/test_files.py create mode 100644 tests/docs_i18n/test_glossary.py create mode 100644 tests/docs_i18n/test_markdown.py create mode 100644 tests/docs_i18n/test_nav.py create mode 100644 tests/docs_i18n/test_navigation.py create mode 100644 tests/docs_i18n/test_pages.py create mode 100644 tests/docs_i18n/test_prompts.py create mode 100644 tests/docs_i18n/test_stage.py create mode 100644 tests/docs_i18n/test_state.py create mode 100644 tests/docs_i18n/test_translate.py create mode 100644 tests/docs_i18n/test_validate.py diff --git a/.gitattributes b/.gitattributes index 0ab3744850..0ac8e0fe36 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,5 @@ # Generated uv.lock linguist-generated=true +i18n/languages/*/pages/** linguist-generated=true +i18n/languages/*/state.json linguist-generated=true +i18n/languages/*/nav.yml linguist-generated=true diff --git a/.github/ISSUE_TEMPLATE/translation.yaml b/.github/ISSUE_TEMPLATE/translation.yaml new file mode 100644 index 0000000000..9342fe89cf --- /dev/null +++ b/.github/ISSUE_TEMPLATE/translation.yaml @@ -0,0 +1,57 @@ +name: 🌐 Translation problem +description: Report a wrong, awkward, or misleading passage in a translated docs page +labels: ["translation"] + +body: + - type: markdown + attributes: + value: | + The translated docs are machine-generated from the English pages; https://py.sdk.modelcontextprotocol.io/translations/ explains how. + Fixes never go into the translated text directly. They go into that language's glossary or style guide under `i18n/languages/`, so you can also open a PR there instead of an issue. + + - type: dropdown + id: language + attributes: + label: Language + options: + - Simplified Chinese (zh-CN) + - Japanese (ja) + - Korean (ko) + - Brazilian Portuguese (pt-BR) + validations: + required: true + + - type: input + id: page + attributes: + label: Page URL + description: The translated page where you found the problem. + placeholder: https://py.sdk.modelcontextprotocol.io/ja/servers/tools/ + validations: + required: true + + - type: textarea + id: passage + attributes: + label: The passage + description: Quote the translated text that's wrong, and the English it corresponds to if you have it. + validations: + required: true + + - type: textarea + id: problem + attributes: + label: What's wrong, or how it should read + description: A wrong term, awkward phrasing, meaning that drifted from the English, tone that's off. If you know the better rendering, give it. + validations: + required: true + + - type: dropdown + id: native-speaker + attributes: + label: Are you a native or fluent speaker of this language? + options: + - "Yes" + - "No" + validations: + required: true diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 334ba818c8..87f8cba904 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -12,6 +12,8 @@ on: # docs pages include their code blocks from these files via `--8<--`, so a # change here changes the rendered site even when no .md file moves. - docs_src/** + # translated pages, banners and the language registry ship in the site + - i18n/** - mkdocs.yml - src/mcp/** - src/mcp-types/** diff --git a/.github/workflows/docs-preview.yml b/.github/workflows/docs-preview.yml index 6f9ec2cc34..d674ea6ed7 100644 --- a/.github/workflows/docs-preview.yml +++ b/.github/workflows/docs-preview.yml @@ -21,6 +21,7 @@ on: paths: - docs/** - docs_src/** + - i18n/** - mkdocs.yml - scripts/docs/** - pyproject.toml @@ -137,7 +138,16 @@ jobs: # /preview-docs, still build with MkDocs. Both arms must write the site # to site/. Keep the detection in sync with build_site() in # scripts/build-docs.sh. - - run: | + # + # DOCS_SITE_URL is the preview's Cloudflare branch-alias host (deploy + # publishes to `--branch=pr-`, served at pr-..pages.dev), + # so the absolute links the build bakes point at the preview instead of + # production; empty when no Pages project is configured, which makes + # build.sh fall back to the production site_url. + - env: + DOCS_SITE_URL: >- + ${{ vars.CLOUDFLARE_PAGES_PROJECT && format('https://pr-{0}.{1}.pages.dev', needs.authorize.outputs.pr_number, vars.CLOUDFLARE_PAGES_PROJECT) || '' }} + run: | if [ -f scripts/docs/build.sh ]; then bash scripts/docs/build.sh else diff --git a/.github/workflows/shared.yml b/.github/workflows/shared.yml index 541fc7bb54..16a1b7d392 100644 --- a/.github/workflows/shared.yml +++ b/.github/workflows/shared.yml @@ -139,7 +139,9 @@ jobs: - name: Check README snippets are up to date run: uv run --frozen scripts/update_readme_snippets.py --check - # `scripts/docs/build.sh` is the whole gauntlet: build_config.py fails on + # `scripts/docs/build.sh` is the whole gauntlet: it opens with the fast, + # no-network gates (a prose heading without a pinned anchor, an invalid + # translation config or committed translation), then build_config.py fails on # nav entries without a page and pages without a nav entry, `zensical build # --strict` fails on broken .md links, `pymdownx.snippets: check_paths: # true` fails on a deleted `docs_src/` include, and the post-build steps diff --git a/.gitignore b/.gitignore index 2e788e71d8..1334196169 100644 --- a/.gitignore +++ b/.gitignore @@ -144,10 +144,15 @@ venv.bak/ # documentation /site /.worktrees/ -# Generated at build time by scripts/docs/ (the API reference tree and the -# concrete Zensical config spliced from mkdocs.yml). +# Local scratch notes are never part of the repo. +/notes/ +# Generated at build time by scripts/docs/ (the API reference tree, the +# concrete Zensical configs spliced from mkdocs.yml, and the staged per-language +# docs trees). /docs/api/ /mkdocs.gen.yml +/mkdocs.*.gen.yml +/.build/ # mypy .mypy_cache/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 321b60bc52..05404dae8f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,6 +11,9 @@ repos: hooks: - id: prettier types_or: [yaml, json5] + # The translation tool writes each language's state.json and nav.yml + # in its own stable format; a formatter rewrite would fight the generator. + exclude: ^i18n/languages/[^/]+/(state\.json|nav\.yml)$ - repo: https://github.com/igorshubovych/markdownlint-cli rev: v0.45.0 @@ -25,6 +28,10 @@ repos: "/tool/markdown/lint", ] types: [markdown] + # Machine-translated pages are generated artefacts: corrections flow + # through i18n/languages//{instructions.md,glossary.json}, never + # through hand or linter edits to the pages themselves. + exclude: ^i18n/languages/[^/]+/pages/ - repo: local hooks: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b0fb9fa57b..8da38e982a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -126,6 +126,10 @@ pre-commit run --all-files - Add type hints to all functions - Include docstrings for public APIs +## Documentation and Translations + +Documentation contributions are English only: the pages under `docs/` are the source of truth, and the translated documentation sites are generated from them, guided by the per-language style guides and glossaries under `i18n/languages//`. Never edit the generated pages under `i18n/languages//pages/`—the next translation run overwrites them. To fix a translation, change that language's `instructions.md` or `glossary.json` (or the English page, if that's where the problem is), and the fix carries into every future run. See [`i18n/README.md`](i18n/README.md) for the details. + ## Pull Requests By the time you open a PR, the "what" and "why" should already be settled in an issue. This keeps reviews focused on implementation. diff --git a/docs/translations.md b/docs/translations.md new file mode 100644 index 0000000000..694466d6f9 --- /dev/null +++ b/docs/translations.md @@ -0,0 +1,24 @@ +# Translations {#translations} + +This documentation is written in English. To make it useful to more people, we also publish it in a few other languages. Those editions are machine-translated, and this page explains what that means for you and how to help improve them. + +## What's available {#whats-available} + +Translated documentation is currently a **preview**, published in four languages: Simplified Chinese, Japanese, Korean and Brazilian Portuguese. Pick one from the language switcher at the top of any page. + +Every translated page opens with a note saying it was machine-translated and linking to its English original. The API reference is not translated: every language site links to the single English one. + +## English is the source of truth {#english-is-the-source-of-truth} + +If a translated page and its English original disagree, the English page is correct. Two situations are called out on the page itself: + +- A page that hasn't been translated yet shows the English text, with a note saying so. +- A page whose English original changed after it was translated carries a warning that it may be behind, until the translation catches up. + +## How the translations are made {#how-the-translations-are-made} + +Translated pages are generated by a tool in this repository from the English pages under `docs/`, guided by two human-written inputs per language: a style guide (register, tone, typography, how to handle jokes and idioms) and a glossary (which terms stay in English, and the required and forbidden renderings for the rest). The generated text is never edited by hand. Every improvement goes into those inputs instead, so it survives the next time the pages are regenerated. + +## Reporting a translation problem {#reporting-a-translation-problem} + +Found a wrong term, an awkward sentence, or a translation that says something the English doesn't? [Open a translation issue](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=translation.yaml) with the language, the page and the passage; reports from native speakers are especially valuable, and maintainers track these with the `translation` label. If you know the fix, propose it directly as a pull request against that language's style guide or glossary under [`i18n/languages/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/i18n/languages) — the correction then shows up on every affected page the next time the translations are regenerated. Problems with the English text itself are fixed in the pages under `docs/`, like any other documentation change. diff --git a/i18n/README.md b/i18n/README.md new file mode 100644 index 0000000000..2de97c2a9a --- /dev/null +++ b/i18n/README.md @@ -0,0 +1,59 @@ +# Documentation translations + +The English documentation under `docs/` is the source of truth. Everything in this directory either steers how those pages are machine-translated (human-authored inputs) or is the generated output of that process. The public-facing explanation lives at [`docs/translations.md`](../docs/translations.md); this file is for maintainers. + +## Layout + +- `languages.yml` — the language registry: one entry per language site (directory/URL code, native name, theme language, hreflang, enabled flag), the pages excluded from translation, and the model IDs the tool uses. The build and the tool both read it; nothing else hardcodes the language list. +- `general-prompt.md` — the translation rules shared by every language. Sent with every translation request. +- `banners//` — the notes staged onto pages in each language site: `disclosure.md` (machine-translated notice), `outdated.md` (may be behind the English page), `untranslated.md` (not translated yet), `stale.md` (translation withdrawn while it is refreshed — see below) and `english-only.md` (page deliberately kept in English). The build fills the `{english_url}` and `{translations_page_url}` placeholders; `banners/en/` is the fallback used when a language has no translated banner text. +- `languages//instructions.md` — register, voice, humour policy and typography for one language. Human-authored. +- `languages//glossary.json` — the termbase for one language: terms that stay in English, required renderings, and banned renderings. Human-authored, validated by the tool. +- `languages//pages/` — the generated translations, mirroring the paths under `docs/`. Never edited by hand (see below). +- `languages//state.json` — the generated record of what each translated page was built from. +- `languages//nav.yml` — the generated map from each English sidebar label in `mkdocs.yml` (section titles and explicit `Label: page.md` entries) to its translation, plus the record of what it was built from. The language build swaps these into the sidebar; a label without a rendering stays English. Never edited by hand. + +## The tool + +Translation, validation and housekeeping are handled by the package under `scripts/docs/i18n/`, which provides these commands: + +```bash +uv run --frozen --group docs python scripts/docs/i18n [options] +``` + +- `status [--lang ]` — for each enabled language, list pages that are missing, outdated (English changed since translation, or prompt/glossary fingerprint changed), current, and removable (English page deleted), plus the state of its `nav.yml` label map. No network. +- `translate --lang [--pages a.md b.md] [--nav] [--all-missing] [--all-outdated] [--limit N] [--dry-run] [--no-verify]` — translate the selected pages (network), and with `--nav` (re)generate the language's `nav.yml` label map; `--all-missing`/`--all-outdated` also pick the map up when it is missing or outdated. Nothing is selected unless you pass `--pages`, `--nav` or an `--all-*` flag. `--limit` caps pages only. `--dry-run` prints the assembled prompts (the nav prompt included) and exits without calling the API. +- `check [--lang ] [--pages ...]` — validate the committed translations and each language's `nav.yml`, no network; this is the CI gate for pull requests touching `i18n/`, and it only ever fails for a real integrity problem. Each page is checked as what it is: a *current* page (English source, glossary and instructions unchanged) gets the full structural validation against that English page; an *outdated* page is not measured against the English or the glossary that have since moved on — it only has to be well-formed by itself (front matter closes, every code fence closes, headings pin distinct well-formed anchors) and is reported so the next translation run refreshes it. Glossary rules are deliberately not enforced on an outdated page: a glossary edit is one of the things that makes a page outdated, and enforcing it offline would fail the gate until a fresh translation run landed. A page whose English source is gone is reported for `prune`, and a translation file with no `state.json` record is reported as untracked. For a `nav.yml` map, labels it lacks are merely behind and entries for labels the nav has dropped are reported as stale, neither failing the check (`--pages` narrows the run to pages only). English and glossary edits therefore never fail `check`; staleness is bannered by the build, not blocked here. +- `verify --lang --pages ...` — run the semantic review gate over the given pages (network). `translate` runs it on freshly generated text unless `--no-verify` is passed. +- `prune --lang ` — delete translated pages whose English source no longer exists and drop them from the state file. +- `stage --lang [--site-url URL]` and `languages` — build machinery used by `scripts/docs/build.sh`: `stage` assembles a language's docs tree under `.build/i18n//docs/` (English pages, translations laid over them, status banners stamped in, and links into the API reference pointed at the English one — a language site links to the single English API reference rather than building its own), and `languages` prints the enabled codes. `--site-url` names the URL the site is served from (the build script derives it from `DOCS_SITE_URL`, defaulting to `mkdocs.yml`'s `site_url`), which the banners' English-page links and the API-reference links are built on. A translation whose links no longer resolve against the English tree (its English page has since renamed or dropped a target the translation still links) is withdrawn rather than staged — the English page is served with the `stale` banner and `stage` prints a warning line for it — so an outdated translation can delay a language site but never break its build. + +Exit codes: `0` success, `1` validation or verification failures, `2` usage or configuration errors. The network commands read `ANTHROPIC_API_KEY` (or `ANTHROPIC_AUTH_TOKEN`) from the environment and fail fast if neither is set; the model IDs come from `languages.yml` and can be overridden with `MCP_DOCS_I18N_MODEL` and `MCP_DOCS_I18N_VERIFY_MODEL`. Run any command with `--help` for the full option list. + +## Correcting a translation + +Never edit the files under `languages//pages/` or a language's `nav.yml` — the next translation run overwrites them. Put the correction where the tool will pick it up: + +- A wrong or inconsistent term → add or fix an entry in that language's `glossary.json`. +- A recurring style or register problem (too formal, a joke that lands badly, an awkward loanword) → add a rule to that language's `instructions.md`, ideally with a short good/bad example. +- English text that is ambiguous or hard to translate → fix the English page under `docs/`. + +Editing the instructions or glossary changes their fingerprint, which marks that language's pages and nav map as outdated (see below), so the next `translate --all-outdated` run regenerates them with the fix in place. A wrong sidebar label is corrected the same way — through the glossary or instructions, then a `--nav` (or `--all-outdated`) run. + +Readers report problems through the "Translation problem" issue form (`.github/ISSUE_TEMPLATE/translation.yaml`), which applies the `translation` label; triage each report into one of the three fixes above. + +## How staleness works + +`languages//state.json` records, for every translated page, the git commit and content hash of the English page it was translated from, a hash per content block, the fingerprints of `general-prompt.md`, that language's `instructions.md` and `glossary.json`, and the model and timestamp of the run. A page is a set of blocks: its front matter, the text before the first `##` heading, then one block per `##` section, so an edit to one section only re-translates that section and the untouched blocks are carried over verbatim from the previous translation. + +`status` compares those records against the current English tree: a page is *missing* if it has no translation, *outdated* if any recorded hash or fingerprint no longer matches, *current* otherwise, and *removable* if its English source is gone. The docs build reads the same state to add a "may be behind the English page" note to outdated pages; pages with no translation are served from the English source with a "not translated yet" note. An outdated page whose translation still links a page the English tree has since renamed or removed is a special case: it would break the language site's strict build, so `status` reports it as "links broken by an English change — English shown until refreshed" and the build serves the English page with the `stale` banner until the next translation run rewrites it. Its `state.json` record already marks it outdated, so nothing else is needed — the next `translate --all-outdated` run refreshes it like any other outdated page. + +The nav map follows the same contract at a smaller grain. `languages//nav.yml` records a hash of the ordered English label list plus the same three prompt-input fingerprints, so it is *missing* until first generated, *outdated* when a label is added, removed or reworded in `mkdocs.yml` or when the general prompt, instructions or glossary change, and *current* otherwise. A retranslation carries every label whose prompt inputs are unchanged over from the previous map byte-for-byte, so only new labels come from the model; a glossary or instructions edit re-asks the whole list. A label with no rendering is shown in English rather than failing the build. + +## Adding a language + +1. Add an entry to `languages.yml` (code, native name, theme language, hreflang, `enabled: true`). +2. Create `languages//instructions.md` with these sections in this order: register, voice, humour and idioms, typography, terminology, and a provisional note. Keep it tight; it is sent verbatim with every request. +3. Create `languages//glossary.json`: the `keep_in_source_language` list plus the seed `terms` entries. +4. Optionally add `banners//` with translated banner text; without it the English banners are used. +5. Run `status --lang ` to see what is missing, then `translate --lang --all-missing`. The `pages/` tree, `state.json` and `nav.yml` are generated by that run; commit them. diff --git a/i18n/banners/en/disclosure.md b/i18n/banners/en/disclosure.md new file mode 100644 index 0000000000..0c6567b5d6 --- /dev/null +++ b/i18n/banners/en/disclosure.md @@ -0,0 +1,4 @@ +??? info "This page was machine-translated" + Translations of this documentation are generated automatically from the English pages, and the [English version of this page]({english_url}) is the authoritative one. + + Found a translation problem? See [how the translations work and how to report an issue]({translations_page_url}). diff --git a/i18n/banners/en/english-only.md b/i18n/banners/en/english-only.md new file mode 100644 index 0000000000..039a4ea1d7 --- /dev/null +++ b/i18n/banners/en/english-only.md @@ -0,0 +1,2 @@ +!!! note "Available in English only" + This page isn't part of the translated documentation, so you're reading it in English. [How the translations work]({translations_page_url}). diff --git a/i18n/banners/en/outdated.md b/i18n/banners/en/outdated.md new file mode 100644 index 0000000000..96b2eb043c --- /dev/null +++ b/i18n/banners/en/outdated.md @@ -0,0 +1,2 @@ +!!! warning "This translation may be behind the English page" + The English source changed after this page was last translated, so parts of it may be out of date. When in doubt, read the [English version of this page]({english_url}). diff --git a/i18n/banners/en/stale.md b/i18n/banners/en/stale.md new file mode 100644 index 0000000000..8ed5cfd183 --- /dev/null +++ b/i18n/banners/en/stale.md @@ -0,0 +1,2 @@ +!!! warning "This page's translation is being refreshed" + The English documentation changed under this page's translation, so you're reading the English version until the translation catches up. [How the translations work]({translations_page_url}). diff --git a/i18n/banners/en/untranslated.md b/i18n/banners/en/untranslated.md new file mode 100644 index 0000000000..664f8e9cd4 --- /dev/null +++ b/i18n/banners/en/untranslated.md @@ -0,0 +1,2 @@ +!!! note "Not translated yet" + This page hasn't been translated yet, so you're reading the English version. [How the translations work]({translations_page_url}). diff --git a/i18n/banners/ja/disclosure.md b/i18n/banners/ja/disclosure.md new file mode 100644 index 0000000000..7009ccf823 --- /dev/null +++ b/i18n/banners/ja/disclosure.md @@ -0,0 +1,4 @@ +??? info "このページは機械翻訳です" + このドキュメントの翻訳版は英語版のページから自動的に生成されており、正式な内容は[このページの英語版]({english_url})です。 + + 翻訳におかしな点を見つけましたか?[翻訳の仕組みと問題の報告方法]({translations_page_url})を参照してください。 diff --git a/i18n/banners/ja/english-only.md b/i18n/banners/ja/english-only.md new file mode 100644 index 0000000000..23f548a3c0 --- /dev/null +++ b/i18n/banners/ja/english-only.md @@ -0,0 +1,2 @@ +!!! note "このページは英語版のみです" + このページは翻訳の対象外のため、英語版のみを提供しています。以下は英語の原文です。[このページの英語版]({english_url})も参照してください。 diff --git a/i18n/banners/ja/outdated.md b/i18n/banners/ja/outdated.md new file mode 100644 index 0000000000..77f584c798 --- /dev/null +++ b/i18n/banners/ja/outdated.md @@ -0,0 +1,2 @@ +!!! warning "この翻訳は英語版より古い可能性があります" + このページが翻訳されたあとに英語版の原文が更新されたため、内容の一部が最新でない可能性があります。迷ったときは[このページの英語版]({english_url})を参照してください。 diff --git a/i18n/banners/ja/untranslated.md b/i18n/banners/ja/untranslated.md new file mode 100644 index 0000000000..c8b578d8bf --- /dev/null +++ b/i18n/banners/ja/untranslated.md @@ -0,0 +1,2 @@ +!!! note "このページはまだ翻訳されていません" + このページはまだ翻訳されていないため、英語版をそのまま表示しています。[翻訳の仕組み]({translations_page_url})も参照してください。 diff --git a/i18n/banners/ko/disclosure.md b/i18n/banners/ko/disclosure.md new file mode 100644 index 0000000000..5d91191cf0 --- /dev/null +++ b/i18n/banners/ko/disclosure.md @@ -0,0 +1,4 @@ +??? info "이 페이지는 기계 번역본입니다" + 이 문서의 번역은 영어 페이지를 바탕으로 자동 생성되며, 기준이 되는 것은 [이 페이지의 영어 원문]({english_url})입니다. + + 번역 문제를 발견했다면 [번역이 만들어지는 방식과 문제를 신고하는 방법]({translations_page_url})을 참고하세요. diff --git a/i18n/banners/ko/english-only.md b/i18n/banners/ko/english-only.md new file mode 100644 index 0000000000..fa7854224d --- /dev/null +++ b/i18n/banners/ko/english-only.md @@ -0,0 +1,2 @@ +!!! note "영어로만 제공되는 페이지입니다" + 이 페이지는 번역 대상이 아니므로 영어로만 제공되며, 아래에는 영어 원문이 그대로 표시됩니다. [이 페이지의 영어 원문]({english_url})도 참고하세요. diff --git a/i18n/banners/ko/outdated.md b/i18n/banners/ko/outdated.md new file mode 100644 index 0000000000..7bb50507ec --- /dev/null +++ b/i18n/banners/ko/outdated.md @@ -0,0 +1,2 @@ +!!! warning "이 번역은 영어 페이지보다 오래되었을 수 있습니다" + 이 페이지를 마지막으로 번역한 뒤에 영어 원문이 바뀌었으므로 일부 내용이 최신이 아닐 수 있습니다. 확실하지 않다면 [이 페이지의 영어 원문]({english_url})을 확인하세요. diff --git a/i18n/banners/ko/untranslated.md b/i18n/banners/ko/untranslated.md new file mode 100644 index 0000000000..ad77aa2e19 --- /dev/null +++ b/i18n/banners/ko/untranslated.md @@ -0,0 +1,2 @@ +!!! note "아직 번역되지 않았습니다" + 이 페이지는 아직 번역되지 않아 영어 원문이 그대로 표시됩니다. [번역이 만들어지는 방식]({translations_page_url})을 참고하세요. diff --git a/i18n/banners/pt-BR/disclosure.md b/i18n/banners/pt-BR/disclosure.md new file mode 100644 index 0000000000..87edecf49c --- /dev/null +++ b/i18n/banners/pt-BR/disclosure.md @@ -0,0 +1,4 @@ +??? info "Esta página foi traduzida por máquina" + As traduções desta documentação são geradas automaticamente a partir das páginas em inglês, e a [versão em inglês desta página]({english_url}) é a oficial. + + Encontrou um problema na tradução? Veja [como as traduções funcionam e como reportar um problema]({translations_page_url}). diff --git a/i18n/banners/pt-BR/english-only.md b/i18n/banners/pt-BR/english-only.md new file mode 100644 index 0000000000..68eb177154 --- /dev/null +++ b/i18n/banners/pt-BR/english-only.md @@ -0,0 +1,2 @@ +!!! note "Disponível apenas em inglês" + Esta página não é traduzida e existe apenas em inglês, então você está lendo o original em inglês. Veja também a [versão em inglês desta página]({english_url}). diff --git a/i18n/banners/pt-BR/outdated.md b/i18n/banners/pt-BR/outdated.md new file mode 100644 index 0000000000..5b58e5021a --- /dev/null +++ b/i18n/banners/pt-BR/outdated.md @@ -0,0 +1,2 @@ +!!! warning "Esta tradução pode estar desatualizada em relação à página em inglês" + O original em inglês mudou depois que esta página foi traduzida pela última vez, então partes dela podem estar desatualizadas. Na dúvida, leia a [versão em inglês desta página]({english_url}). diff --git a/i18n/banners/pt-BR/untranslated.md b/i18n/banners/pt-BR/untranslated.md new file mode 100644 index 0000000000..6b2571394f --- /dev/null +++ b/i18n/banners/pt-BR/untranslated.md @@ -0,0 +1,2 @@ +!!! note "Ainda não traduzida" + Esta página ainda não foi traduzida, então você está lendo a versão em inglês. Veja [como as traduções funcionam]({translations_page_url}). diff --git a/i18n/banners/zh-CN/disclosure.md b/i18n/banners/zh-CN/disclosure.md new file mode 100644 index 0000000000..2a6d4df5ec --- /dev/null +++ b/i18n/banners/zh-CN/disclosure.md @@ -0,0 +1,4 @@ +??? info "本页为机器翻译" + 这份文档的其他语言版本由英文页面自动翻译生成,[本页的英文版本]({english_url})是权威版本,如有出入以英文为准。 + + 发现翻译问题?请看[翻译是如何生成的以及如何反馈问题]({translations_page_url})。 diff --git a/i18n/banners/zh-CN/english-only.md b/i18n/banners/zh-CN/english-only.md new file mode 100644 index 0000000000..6847d7556b --- /dev/null +++ b/i18n/banners/zh-CN/english-only.md @@ -0,0 +1,2 @@ +!!! note "本页仅提供英文版" + 这一页不在翻译范围内,只提供英文版本,下面显示的是英文原文。也可以直接查看[本页的英文版本]({english_url})。 diff --git a/i18n/banners/zh-CN/outdated.md b/i18n/banners/zh-CN/outdated.md new file mode 100644 index 0000000000..5bf92a6876 --- /dev/null +++ b/i18n/banners/zh-CN/outdated.md @@ -0,0 +1,2 @@ +!!! warning "本页翻译可能落后于英文页面" + 英文原文在本页上次翻译之后已经修改,部分内容可能已经过时。拿不准时,请查看[本页的英文版本]({english_url})。 diff --git a/i18n/banners/zh-CN/untranslated.md b/i18n/banners/zh-CN/untranslated.md new file mode 100644 index 0000000000..fe94ff105a --- /dev/null +++ b/i18n/banners/zh-CN/untranslated.md @@ -0,0 +1,2 @@ +!!! note "本页尚未翻译" + 这一页还没有翻译,下面显示的是英文原文。想了解翻译是如何进行的,请看[翻译说明]({translations_page_url})。 diff --git a/i18n/general-prompt.md b/i18n/general-prompt.md new file mode 100644 index 0000000000..ddbbd71a9d --- /dev/null +++ b/i18n/general-prompt.md @@ -0,0 +1,59 @@ +# Translation rules + +You are translating a page of the MCP Python SDK documentation from English into the target language named in the language instructions that follow. The readers are software developers using the SDK. + +## Your role + +- Write natural, native-quality prose in the target language. The page should read as if a developer who is a native speaker wrote it, not as a translation. +- Keep the meaning exact. Do not add claims, drop caveats, reorder steps, or change the strength of a requirement (must / should / may). +- Follow the language instructions and the glossary strictly. Where the two disagree, the glossary wins. +- Translate the whole page. Never summarise, abridge, or leave a placeholder such as "translation continues below". + +## Never translate + +Copy the following byte-for-byte from the English source: + +- Fenced code blocks: the fence markers, the info string, and every line inside them, including code comments. +- Inline code spans (text between backticks). +- URLs and link destinations, including `#fragment` anchors, and image paths. +- HTML tags and their attribute values. +- Front matter keys (the `key:` part of each front matter line). +- Snippet-include lines containing `--8<--`. +- Code-annotation markers such as `# (1)!`. +- Heading anchor attributes: the `{#some-id}` at the end of a heading (it may also be written with spaces, `{ #some-id }`; copy it exactly as it appears). +- The syntax markers for admonitions, collapsible blocks and content tabs (`!!!`, `???`, `???+`, `///`, `===`) and the block-type keyword that follows them (`note`, `tip`, `warning`, ...). +- Footnote labels (`[^1]`), abbreviation definitions (`*[HTML]: ...`) and emoji shortcodes (`:smile:`). +- Mermaid diagram source inside `mermaid` fences. + +Do translate the human-language text around those elements: prose, headings, link text, image alt text, table cells, list items, admonition titles (the quoted text after `!!! type`) and bodies, and content-tab labels (the quoted text after `===`). + +## Preserve the structure exactly + +The translation must have the same shape as the English source, block for block: + +- The same headings, at the same levels, in the same order, each ending in the same `{#anchor}` attribute as the source. +- The same number and type of admonitions, collapsible blocks and content-tab groups, in the same order. +- The same tables, with the same number of rows and columns. +- The same lists (same nesting, same number of items) and the same code fences (same count, same info strings, identical contents). +- The same links and images, in the same order. Never add, remove or merge a link. +- The same footnotes, and the same blank lines separating blocks. + +Do not add explanatory notes, translator's remarks or extra examples. + +## Links and anchors + +- Keep every link destination exactly as written in the source, whether it is an absolute URL, a relative path such as `../servers/tools.md`, or a bare `#anchor`. Only the link text is translated. +- Do not add anchors the source does not have, and never rewrite a fragment: heading anchors are pinned in the English source, so the same `#id` is valid on every language site. +- Preserve the link syntax the source uses (Markdown `[text](target)` or HTML ``). + +## Updating an existing translation + +When the request includes a previous translation of the page and marks which sections of the English page changed: + +- Outside the changed sections, reproduce the previous translation verbatim. Do not rephrase, "improve" or re-punctuate text whose English has not changed. +- Inside the changed sections, translate the new English following all of the rules above, and keep terminology, register and tone consistent with the surrounding unchanged text. +- A section is the page's front matter, the text before the first second-level heading, or one second-level heading (`##`) together with everything under it up to the next. + +## Output + +Return only the translated Markdown document, from its first line to its last. Do not add a preamble, a summary or any commentary, and do not wrap the document in a code fence. diff --git a/i18n/languages.yml b/i18n/languages.yml new file mode 100644 index 0000000000..ec652c645f --- /dev/null +++ b/i18n/languages.yml @@ -0,0 +1,45 @@ +# Registry of the translated documentation sites. English at docs/ is the +# source; every entry below is one machine-translated site published next to it +# at //. The docs build and the tool under scripts/docs/i18n/ both read +# this file — nothing else lists the languages. + +languages: + - code: zh-CN # directory under i18n/languages/ and the site's URL prefix + name: 简体中文 # native name shown in the language switcher + theme_language: zh # the theme's `theme.language` (UI strings, search) + hreflang: zh-Hans # value announced in + script: cjk # writing system; non-latin scripts get the untranslated-English check + enabled: true + - code: ja + name: 日本語 + theme_language: ja + hreflang: ja + script: cjk + enabled: true + - code: ko + name: 한국어 + theme_language: ko + hreflang: ko + script: hangul + enabled: true + - code: pt-BR + name: Português (Brasil) + theme_language: pt-BR + hreflang: pt-BR + script: latin + enabled: true + +# Pages that are never translated: the language sites serve their English +# text. Nav paths as written in mkdocs.yml; a trailing /** covers a subtree. +# (The API reference is not a page set here: a language site does not carry +# it at all and links to the English reference instead.) +exclude_pages: + - migration.md + +# Model IDs for the translation and semantic-review passes. Both must be +# publicly documented Claude model IDs; the reviewer is deliberately the +# stronger of the two. MCP_DOCS_I18N_MODEL / MCP_DOCS_I18N_VERIFY_MODEL and the +# CLI flags override them. +models: + translate: claude-opus-5 + verify: claude-fable-5 diff --git a/i18n/languages/ja/glossary.json b/i18n/languages/ja/glossary.json new file mode 100644 index 0000000000..ba38f88367 --- /dev/null +++ b/i18n/languages/ja/glossary.json @@ -0,0 +1,295 @@ +{ + "version": 1, + "keep_in_source_language": [ + "MCP", + "Model Context Protocol", + "MCPServer", + "FastMCP", + "ClientSession", + "Context", + "ctx", + "stdio", + "Streamable HTTP", + "SSE", + "JSON-RPC", + "JSON", + "OAuth", + "PKCE", + "JWT", + "CIMD", + "HTTP", + "HTTPS", + "TLS", + "CORS", + "URI", + "URL", + "ASGI", + "WebSocket", + "API", + "SDK", + "CLI", + "IDE", + "LLM", + "SEP", + "RFC", + "Python", + "TypeScript", + "Node.js", + "PyPI", + "Pydantic", + "Starlette", + "FastAPI", + "uvicorn", + "httpx", + "anyio", + "asyncio", + "trio", + "pytest", + "OpenTelemetry", + "Inspector", + "Claude", + "GitHub", + "VS Code", + "Windows", + "macOS", + "Linux", + "llms.txt", + "2026-07-28", + "2025-11-25", + "2025-06-18", + "2025-03-26" + ], + "terms": [ + { + "source": "tool", + "target": "ツール", + "note": "MCP protocol noun (a server exposes tools). Standard rendering. Wire identifiers such as `tools/call` and `tools/list` are code and stay Latin.", + "avoid": [], + "enforce": false + }, + { + "source": "resource", + "target": "リソース", + "note": "MCP protocol noun, and also the general noun (a pool acquired in a lifespan is still リソース). Standard rendering. Never 資源, which is the natural-resources sense; `resources/read` stays Latin.", + "avoid": ["資源"], + "enforce": true + }, + { + "source": "prompt", + "target": "プロンプト", + "note": "The MCP feature (a reusable prompt a server exposes) and the everyday word; プロンプト in both senses. Standard rendering. `prompts/get` stays Latin.", + "avoid": [], + "enforce": false + }, + { + "source": "sampling", + "target": "サンプリング", + "note": "The (deprecated) client feature that lets a server borrow the client's model. Standard rendering. Never 標本抽出, which is statistical sampling and the wrong sense; the `sampling` capability key and `sampling/createMessage` stay Latin.", + "avoid": ["標本抽出"], + "enforce": true + }, + { + "source": "roots", + "target": "ルート", + "note": "The (deprecated) client feature listing workspace folders. Provisional pending native review: ルート also spells \"route\" and \"root path\", so gloss the English on first use per page — ルート(roots). Never ルーツ (ancestry/origins). A `Root` object in code font stays Latin.", + "avoid": ["ルーツ"], + "enforce": true + }, + { + "source": "elicitation", + "target": "エリシテーション", + "note": "OPEN QUESTION for native review: there is no established Japanese term for the server asking the user a question mid-request. Provisionally pinned to the transliteration エリシテーション, glossed with the English on its first appearance per page — エリシテーション(elicitation). Do not substitute 誘導 or 引き出し unless review settles on one. `elicitation/create` and the `Elicit` class stay Latin.", + "avoid": [], + "enforce": false + }, + { + "source": "capability", + "target": "ケイパビリティ", + "note": "A negotiated protocol capability (what a client or server declared it supports). Provisional pending native review: ケイパビリティ rather than the general-purpose 機能 (feature) or 能力 (ability). The `capabilities` field and keys such as `sampling.tools` stay Latin.", + "avoid": [], + "enforce": false + }, + { + "source": "transport", + "target": "トランスポート", + "note": "The connection mechanism (\"every standard transport\" → 標準のトランスポート). Standard rendering; never 輸送 (freight transport) or 輸送手段. The transport names stdio, Streamable HTTP and SSE stay in English.", + "avoid": ["輸送"], + "enforce": true + }, + { + "source": "session", + "target": "セッション", + "note": "An MCP session (the negotiated connection state). Standard rendering; never the coinage 会期. `session` objects in code font stay Latin.", + "avoid": ["会期"], + "enforce": false + }, + { + "source": "handler", + "target": "ハンドラー", + "note": "The tool, resource or prompt function you register (nav section \"Inside your handler\" → ハンドラーの中で). Standard word; the long-vowel spelling ハンドラー (not ハンドラ) follows the katakana rule in instructions.md and is provisional pending native review.", + "avoid": [], + "enforce": false + }, + { + "source": "dependency", + "target": "依存関係", + "note": "The SDK's parameter-injection feature (the \"Dependencies\" page → 依存関係). Provisional pending native review: the pattern name \"dependency injection\" is customarily 依存性の注入, so a page may use that phrase for the pattern while individual dependencies are 依存関係. The `Resolve` marker class stays Latin.", + "avoid": [], + "enforce": false + }, + { + "source": "client", + "target": "クライアント", + "note": "An MCP client, and the client side of a connection. Standard rendering; never 顧客 (a customer). The `Client` class name stays Latin in code font.", + "avoid": ["顧客"], + "enforce": true + }, + { + "source": "server", + "target": "サーバー", + "note": "An MCP server (the program you build). Standard rendering with the long-vowel mark — サーバー, never サーバ (see instructions.md). The low-level `Server` class stays Latin in code font.", + "avoid": [], + "enforce": false + }, + { + "source": "host", + "target": "ホスト", + "note": "The MCP host: the application that embeds the client and drives the model, and also a network host. Standard rendering in both senses; never 宿主 (a biological host).", + "avoid": ["宿主"], + "enforce": true + }, + { + "source": "context", + "target": "コンテキスト", + "note": "The generic lower-case word (\"provide context to LLMs\" → LLM にコンテキストを提供する). Provisional pending native review: pin one spelling per corpus — コンテキスト, not コンテクスト. The capitalised `Context` is the SDK object injected as `ctx`; it is on the keep-in-source list and stays Latin in prose (\"The Context\" → Context).", + "avoid": ["コンテクスト"], + "enforce": false + }, + { + "source": "resolver", + "target": "リゾルバー", + "note": "The function attached to a parameter with `Resolve(...)` that computes or asks for its value. Provisional pending native review: the loanword リゾルバー, not 解決器. The `Resolve` class stays Latin.", + "avoid": ["解決器"], + "enforce": false + }, + { + "source": "lifespan", + "target": "ライフスパン", + "note": "The server's startup/shutdown scope (the \"Lifespan\" page, as in the ASGI lifespan). Provisional pending native review: the loanword ライフスパン, not 寿命 (the biological sense). Not enforced, because the neighbouring English word \"lifetime\" (\"for the lifetime of the host app\") can legitimately render as 寿命 in the same block. The `lifespan` parameter name stays Latin in code font.", + "avoid": ["寿命"], + "enforce": false + }, + { + "source": "deprecated", + "target": "非推奨", + "note": "Advisory status: still works, scheduled for removal later — 非推奨, not 廃止 (which reads as already removed); \"removed\" is 削除. \"Deprecation warning\" → 非推奨の警告; the `MCPDeprecationWarning` class stays Latin. Provisional pending native review.", + "avoid": ["廃止"], + "enforce": false + }, + { + "source": "back-channel", + "target": "バックチャネル", + "note": "The server-to-client request channel that exists only on legacy connections. Provisional coinage pending native review: gloss the English on first use per page — バックチャネル(back-channel). Not the older spelling バックチャンネル.", + "avoid": ["バックチャンネル"], + "enforce": false + }, + { + "source": "wire", + "target": "通信路", + "note": "The corpus's light metaphor for the byte stream between client and server (\"stdout is the wire\" → stdout が通信路そのものです; \"invisible on the wire\" → 通信上には現れません; \"the JSON on the wire\" → 実際に送受信される JSON). Provisional pending native review. Never a literal 電線 or ワイヤー.", + "avoid": ["電線", "ワイヤー"], + "enforce": false + }, + { + "source": "era", + "target": "世代", + "note": "\"Protocol era\" (\"a 2025-era client\", \"whatever era the client speaks\") → プロトコルの世代, 2025 年世代のクライアント. Provisional pending native review; not the literal 時代.", + "avoid": ["時代"], + "enforce": false + }, + { + "source": "legacy", + "target": "レガシー", + "note": "\"A legacy connection/client\" = one negotiated at spec version 2025-11-25 or earlier → レガシー接続, レガシークライアント (prenominal loanword). Provisional pending native review.", + "avoid": [], + "enforce": false + }, + { + "source": "multi-round-trip", + "target": "マルチラウンドトリップ", + "note": "The 2026-07-28 request pattern (\"Multi-round-trip requests\" → マルチラウンドトリップリクエスト); a single \"round trip\" → ラウンドトリップ or 往復 by context. Provisional coinage pending native review: gloss the English on first use per page — マルチラウンドトリップ(multi-round-trip). The abbreviation MRTR stays Latin.", + "avoid": [], + "enforce": false + }, + { + "source": "handshake", + "target": "ハンドシェイク", + "note": "The initialization handshake (\"the classic handshake\" → 従来のハンドシェイク). The established loanword; never the literal 握手. Provisional pending native review.", + "avoid": ["握手"], + "enforce": true + }, + { + "source": "request", + "target": "リクエスト", + "note": "A JSON-RPC or HTTP request (\"the initialize request\" → 初期化リクエスト); the verb is リクエストする or 要求する by context. `Request` types in code font stay Latin. Provisional pending native review.", + "avoid": [], + "enforce": false + }, + { + "source": "response", + "target": "レスポンス", + "note": "A JSON-RPC or HTTP response; `Response` types in code font stay Latin. Provisional pending native review.", + "avoid": [], + "enforce": false + }, + { + "source": "callback", + "target": "コールバック", + "note": "Client callbacks and OAuth redirect callbacks alike; parameter names such as `sampling_callback` stay Latin. Provisional pending native review.", + "avoid": [], + "enforce": false + }, + { + "source": "decorator", + "target": "デコレーター", + "note": "The Python decorators the SDK is built on; `@mcp.tool()` and its siblings are code and stay untouched. Long-vowel spelling per instructions.md. Provisional pending native review.", + "avoid": [], + "enforce": false + }, + { + "source": "type hint", + "target": "型ヒント", + "note": "Python type hints (\"from your type hints\" → 型ヒントから). Provisional pending native review.", + "avoid": [], + "enforce": false + }, + { + "source": "argument", + "target": "引数", + "note": "A call argument; the declared parameter is パラメーター (see the katakana rule in instructions.md). Provisional pending native review.", + "avoid": [], + "enforce": false + }, + { + "source": "return value", + "target": "戻り値", + "note": "A function's return value; the `return` keyword and return annotations are code. Provisional pending native review.", + "avoid": [], + "enforce": false + }, + { + "source": "exception", + "target": "例外", + "note": "A raised Python exception (\"raises an exception\" → 例外を送出する); exception class names stay Latin. Provisional pending native review.", + "avoid": [], + "enforce": false + }, + { + "source": "async", + "target": "非同期", + "note": "The prose adjective (\"the async runtime\" → 非同期ランタイム, \"an async callback\" → 非同期コールバック); the `async` and `await` keywords in code font stay Latin. Provisional pending native review.", + "avoid": [], + "enforce": false + } + ] +} diff --git a/i18n/languages/ja/instructions.md b/i18n/languages/ja/instructions.md new file mode 100644 index 0000000000..5672755782 --- /dev/null +++ b/i18n/languages/ja/instructions.md @@ -0,0 +1,168 @@ +# Japanese (ja) — translation instructions + +Target language: Japanese (日本語), directory and URL code `ja`, page language +tag `ja`. This file is sent verbatim with every translation request for this +language, on top of the shared rules in `../../general-prompt.md`. The +termbase in `glossary.json` is sent alongside it and wins any terminology +conflict with this file. + +## 1. Register + +Write body prose in the polite です・ます form (敬体), consistently, on every +page — tutorials, reference tables, admonitions and troubleshooting entries +alike. + +- Never mix in だ・である (常体) sentence endings within body text, and do + not escalate into honorifics (尊敬語・謙譲語): 使うときは, not + お使いいただく際には. +- Headings, table headers, content-tab labels and other UI-like fragments + are noun phrases (体言止め) or the plain dictionary form of a verb, never + です・ます: "Run it" → 実行する or 実行方法, "The Context" → Context, + "Handling errors" → エラーの処理. A heading phrased as a question in + English may stay a question in the plain form: "Where does this go?" → + これはどこに置くべきか. +- Instructions to the reader: 〜してください for a step to perform, + 〜します / 〜できます for describing what code does, 〜しないでください + for prohibitions. Prefer 〜です over 〜になります / 〜となります when both + are grammatical. +- The reader is never named. Do not translate "you" / "your" as あなた, + あなたの, 君, ユーザー様: drop the subject, which Japanese does + naturally, or restructure the sentence. "You can pass a schema" → + スキーマを渡せます. Where a subject is unavoidable, name the role — + サーバー, クライアント, ツール, 呼び出し側 — never a pronoun. "Your server" + is サーバー, or 自分のサーバー / 作成中のサーバー only when the ownership + is the point. +- One page, one register: a page that drifts between です・ます and である, + or that reintroduces あなた, is wrong even when each sentence is + acceptable on its own. + +## 2. Voice + +The English source is warm, direct and confident: short sentences, second +person, and the occasional one-line payoff ("That's the whole API."). Carry +that voice into natural Japanese; do not flatten it into formality, and do not +mirror the English word for word. + +- Guide, don't lecture. The reader should feel accompanied by a knowledgeable + colleague, not addressed by a notice. Directness comes from concrete verbs + and plain word order; warmth comes from the polite register itself, + considerate connectives (まず, ここでは, なお) and the occasional + 〜してみましょう / 〜してみてください for an encouraging aside. +- Keep the short payoff sentences short: "That's the whole API." → + API はこれだけです。 — not a formal summary sentence. +- Split long English sentences; follow Japanese rhythm rather than the + source's clause structure, but never merge, drop or reorder the technical + claims themselves. +- Anti-patterns — the stiff, legalistic translationese that Japanese + technical translations drift into by default: no 〜なのである / + 〜のである; no nominalisation chains (〜の実施を行うことにより → + 〜すると); no boilerplate such as 〜するものとします or 〜が求められます + where 〜してください is meant; no stacked ただし / なお clauses; no + needlessly formal kanji where kana reads more easily (できる not 出来る). + The opposite over-correction is also wrong: no よ endings, no + buddy-casual tone, and ね at most sparingly in tutorial prose, never in + reference pages. + +Example — English: "You don't construct it and you don't configure it. You +ask for it." + +- Not this (translationese): 利用者がその構築および構成を実施する必要はなく、 + 要求のみを行うものとする。 +- Not this either (pronoun + casual): あなたはそれを構築しないし、設定もしない。 + 要求するだけだよ。 +- This: 自分で組み立てる必要も、設定する必要もありません。要求するだけです。 + +## 3. Humour and idioms + +- Translate the intent of a joke, aside or idiom, never its words. Recast + it as a friendly plain sentence carrying the same information; if a + lighthearted phrase carries no information at all, keep the sentence brief + and natural rather than inventing a Japanese joke. Never drop the technical + content around it. +- Recurring English tags get fixed renderings: "X has the whole story" / + "The whole story is in X" → 詳しくは X を参照してください; + "That's it. It's just Python." → これだけです。ただの Python です。 +- Idioms take the plain meaning, not the picture: "Out of the box the app + answers **only** requests addressed to localhost." → デフォルトでは、この + アプリは localhost 宛てのリクエストに**だけ**応答します。 — not the literal + 箱から出してすぐ. +- Exclamation marks: drop them by default. Keep a single full-width ! + only where the English is a genuine exclamation of encouragement, never + after a warning or instruction, never doubled, never in a heading. +- Emoji: reproduce an emoji only where the English page has one, in the same + place (the source occasionally closes a step with ✨); never add emoji and + never put one in a heading. + +## 4. Typography + +- Punctuation is full-width 「、」 and 「。」; never 「,」「.」, and never a + half-width `,` or `.` closing Japanese prose. A colon that introduces a + code block, list or example becomes 「:」, or better a complete sentence + ending in 「。」 (次のように書きます。). +- Full-width forms inside Japanese text: 「」 for quoted terms and English + scare quotes, 『』 for nested quotes and titles, ? and ! when kept, and + () always — Japanese parentheses are full-width even when they enclose + only Latin text or code, as in the first-use gloss ルート(roots). +- Widths: kana and kanji full-width, no half-width katakana; Latin letters, + digits and code half-width. Counting uses half-width Arabic numerals + (3 つの答え, not 三つ), except in set phrases such as 一度 or 一部. +- Spacing: insert one half-width space between Japanese text and any + half-width run — an English word, a number, an inline code span, a link + whose text is Latin: Python の型ヒント, `Context` を受け取ります, + MCP サーバー. No space next to 「、」「。」 or full-width brackets + (`ctx.session` を使うと、), and none inside katakana compounds + (エラーメッセージ, ツール呼び出し). This spacing convention is provisional; + apply it uniformly. +- No italics: Japanese type has no true italic. When the English + italicises a word that gets translated, drop the emphasis or use 「」; + keep `**bold**` where the source has it, and keep the bold on negations + (**not** → **ではありません** / **しません**). Emphasis markers around + text that stays in English are copied as-is. +- Dashes and ranges: an English em-dash aside is recast with 、, () or a + second sentence, not with a ――; ranges use から (3.10 から 3.14), not 〜 + or –. +- Sentence length: one idea per sentence and at most three 「、」. In one + bulleted list, items either all end in 「。」 (complete sentences) or none + do (fragments). + +## 5. Terminology pointer + +The glossary is sent separately and takes precedence over anything here. +It holds every term-by-term rendering — the six core MCP nouns and the +everyday computing vocabulary alike — and marks each one as standard, +provisional or an open question; use its renderings and its first-use +glosses exactly as noted. The rules below are the conventions those +renderings assume. + +- Identifiers stay in Latin script exactly as written: class, function, + method, parameter, environment-variable, error and package names, + protocol method names such as `tools/call`, and everything in code font. + Product and standard names, and every term under + `keep_in_source_language`, stay in English too (MCP, Streamable HTTP, + JSON-RPC, OAuth, the SDK's class names, spec revision dates such as + 2026-07-28), always in the singular: an English plural "s" is dropped, + "the APIs" → API. Do not append a katakana reading after them. +- A term the glossary marks for a first-use gloss carries the English in + full-width parentheses on its first appearance in a page — ルート(roots), + エリシテーション(elicitation) — and appears alone after that. A glossary + word used as a wire identifier or a key in code font is code and stays + Latin. +- Katakana loanwords take the long-vowel mark for -er, -or and -ar endings: + サーバー (never サーバ), ハンドラー, リゾルバー, ユーザー, パラメーター, + ヘッダー, フォルダー, プロバイダー. Words ending in -y keep their customary + short form: プロパティ, ディレクトリ, ライブラリ, セキュリティ, メモリ. Words + ending in -ware take ウェア: ミドルウェア, ソフトウェア. +- Katakana compounds are written solid, without a space or a 中黒: + エラーメッセージ, プロトコルバージョン (use ・ only between two proper + names). +- Prefer the established loanword over an invented native coinage; the + glossary lists the settled pairs (セッション not 会期, トランスポート not + 輸送手段, ハンドシェイク not 握手). + +## 6. Provisional note + +These conventions are provisional and awaiting review by native +Japanese-speaking contributors. To propose a change — a better rendering, a +rule that produces awkward Japanese, a term that needs pinning — edit this +file, or `glossary.json` next to it, in a pull request. The generated pages +are never edited by hand; they are regenerated from these inputs. diff --git a/i18n/languages/ko/glossary.json b/i18n/languages/ko/glossary.json new file mode 100644 index 0000000000..9fbdbdc3c8 --- /dev/null +++ b/i18n/languages/ko/glossary.json @@ -0,0 +1,225 @@ +{ + "version": 1, + "keep_in_source_language": [ + "MCP", + "Model Context Protocol", + "MCPServer", + "FastMCP", + "ClientSession", + "Context", + "ctx", + "stdio", + "Streamable HTTP", + "SSE", + "JSON-RPC", + "JSON", + "OAuth", + "PKCE", + "JWT", + "CIMD", + "HTTP", + "HTTPS", + "TLS", + "CORS", + "URI", + "URL", + "ASGI", + "WebSocket", + "API", + "SDK", + "CLI", + "IDE", + "LLM", + "SEP", + "RFC", + "Python", + "TypeScript", + "Node.js", + "PyPI", + "Pydantic", + "Starlette", + "FastAPI", + "uvicorn", + "httpx", + "anyio", + "asyncio", + "trio", + "pytest", + "OpenTelemetry", + "Inspector", + "Claude", + "GitHub", + "VS Code", + "Windows", + "macOS", + "Linux", + "llms.txt", + "2026-07-28", + "2025-11-25", + "2025-06-18", + "2025-03-26" + ], + "terms": [ + { + "source": "tool", + "target": "도구", + "note": "MCP primitive: an action the model chooses and calls. The wire identifiers `tools/call` and `tools/list` and any code identifiers stay in Latin script inside code font. Provisional pending native review.", + "avoid": ["툴"], + "enforce": false + }, + { + "source": "resource", + "target": "리소스", + "note": "MCP primitive: read-only data the application reads. `resources/read` and other identifiers stay Latin in code font. Provisional pending native review; 자원 is the general system-resources word and is avoided here.", + "avoid": ["자원"], + "enforce": false + }, + { + "source": "prompt", + "target": "프롬프트", + "note": "Both the MCP primitive (a message template a person invokes) and the general LLM sense; 프롬프트 in both. Provisional pending native review.", + "avoid": [], + "enforce": false + }, + { + "source": "sampling", + "target": "샘플링", + "note": "MCP feature where the server asks the client's model for a completion; not the statistics sense (표본 추출). Provisional pending native review.", + "avoid": ["표본 추출", "표집"], + "enforce": false + }, + { + "source": "roots", + "target": "루트", + "note": "Filesystem locations the client exposes. No plural marker (write 루트, not 루트들); the `roots/list` identifier stays Latin. Target provisional pending native review; 뿌리 (the botanical root) is never correct here.", + "avoid": ["뿌리"], + "enforce": true + }, + { + "source": "elicitation", + "target": "엘리시테이션", + "note": "Transliteration; write 엘리시테이션(elicitation) at the first occurrence on a page, 엘리시테이션 alone afterwards. The verb \"elicit\" in prose is 사용자에게 입력을 요청하다; `ctx.elicit(...)` stays code. Provisional pending native review — the least settled term in this file; 유도 is the candidate alternative to confirm against, so it is noted here rather than banned.", + "avoid": ["도출"], + "enforce": false + }, + { + "source": "capability", + "target": "기능", + "note": "A negotiated protocol feature (\"capability negotiation\" → 기능 협상); the `capabilities` wire field and `client.server_capabilities` stay Latin in code. Provisional pending native review — 기능 doubles as \"feature\"; native review should confirm the collision is acceptable.", + "avoid": ["역량", "능력"], + "enforce": false + }, + { + "source": "transport", + "target": "트랜스포트", + "note": "The countable, named connection mechanism (stdio 트랜스포트, 인메모리 트랜스포트) and the `Transport` protocol name in code. Prose describing the generic idea of how messages are carried may say 전송 방식 without implying a separate term. Provisional pending native review; 운송/수송 (freight transport) are never correct.", + "avoid": ["운송", "수송"], + "enforce": true + }, + { + "source": "session", + "target": "세션", + "note": "Standard loanword; `ctx.session` and other identifiers stay code. Provisional pending native review.", + "avoid": [], + "enforce": false + }, + { + "source": "handler", + "target": "핸들러", + "note": "The function registered for a tool, resource or prompt. 핸들러 rather than 처리기, to match modern Korean developer docs and the code vocabulary. Provisional pending native review.", + "avoid": ["처리기"], + "enforce": false + }, + { + "source": "dependency", + "target": "의존성", + "note": "Dependency-injection sense (\"dependency injection\" → 의존성 주입); a package dependency in installation contexts is also 의존성. Provisional pending native review; 종속성 is the competing house style.", + "avoid": ["종속성"], + "enforce": false + }, + { + "source": "client", + "target": "클라이언트", + "note": "The protocol role. The `Client` and `ClientSession` class names stay Latin in code font (see keep list). Provisional pending native review; 고객 means a customer and is never correct here.", + "avoid": ["고객"], + "enforce": true + }, + { + "source": "server", + "target": "서버", + "note": "The protocol role. The `MCPServer` and `Server` class names stay Latin in code font (see keep list). Provisional pending native review.", + "avoid": [], + "enforce": false + }, + { + "source": "host", + "target": "호스트", + "note": "\"MCP host\" is the application that embeds a client (a desktop assistant, an editor); the corpus also says \"ASGI host\" for the app that serves an ASGI app — 호스트 covers both. Provisional pending native review.", + "avoid": ["주최자"], + "enforce": false + }, + { + "source": "request", + "target": "요청", + "note": "A JSON-RPC or HTTP request; the verb \"to request\" is 요청하다. Provisional pending native review.", + "avoid": ["리퀘스트"], + "enforce": false + }, + { + "source": "response", + "target": "응답", + "note": "A JSON-RPC or HTTP response. Provisional pending native review.", + "avoid": ["리스폰스"], + "enforce": false + }, + { + "source": "context", + "target": "컨텍스트", + "note": "The \"context provided to a model\" sense (as in Model Context Protocol). The `Context` class and `ctx` are identifiers and stay Latin (see keep list). The everyday idiom \"in this context\" is not this term and may be recast (이 경우, 여기서는). Provisional pending native review; 맥락 is the competing rendering for the general word.", + "avoid": [], + "enforce": false + }, + { + "source": "notification", + "target": "알림", + "note": "Protocol notifications (`notifications/...` identifiers stay Latin); \"send a notification\" → 알림을 보내다. Provisional pending native review.", + "avoid": ["통지"], + "enforce": false + }, + { + "source": "resolver", + "target": "리졸버", + "note": "The SDK's `Resolve(...)`-annotated parameter resolvers. Provisional pending native review; 해석기 (parser/interpreter) and 해결자 are avoided.", + "avoid": ["해석기", "해결자"], + "enforce": false + }, + { + "source": "authorization", + "target": "인가", + "note": "Security sense: authorization → 인가 (인가 서버 for authorization server, 인가 코드 for authorization code), distinct from authentication → 인증. The `Authorization` HTTP header and code identifiers stay Latin. Provisional pending native review — 승인 and 권한 부여 are competing renderings to confirm against.", + "avoid": [], + "enforce": false + }, + { + "source": "deprecated", + "target": "지원 중단 예정", + "note": "Advisory status: still works but discouraged. First occurrence on a page may add the original: 지원 중단 예정(deprecated). \"Removed\" is a different word (제거됨); the corpus contrasts the two, so never render deprecated as 폐기됨 or 제거됨. Provisional pending native review.", + "avoid": ["폐기된", "폐기 예정"], + "enforce": false + }, + { + "source": "round-trip", + "target": "왕복", + "note": "A network round trip; \"multi-round-trip requests\" → 다중 왕복 요청. Provisional pending native review.", + "avoid": ["라운드 트립"], + "enforce": false + }, + { + "source": "lifespan", + "target": "lifespan", + "note": "Keep the English word in Korean prose (lifespan 함수, 호스트 앱의 lifespan), matching the `lifespan=` parameter it names. Provisional pending native review — 수명 주기 was rejected because it collides with \"lifecycle\" (생명 주기).", + "avoid": ["수명"], + "enforce": false + } + ] +} diff --git a/i18n/languages/ko/instructions.md b/i18n/languages/ko/instructions.md new file mode 100644 index 0000000000..2bea8d5811 --- /dev/null +++ b/i18n/languages/ko/instructions.md @@ -0,0 +1,140 @@ +# Korean (ko) — translation instructions + +Target language: Korean (한국어), directory and URL code `ko`, page language +tag `ko`. This file is sent verbatim with every translation request for this +language, on top of the shared rules in `../../general-prompt.md`. The +termbase in `glossary.json` is sent alongside it and wins any terminology +conflict with this file. + +## 1. Register + +Write 합쇼체 (formal-polite, sentence endings in -습니다 / -ㅂ니다) as the one +register for the whole page. + +- Body prose, list items, table cells and admonition bodies end in -습니다 / + -ㅂ니다: "The SDK does the rest." → SDK가 나머지를 처리합니다. +- Short imperatives (steps, instructions, calls to action) use -세요: + "Create a file `server.py`" → `server.py` 파일을 만드세요. Never -십시오, never + the bare 해요체 (-어요 / -예요 / -해요), and never plain-style -다 endings. +- Headings are noun phrases where the English heading is a noun phrase + ("Installation" → 설치). An English heading phrased as a sentence or a + question becomes a noun phrase too: "What's new in v2" → v2에서 달라진 점. + Do not write -나요? or -습니까? headings. +- Never address the reader with 당신, 여러분 or 우리. Korean drops the + subject: "you can pass a URL" → URL을 전달할 수 있습니다. Where a subject is + unavoidable, name the role — 클라이언트, 서버, 사용자 — never a pronoun. + "Your server" is 서버 or, when the contrast matters, 작성한 서버. +- One page, one register. Mixing -습니다 with -어요, or -세요 with -십시오, is + wrong even when each sentence is correct on its own. + +## 2. Voice + +Warm, direct and considerate: the reader is a capable developer being +guided by a colleague, not lectured by a manual. + +- Keep the source's directness and its short payoff sentences. "That's a + complete MCP server." → 이것으로 완전한 MCP 서버가 완성됩니다. Do not pad the + translation with hedges the English does not have. +- A brief friendly aside is welcome in Korean too — 참고로, 다행히, a plain + 환영합니다 — as long as it stays in 합쇼체. +- Prefer verbs over noun stacks. "Configuration of the transport" is 트랜스포트를 + 설정하는 방법, not 트랜스포트의 설정. +- Avoid translationese (번역체): + - no double passives: -되어지다 → -되다; no -할 것입니다 chains where -합니다 + says the same thing; + - no pronoun crutches: drop 그것, 그들, 이것들 — repeat the noun or restructure; + - do not stack a conditional marker on top of -면: drop 만약 when -(으)면 + already carries the condition; + - mark plurals sparingly: Korean rarely needs -들 ("the tools" → 도구); + - no honorific inflation: 살펴보시면 ✗ → 살펴보면 ✓ (-세요 endings are the + only place -시- appears); + - do not overuse -에 대해 / -에 대하여 where a plain object particle works. + +Example — English: "A **host** is the LLM application: Claude, an IDE, an +agent runtime. It's the thing the user is talking to." + +- Wrong (translationese): **호스트**는 LLM 애플리케이션입니다: Claude, IDE, + 에이전트 런타임. 그것은 사용자가 그것에게 이야기하는 것입니다. +- Right: **호스트**는 LLM 애플리케이션입니다. Claude, IDE, 에이전트 런타임이 여기에 + 해당하며, 사용자가 대화하는 상대가 바로 호스트입니다. + +## 3. Humour and idioms + +Translate the information, not the joke. + +- Idioms, puns and light asides are recast into a plain friendly 합쇼체 + sentence that carries the same fact, never translated word for word: "Out + of the box the app answers **only** requests addressed to localhost." → + 기본적으로 이 앱은 localhost로 오는 요청**만** 받습니다. — not 상자에서 꺼내자마자. +- Recurring English tags get fixed renderings: "**[X](…)** has the whole + story" / "The whole story is in **[X](…)**" → 자세한 내용은 **[X](…)**에서 + 확인하세요.; "That's the whole API." / "That's the whole protocol." → 이것이 + API의 전부입니다. / 프로토콜은 이것이 전부입니다.; "That's it. It's just Python." + → 이게 전부입니다. 평범한 Python일 뿐입니다. +- Exclamation marks: keep one only where the English is genuinely + emphatic; a routine sentence ends with 온점 even if the source ends in "!". +- Emoji: the source's only emoji are two ✨ closing payoff lines, and they + are dropped in Korean; the friendliness moves into the wording. "You get + `3` back. ✨" → `3`이 돌아옵니다. Emoji shortcodes (`:smile:`) are syntax and + are left exactly as the shared rules say. +- If a light aside has no natural Korean equivalent, replace it with a + neutral sentence stating the underlying point — never leave a gap and + never add a translator's note explaining the joke. + +## 4. Typography + +- Punctuation is ASCII: `. , ? ! ( )`. Never 。 、 「」 or full-width forms. + Every sentence, including -세요 imperatives, ends with 온점 `.`. +- No sentence-final colon or dash before a code block or list: "Try this:" + → 다음을 시도해 보세요. An English em-dash aside becomes a comma, a + parenthesis, or its own sentence — no ` — ` in Korean prose. +- Straight quotes only. No italics on Hangul: where the source italicises a + word that becomes Korean, use `**굵게**` or nothing; italics may stay + around Latin-script words. +- Spacing follows 한글 맞춤법: words are separated by spaces, but a + particle (조사) attaches to the word before it — also after Latin words and + code spans, with no space in between: Python은, MCP를, `add`를 호출합니다, + `Client`가 연결을 맺습니다. Latin words otherwise sit in the sentence like + Korean words, with normal spacing on each side. +- Choose the particle after a Latin word or code span by how the term is + read aloud: Python은 (파이썬), stdio는, MCP는 (엠씨피), `list_tools`를, + Streamable HTTP를. When the reading is unclear (symbols, mixed digits), + restructure so a Korean noun carries the particle — `x` 값을, `--port` + 옵션은. Never write the double form 은(는) / 을(를) / 이(가). +- Digits are ASCII; a unit or counter follows a numeral without a space: + 3개, 30초, 8000번 포트, 5MB. Version numbers and the protocol's date-shaped + revision strings are identifiers and are copied byte-for-byte (they are in + the glossary's keep list). A calendar date written out in prose, if any, + becomes 2026년 7월 28일. +- Parenthetical originals use ASCII parentheses with no space before them: + 엘리시테이션(elicitation). + +## 5. Terminology pointer + +The glossary (`glossary.json`) is injected separately and overrides this +file on every term it covers. These conventions apply to everything the +glossary does not pin: + +- Loanword spellings follow the standard 외래어 표기법: 서버, 클라이언트, + 콜백 (not 콜빽), 프롬프트, 세션, 토큰, 스키마, 데코레이터, 미들웨어. Where an + ICT term is not in the glossary, prefer the rendering that mainstream + Korean developer documentation uses; treat 국립국어원 and TTA usage as the + tie-breaker. +- Three strategies coexist and the glossary decides which applies per term: + transliterate established loanwords (스트림, 서버), translate into the common + Sino-Korean word where that is the mainstream (요청, 응답, 알림, 도구, 인가, + 의존성), and keep in Latin script anything that is an identifier or a + proper name — class and function names, wire method names such as + `tools/call`, package names, protocol and product names. +- 한글(English) 병기: the glossary marks a few MCP-specific nouns for a + parenthetical original on first mention only — 엘리시테이션(elicitation) once, + then 엘리시테이션. Class names never get a Hangul gloss. +- One term, one rendering, throughout the page. Do not alternate between + 객체 and 오브젝트, or between 컨텍스트 and 맥락, for the same source term. +- Abbreviations stay Latin and lose the English plural "s": "the APIs" → API. + +## 6. Provisional note + +Every decision in this file is provisional pending review by native Korean +speakers. To propose a change, edit this file (or `glossary.json`) in a pull +request — never edit the generated pages under `pages/`. diff --git a/i18n/languages/pt-BR/glossary.json b/i18n/languages/pt-BR/glossary.json new file mode 100644 index 0000000000..f18caee835 --- /dev/null +++ b/i18n/languages/pt-BR/glossary.json @@ -0,0 +1,232 @@ +{ + "version": 1, + "keep_in_source_language": [ + "MCP", + "Model Context Protocol", + "MCPServer", + "FastMCP", + "ClientSession", + "Context", + "ctx", + "stdio", + "Streamable HTTP", + "SSE", + "JSON-RPC", + "JSON", + "OAuth", + "PKCE", + "JWT", + "CIMD", + "HTTP", + "HTTPS", + "TLS", + "CORS", + "URI", + "URL", + "ASGI", + "WebSocket", + "API", + "SDK", + "CLI", + "IDE", + "LLM", + "SEP", + "RFC", + "Python", + "TypeScript", + "Node.js", + "PyPI", + "Pydantic", + "Starlette", + "FastAPI", + "uvicorn", + "httpx", + "anyio", + "asyncio", + "trio", + "pytest", + "OpenTelemetry", + "Inspector", + "Claude", + "GitHub", + "VS Code", + "Windows", + "macOS", + "Linux", + "llms.txt", + "2026-07-28", + "2025-11-25", + "2025-06-18", + "2025-03-26" + ], + "terms": [ + { + "source": "tool", + "target": "ferramenta", + "note": "MCP protocol noun (a server exposes tools), feminine: a ferramenta / as ferramentas. Wire identifiers such as `tools/call` and the `@mcp.tool()` decorator are code and stay untouched. First mention on a page may read \"ferramenta (tool)\". Provisional pending native review.", + "avoid": [], + "enforce": false + }, + { + "source": "resource", + "target": "recurso", + "note": "MCP protocol noun (data a server exposes for reading), masculine: o recurso / os recursos. `resources/read` and `@mcp.resource()` are code. Provisional pending native review.", + "avoid": [], + "enforce": false + }, + { + "source": "prompt", + "target": "prompt", + "note": "MCP protocol noun for the reusable message templates a server exposes, and the general AI sense; kept in English as Brazilian AI writing does. Masculine: o prompt / os prompts. `prompts/get` and `@mcp.prompt()` are code. Provisional pending native review.", + "avoid": [], + "enforce": false + }, + { + "source": "sampling", + "target": "amostragem", + "note": "MCP feature where the server asks the client for an LLM completion. First mention on a page reads \"amostragem (sampling)\" so the reader can map it to `sampling/createMessage`, which is code. Keeping the English word instead is the open alternative — provisional pending native review.", + "avoid": [], + "enforce": false + }, + { + "source": "roots", + "target": "roots", + "note": "MCP client capability (the directories a client exposes to the server); kept in English because readers meet it as the identifier `roots/list`. Masculine plural: os roots. May take the gloss \"roots (diretórios raiz)\" on first mention. Translating it as raízes is the open alternative — provisional pending native review.", + "avoid": ["raízes"], + "enforce": false + }, + { + "source": "elicitation", + "target": "elicitação", + "note": "MCP feature where the server asks the user a question through the client (`elicitation/create`, `ctx.elicit()` are code). The Portuguese noun is rare but exact; first mention on a page reads \"elicitação (elicitation)\". Feminine: a elicitação. Provisional pending native review.", + "avoid": [], + "enforce": false + }, + { + "source": "capability", + "target": "capacidade", + "note": "What client and server declare during initialization (\"negociação de capacidades\"). Note that funcionalidade is the word for a feature, which is a different thing. Provisional pending native review.", + "avoid": ["habilidade"], + "enforce": false + }, + { + "source": "transport", + "target": "transporte", + "note": "The connection layer. The individual transports — stdio, Streamable HTTP, SSE — are names on the keep list and stay in English (\"o transporte stdio\"). Provisional pending native review.", + "avoid": [], + "enforce": false + }, + { + "source": "session", + "target": "sessão", + "note": "Feminine: a sessão / as sessões (\"ID de sessão\"). The `session` object, `ClientSession` and `ServerSession` are code and stay Latin. Provisional pending native review.", + "avoid": [], + "enforce": false + }, + { + "source": "handler", + "target": "handler", + "note": "The functions you register on a server; kept in English as everyday Brazilian developer usage does. Masculine: o handler / os handlers. \"manipulador\" reads dated in this audience — provisional pending native review.", + "avoid": ["manipulador"], + "enforce": false + }, + { + "source": "dependency", + "target": "dependência", + "note": "Both package dependencies and the SDK's dependency injection — parameters declared with `Resolve(...)` (\"injeção de dependência\"). Feminine: a dependência. Provisional pending native review.", + "avoid": [], + "enforce": false + }, + { + "source": "resolver", + "target": "resolvedor", + "note": "The plain function that fills a `Resolve(...)`-annotated parameter before the tool runs. Rendered as the noun \"resolvedor\" (masculine: o resolvedor) because in Portuguese \"resolver\" is a verb; keeping the English noun is the open alternative — provisional pending native review.", + "avoid": [], + "enforce": false + }, + { + "source": "client", + "target": "cliente", + "note": "Masculine: o cliente. The `Client` class and the `mcp.client` module are code and stay Latin. Provisional pending native review.", + "avoid": [], + "enforce": false + }, + { + "source": "server", + "target": "servidor", + "note": "Masculine: o servidor. The `MCPServer`, `Server` and `ServerSession` classes are code and stay Latin. Provisional pending native review.", + "avoid": [], + "enforce": false + }, + { + "source": "host", + "target": "host", + "note": "The MCP host — the application the user talks to (Claude Desktop, an IDE) — kept in English as Brazilian developers say it. Masculine: o host / os hosts. Provisional pending native review.", + "avoid": ["anfitrião", "hospedeiro"], + "enforce": true + }, + { + "source": "request", + "target": "requisição", + "note": "HTTP and JSON-RPC requests (\"requisição HTTP\", \"a requisição de inicialização\"). Feminine: a requisição. solicitação is understood too, but use requisição throughout rather than alternating. Provisional pending native review.", + "avoid": ["pedido"], + "enforce": false + }, + { + "source": "token", + "target": "token", + "note": "Kept in English for both OAuth tokens (\"token de acesso\", \"refresh token\") and LLM tokens. Masculine: o token / os tokens. Provisional pending native review.", + "avoid": ["ficha"], + "enforce": false + }, + { + "source": "lifespan", + "target": "lifespan", + "note": "The SDK feature and its `lifespan=` parameter; kept in English so the prose matches the parameter name. Masculine: o lifespan. May take the gloss \"lifespan (ciclo de vida do servidor)\" on first mention. Provisional pending native review.", + "avoid": ["expectativa de vida", "vida útil"], + "enforce": true + }, + { + "source": "callback", + "target": "callback", + "note": "Kept in English, covering both OAuth redirect callbacks and function callbacks. Masculine: o callback / os callbacks. Provisional pending native review.", + "avoid": ["retorno de chamada"], + "enforce": false + }, + { + "source": "deploy", + "target": "deploy", + "note": "Borrow the noun, not a verb: \"o deploy\", \"fazer o deploy\", \"depois do deploy\". Masculine. \"implantação\" is acceptable in formal contexts but pin \"deploy\" for consistency. Provisional pending native review.", + "avoid": ["deployar", "deployado"], + "enforce": true + }, + { + "source": "library", + "target": "biblioteca", + "note": "Feminine: a biblioteca. The false friend \"livraria\" means a bookshop and is never right here.", + "avoid": ["livraria"], + "enforce": true + }, + { + "source": "back-channel", + "target": "canal de retorno", + "note": "This documentation's term for the server calling back into the client during a request. First mention on a page reads \"canal de retorno (back-channel)\" so the reader can connect it to the `NoBackChannelError` exception, which is code. Provisional pending native review.", + "avoid": [], + "enforce": false + }, + { + "source": "file", + "target": "arquivo", + "note": "Masculine: o arquivo. \"ficheiro\" is European Portuguese and is always an error in this Brazilian Portuguese target.", + "avoid": ["ficheiro"], + "enforce": true + }, + { + "source": "user", + "target": "usuário", + "note": "Masculine as the generic form: o usuário / os usuários. \"utilizador\" is European Portuguese and is always an error in this Brazilian Portuguese target.", + "avoid": ["utilizador"], + "enforce": true + } + ] +} diff --git a/i18n/languages/pt-BR/instructions.md b/i18n/languages/pt-BR/instructions.md new file mode 100644 index 0000000000..60b74c7484 --- /dev/null +++ b/i18n/languages/pt-BR/instructions.md @@ -0,0 +1,190 @@ +# Brazilian Portuguese (pt-BR) — translation instructions + +Target language: Brazilian Portuguese (Português do Brasil), directory and +URL code `pt-BR`, page language tag `pt-BR`. This file is sent verbatim with +every translation request for this language, on top of the shared translation +rules in `../../general-prompt.md`. The termbase in `glossary.json` is sent +alongside it and wins any terminology conflict with this file. + +## 1. Register + +Write the casual-neutral register Brazilian developer documentation uses: +professional, relaxed, and direct. + +- Address the reader as **você**, with third-person-singular verb forms to + match. Never o senhor / a senhora, never tu, never vós, and never a mix. + The rule holds in body prose, headings, admonition titles, table cells and + link text. +- Instructions and steps are direct imperatives in the você form: "Install + the SDK, then run the server" → Instale o SDK e depois execute o servidor — + not Instala o SDK (tu form), and not Você deve instalar o SDK (needless + modal). A bare imperative per step is fine; a por favor in front of every + step is not. +- Portuguese drops the subject pronoun freely. Write você where a sentence + needs an explicit subject or a contrast, and let the verb carry the person + otherwise; three or four você in one paragraph is a signal to rephrase. + Object pronouns follow the same person: para você / a você, never the + tu-form te / ti. +- The authorial "we" is nós (Recomendamos, chamamos), never the spoken + a gente. +- The register is uniform across a page. A page that drifts between você and + o senhor, or between direct imperatives and an impersonal officialese voice, + is wrong even when each sentence is acceptable on its own. +- This is Brazilian Portuguese only. Every European Portuguese form is an + error here: + - vocabulary: arquivo (never ficheiro), tela (never ecrã), usuário (never + utilizador), salvar (never guardar), excluir / apagar (never eliminar for + "delete"), baixar (never transferir / descarregar for "download"), mouse + (never rato), site (never sítio); + - grammar: the progressive is estar + gerund — o servidor está rodando — + never estar a + infinitive (está a rodar, está a correr); + - spelling: the post-1990 orthography — ação, ótimo, ideia — never acção, + óptimo, idéia. + +## 2. Voice + +Aim for the voice of an experienced Brazilian engineer explaining a library +to a colleague: warm, direct, plain-spoken. The English is built on short +declarative payoff sentences ("That's the whole API."); keep them short — Essa +é a API inteira. + +Do: + +- Follow Portuguese rhythm. Split a long English sentence into two Portuguese + ones instead of mirroring its clause chain, and use everyday connectives + (então, ou seja, por isso) where they help the reader along. +- Use concrete verbs (executar, passar, retornar, declarar, bloquear) rather + than nominal chains: fazer a execução de → executar. +- Keep the source's directness. Where the English says "don't", the + Portuguese says não faça isso / não use, not a hedge like talvez seja + interessante evitar. + +Avoid — these are the marks of a machine or bureaucratic translation: + +- Officialese and legalistic filler: o presente documento, supracitado, + outrossim, faz-se necessário, deve-se ressaltar que, and o mesmo used as a + pronoun. +- Gerundismo: vamos estar mostrando → vamos mostrar; irá estar retornando → + vai retornar. +- Verbified anglicisms from spoken developer slang: deployar, commitar, + buildar, startar, mergear. Write fazer o deploy, fazer commit, gerar o build, + iniciar, fazer o merge. +- English-shaped Portuguese: calqued idioms (sob o capô for "under the hood" — + the Brazilian phrase is por baixo dos panos; no fim do dia for "at the end + of the day" — say no fim das contas), possessive chains, and passives where + an active sentence is natural ("The tool is called by the model" → o modelo + chama a ferramenta, not a ferramenta é chamada pelo modelo). +- Marketing hype and stacked exclamation marks. Keep an exclamation mark only + where the English one carries genuine emphasis. + +## 3. Humour and idioms + +The English is friendly and dry rather than jokey — short payoff sentences, a +few stock phrases, the rare emoji — and Brazilian technical writing is warm by +default, so most of that carries over unchanged. The idioms still need +recasting. + +- Never translate a pun, idiom or aside literally. Say what it means as a + short, natural Brazilian sentence in the same register. Where a common + Brazilian idiom happens to carry the same meaning, use it; where nothing + fits, use the plain statement. If an aside carries no information you may + drop it — but never drop a technical caveat that happens to be phrased + lightly. +- Recurring English tags get fixed renderings: "**[X](…)** has the whole + story" / "The whole story is in **[X](…)**" → **[X](…)** tem a história + completa; "That's the whole API." / "That's the whole protocol." → A API + inteira é essa. / O protocolo inteiro é esse.; "That's it. It's just + Python." → É só isso. É apenas Python. +- Idioms take the plain meaning, not the picture: "Out of the box the app + answers **only** requests addressed to localhost." → Por padrão, o app + responde **apenas** a requisições endereçadas ao localhost — not a calqued + fora da caixa. +- Culture-bound references (US sports, TV shows, holidays) → the plain + meaning. +- Emoji: keep the source's rare, deliberately placed emoji exactly where they + are — two payoff lines end in ✨ ("You get `3` back. ✨"). Never add new + ones. + +Worked examples (source → good / bad): + +- "You get `3` back. ✨" → good: Você recebe `3` de volta. ✨ / bad: Você + recebe `3` de volta! ✨ (added exclamation mark). +- "Give a parameter a default value and it stops being required. That's it. + It's just Python." → good: Dê um valor padrão a um parâmetro e ele deixa de + ser obrigatório. É só isso. É apenas Python. / bad: Dê um valor default + para um parâmetro e ele para de ser requerido. É isso aí, é só Python! ✨ + (untranslated default and requerido, slangy tag, added exclamation and + emoji). + +## 4. Typography + +- Prose punctuation is standard Brazilian usage written with the same + characters the source uses: keep straight double quotes ("…") and + apostrophes as they are; do not switch to «guillemets» or “curly quotes”; no + inverted ¿ ¡; no space before ! ? : ; (that is a French convention). +- Sentence case for headings, admonition titles and content-tab labels: + capitalise the first word and proper nouns only (Configurando o transporte, + not Configurando O Transporte). Language names, months and weekdays are + lower-case in Portuguese (a versão em inglês, em julho); proper nouns stay + capitalised (Python, GitHub, Claude Desktop). +- Digits stay ASCII. Protocol revision strings such as `2026-07-28` and + `2025-11-25` are identifiers, copied byte-for-byte — never 28/07/2026, + never 28 de julho de 2026. Version numbers, HTTP status codes, ports, error + codes, and RFC and SEP numbers are copied exactly. +- Ordinary prose quantities take the decimal comma only when nothing but the + separator changes (a timeout of 2.5 seconds → um timeout de 2,5 segundos); + when in doubt, keep the number as the source writes it. A space separates a + number from a Latin unit (100 MB, 30 s); % attaches with no space (100%). +- Latin abbreviations: e.g. → por exemplo, i.e. → ou seja / isto é; etc. + stays etc.; vs → versus, or ou / contra when a plain word reads better. + Where the English uses & in prose, write e. +- Loanwords kept in English are set in normal type — no italics, no scare + quotes — and take a Portuguese article: o handler, os tokens, a string. + Bold and italics land on the same words the source emphasises; a bolded + negation ("**not**" → **não**) stays bold. +- Ordinals use the indicators º / ª (1º, 2ª). Keep the source's dashes, + colons and parentheses as they are; do not turn a colon into a travessão + or the reverse. + +## 5. Terminology pointer + +The termbase is `glossary.json` next to this file. It is injected into the +prompt separately and its renderings override anything written here. This +section only fixes the conventions the glossary assumes: + +- Terms listed under `keep_in_source_language` are copied exactly as they + appear in the English source — same spelling, casing and plural "s" (SDKs + stays SDKs). They are not translated, italicised, re-cased or wrapped in + quotes. +- Everything in code font — class, function, method, parameter and module + names, protocol method strings (`tools/call`, `notifications/...`), header + names, error text, config keys — stays byte-identical. You may put a + Portuguese article or the word for the kind of thing in front of it: a + classe `Context`, o parâmetro `lifespan=`, o método `session.list_tools()`. + A glossary term used as a code-font identifier stays Latin even though its + prose noun is translated: "the `sampling` capability" → a capacidade + `sampling`. +- English technical nouns that stay in English keep their English spelling, + take a fixed grammatical gender, and pluralise the Brazilian way (add "s"). + Masculine by default — o token / os tokens, o handler, o callback, o host, + o schema, o payload, o endpoint, o log, o loop, o build, o commit, o deploy, + o prompt, o middleware — feminine where usage is settled: a string, a + thread, a query, a flag, a tag, a URL, a API, a issue. Where a glossary + entry's note gives a gender, it wins. +- Nouns are borrowed, verbs are not: fazer o deploy, fazer commit, fazer o + merge — never deployar, commitar, mergear (see §2). +- First-use gloss: a translated MCP concept the reader may need to map back to + the English specification carries the English in parentheses on its first + occurrence on a page — elicitação (elicitation) — and appears alone after + that. Each glossary entry's note says whether the term takes the gloss. +- One rendering per term per page: the glossary target, every time. Where an + entry's note marks the choice as open or provisional, still use the listed + target consistently rather than picking per sentence. + +## 6. Provisional note + +The register, voice and terminology decisions above are provisional, +pending review by native Brazilian Portuguese-speaking readers. To propose a +change, edit this file or `glossary.json` in a pull request — ideally with a +short good/bad example when the change is about phrasing; never edit the +generated pages under `pages/`, which the next translation run overwrites. diff --git a/i18n/languages/zh-CN/glossary.json b/i18n/languages/zh-CN/glossary.json new file mode 100644 index 0000000000..95cd2dc157 --- /dev/null +++ b/i18n/languages/zh-CN/glossary.json @@ -0,0 +1,232 @@ +{ + "version": 1, + "keep_in_source_language": [ + "MCP", + "Model Context Protocol", + "MCPServer", + "FastMCP", + "ClientSession", + "Context", + "ctx", + "stdio", + "Streamable HTTP", + "SSE", + "JSON-RPC", + "JSON", + "OAuth", + "PKCE", + "JWT", + "CIMD", + "HTTP", + "HTTPS", + "TLS", + "CORS", + "URI", + "URL", + "ASGI", + "WebSocket", + "API", + "SDK", + "CLI", + "IDE", + "LLM", + "SEP", + "RFC", + "Python", + "TypeScript", + "Node.js", + "PyPI", + "Pydantic", + "Starlette", + "FastAPI", + "uvicorn", + "httpx", + "anyio", + "asyncio", + "trio", + "pytest", + "OpenTelemetry", + "Inspector", + "Claude", + "GitHub", + "VS Code", + "Windows", + "macOS", + "Linux", + "llms.txt", + "2026-07-28", + "2025-11-25", + "2025-06-18", + "2025-03-26" + ], + "terms": [ + { + "source": "tool", + "target": "工具", + "note": "MCP protocol noun (a server exposes tools). Wire identifiers such as `tools/call` and `tools/list` are code and stay Latin. Provisional pending native review.", + "avoid": [], + "enforce": false + }, + { + "source": "resource", + "target": "资源", + "note": "MCP protocol noun; \"resource template\" → 资源模板. `resources/read` stays Latin. Provisional pending native review.", + "avoid": [], + "enforce": false + }, + { + "source": "prompt", + "target": "提示词", + "note": "The MCP feature: a reusable prompt a server exposes (`prompts/get` stays Latin). Not the bare 提示, which reads as \"hint\" and is the standard rendering of the `tip` admonition title. Provisional pending native review.", + "avoid": [], + "enforce": false + }, + { + "source": "sampling", + "target": "采样", + "note": "The (deprecated) client feature that lets a server borrow the client's model. Gloss the English on first use per page: 采样(sampling). The `sampling` capability key and `sampling/createMessage` stay Latin. 抽样/取样 mean statistical sampling and are the wrong sense here.", + "avoid": ["抽样", "取样"], + "enforce": true + }, + { + "source": "roots", + "target": "根目录", + "note": "The (deprecated) client feature listing workspace folders; a `Root` object in code font stays Latin. Gloss the English on first use per page: 根目录(roots). Never the bare 根 and never 根节点 (a tree node). Provisional pending native review.", + "avoid": ["根节点"], + "enforce": false + }, + { + "source": "elicitation", + "target": "征询", + "note": "OPEN QUESTION for native review: existing Chinese material renders this concept as 引导, 引导获取, 询问 or 征询, and even our own drafts disagree between 征询 and 引导. Provisionally pinned to 征询, glossed with the English on first use per page: 征询(elicitation). Use it consistently within a page whichever way the review settles. `elicitation/create` and the `Elicit` class stay Latin.", + "avoid": [], + "enforce": false + }, + { + "source": "capability", + "target": "能力", + "note": "A negotiated protocol capability (声明了 `sampling` 能力). The `capabilities` field and keys such as `sampling.tools` stay Latin. Not 功能, which means \"feature\" — the corpus uses \"feature\" as a separate word. Provisional pending native review.", + "avoid": ["功能"], + "enforce": false + }, + { + "source": "transport", + "target": "传输", + "note": "As a countable noun use 传输方式 (\"three transports\" → 三种传输方式). The transport names stdio, Streamable HTTP and SSE stay in English. Provisional pending native review.", + "avoid": [], + "enforce": false + }, + { + "source": "session", + "target": "会话", + "note": "An MCP session (the negotiated connection state); `session` objects in code font stay Latin. Not 会议 (a meeting). Provisional pending native review.", + "avoid": ["会议"], + "enforce": false + }, + { + "source": "handler", + "target": "处理函数", + "note": "The tool, resource or prompt function you register (nav section \"Inside your handler\" → 在处理函数内部). Open question for native review: 处理器 is the competing rendering; pick one and never mix within a page. Provisional.", + "avoid": [], + "enforce": false + }, + { + "source": "dependency", + "target": "依赖", + "note": "The SDK's dependency-injection feature (the \"Dependencies\" page); use 依赖项 where a countable noun is needed. The `Resolve` marker class stays Latin. Provisional pending native review.", + "avoid": [], + "enforce": false + }, + { + "source": "client", + "target": "客户端", + "note": "An MCP client, and the client side of a connection. The `Client` class name stays Latin in code font. Not 客户 (a customer). Provisional pending native review.", + "avoid": [], + "enforce": false + }, + { + "source": "server", + "target": "服务器", + "note": "An MCP server (the program you build). \"server-side\" → 服务器端. The low-level `Server` class stays Latin in code font. Not 伺服器 (the zh-TW term); do not mix in 服务端 for the same noun. Provisional pending native review.", + "avoid": ["伺服器", "服务端"], + "enforce": false + }, + { + "source": "host", + "target": "宿主", + "note": "The MCP host: the application that embeds the client and drives the model. Not 主机 (a machine or hostname). Provisional pending native review.", + "avoid": ["主机"], + "enforce": false + }, + { + "source": "resolver", + "target": "解析器", + "note": "The SDK's dependency-resolver mechanism (\"an elicitation resolver\" → elicitation 解析器 in glossary terms: 征询解析器). The `Resolve` class stays Latin. Provisional pending native review, together with the handler entry.", + "avoid": [], + "enforce": false + }, + { + "source": "lifespan", + "target": "生命周期", + "note": "The server's startup/shutdown scope (the \"Lifespan\" page). The `lifespan` parameter name stays Latin in code font. 寿命 is the biological sense and is wrong here. Provisional pending native review.", + "avoid": ["寿命"], + "enforce": false + }, + { + "source": "callback", + "target": "回调", + "note": "Client callbacks; parameter names such as `sampling_callback` stay Latin. Provisional pending native review.", + "avoid": [], + "enforce": false + }, + { + "source": "notification", + "target": "通知", + "note": "A JSON-RPC notification (a message that expects no response); method strings such as `notifications/tools/list_changed` stay Latin. Provisional pending native review.", + "avoid": [], + "enforce": false + }, + { + "source": "back-channel", + "target": "反向通道", + "note": "The server-to-client request channel that exists only on 2025-era, non-stateless connections. Provisional coinage: gloss the English on first use per page — 反向通道(back-channel). Pending native review.", + "avoid": [], + "enforce": false + }, + { + "source": "wire", + "target": "线路", + "note": "The corpus's light metaphor for the transport stream (\"on the wire\" → 在线路上; \"nothing changes on the wire\" → 线路上没有任何变化). Never 电线 (a physical cable). Provisional pending native review.", + "avoid": ["电线"], + "enforce": false + }, + { + "source": "deprecated", + "target": "已弃用", + "note": "Also \"deprecation warning\" → 弃用警告 and \"X is deprecated\" → X 已弃用. Pin the 弃用 family throughout; do not switch to 废弃 or 淘汰 for the same concept. Provisional pending native review.", + "avoid": ["废弃", "淘汰"], + "enforce": false + }, + { + "source": "context", + "target": "上下文", + "note": "The generic lower-case word (\"provide context to LLMs\" → 为 LLM 提供上下文). The capitalised `Context` is the SDK object injected as `ctx`; both are on the keep-in-source list and stay in English in prose (\"The Context\" → Context). Provisional pending native review.", + "avoid": [], + "enforce": false + }, + { + "source": "multi-round-trip", + "target": "多轮往返", + "note": "The 2026-07-28 request pattern (\"Multi-round-trip requests\" → 多轮往返请求); \"round-trip\" alone → 往返. Provisional coinage: gloss the English on first use per page — 多轮往返(multi-round-trip). Pending native review.", + "avoid": [], + "enforce": false + }, + { + "source": "you", + "target": "你", + "note": "The register rule from instructions.md made machine-checkable: 您 must never appear on a page. Prefer dropping the pronoun; when one is needed it is 你.", + "avoid": ["您"], + "enforce": true + } + ] +} diff --git a/i18n/languages/zh-CN/instructions.md b/i18n/languages/zh-CN/instructions.md new file mode 100644 index 0000000000..273c2f3529 --- /dev/null +++ b/i18n/languages/zh-CN/instructions.md @@ -0,0 +1,146 @@ +# Simplified Chinese (zh-CN) — translation instructions + +Target language: Simplified Chinese (简体中文), directory and URL code +`zh-CN`, page language tag `zh-Hans`. This file is sent verbatim with every +translation request for this language, on top of the shared translation rules +in `../../general-prompt.md`. The termbase in `glossary.json` is sent +alongside it and wins any terminology conflict with this file. + +## 1. Register + +Write the casual-neutral written register that Chinese developer +documentation uses: plain, matter-of-fact, and even. + +- Address the reader as 你. Never use the honorific 您, and never mix the + two. The rule holds in body prose, headings, admonition titles, table + cells and link text. +- Prefer no pronoun at all when the sentence stays clear — Chinese + instructions read naturally without a subject: "You can pass a schema" → + 可以传入一个模式. Reach for 你 only where the sentence would otherwise be + ambiguous about who acts. +- Steps and instructions are bare imperatives without a subject: + "Run the server" → 运行服务器, not 请您运行服务器. A single 请 is fine where + it reads natural; a 请 in front of every step is not. +- The register is uniform across a page. A page that drifts between 你 and + 您, or between plain and formal sentence endings, is wrong even when each + sentence is acceptable on its own. + +## 2. Voice + +Aim for the voice of an experienced Chinese-speaking engineer explaining a +library to a colleague: warm, direct, professional, compact. The English is +built on short declarative payoff sentences ("That's a complete MCP +server."); keep them short — 这就是一个完整的 MCP 服务器。 + +Do: + +- Follow Chinese word order and rhythm. Break one long English sentence + into two Chinese ones instead of mirroring its clause structure. +- Use concrete verbs (运行, 传入, 返回, 声明, 阻塞) rather than nominal chains. +- Keep the source's directness. Where the English says "don't", the + Chinese says 不要, not a hedge like 也许可以考虑避免. + +Avoid — these are the marks of a machine or customer-service translation: + +- 您, and its whole register: 温馨提示, 亲, 敬请, 感谢您的耐心等待. +- Formal padding: 进行……操作, 对……进行处理, and 可能像下面这样, where a plain + 是这样的 does the job. +- English-shaped Chinese: possessive chains (你的服务器的工具的模式), 被 passives + where a topic-comment sentence is natural, and a translated connective + (然而, 因此, 此外) at the start of every sentence. +- Marketing hype and internet slang: 神器, 保姆级, 给力, 强大到没朋友. + +## 3. Humour and idioms + +The English is friendly and dry rather than jokey: short payoff sentences, +a few stock phrases, the rare emoji. Carry the friendliness; recast the +idioms. + +- Never translate a pun, idiom or aside literally. Say what it means as a + short, natural Chinese sentence in the same register. If an aside carries + no information you may drop it — but never drop a technical caveat that + happens to be phrased lightly. +- Recurring English tags get fixed renderings: "**[X](…)** has the whole + story" / "The whole story is in **[X](…)**" → 详见 **[X](…)**; + "That's the whole API." / "That's the whole protocol." → 整个 API 就这些。 + / 整个协议就是这样。; "That's it. It's just Python." → 就这样,只是普通的 + Python。 +- Idioms take the plain meaning, not the picture: "Out of the box the app + answers **only** requests addressed to localhost." → 默认情况下,这个应用 + **只**响应发往 localhost 的请求。— not the literal 开箱即用地. +- Emoji: keep the source's rare, deliberately placed emoji exactly where + they are — two payoff lines end in ✨ ("You get `3` back. ✨"). Never + add new ones. +- Exclamation marks are rare in Chinese technical prose and the English + hardly uses them; do not add one to a plain payoff sentence. + +Worked examples (source → good / bad): + +- "You get `3` back. ✨" → good: 返回值是 `3`。✨ / bad: 你会得到3!✨ + (missing Han–Latin spacing, added exclamation). +- "Give a parameter a default value and it stops being required. That's + it. It's just Python." → good: 给参数设一个默认值,它就不再是必填参数。 + 就这样,只是普通的 Python。/ bad: 给一个参数一个默认值,然后它就停止是必需的了。 + 就是它。它只是Python而已!(English-shaped 它 chain, missing Han–Latin + spacing, added exclamation). + +## 4. Typography + +- Chinese prose takes full-width punctuation: ,。:;!?、()“” with ‘’ + nested inside “”, the dash —— and the ellipsis ……. Punctuation inside + code spans, code blocks, commands, URLs and quoted English text stays + half-width and untouched. +- Enumerations in prose use the enumeration comma 、: "a, b, and c" → + a、b 和 c, not a,b,和 c. +- Put one half-width space between Han characters and any run of Latin + letters or digits (使用 Streamable HTTP 传输; 需要 Python 3.10+); put no + space between a full-width punctuation mark and adjacent Latin text + (配置好 stdio。). Keep the spaces around Markdown markers (`**…**`, + links) exactly as the source has them. +- No italics in Chinese text. Where the source italicises a word for + emphasis, use **bold**; where the source italicises an example utterance + or a hypothetical question the user might see, wrap it in “” instead. + Keep bold on the same words the source bolds — a bolded negation + ("**not**" → "**不是**" / "**不会**") stays bold. +- Digits stay half-width Arabic numerals. Protocol revision strings such + as `2026-07-28` and `2025-11-25` are identifiers, copied byte-for-byte — + never 2026年7月28日, never 2026/07/28. Other dates keep the source's format. +- Numbers and units: half-width digits with a space before a Latin unit + (10 MB); % and ° attach with no space; a Chinese unit needs no space + (5 秒). +- Do not hard-wrap a line between two Han characters (the renderer turns + the line break into a stray space). Keep the source's block layout and + wrap only where the source wraps or after full-width punctuation. + +## 5. Terminology pointer + +The termbase is `glossary.json` next to this file. It is injected into the +prompt separately and its renderings override anything written here. This +section only fixes the conventions the glossary assumes: + +- Terms listed under `keep_in_source_language`, and any other English word + left in Latin script, are copied exactly as spelled and always in the + singular, with no article and no English plural "s": "the URIs" → URI, + "children" → child. They are never transliterated or re-cased; the spacing + rule in §4 sets them off from the surrounding Han text. +- Everything in code font, plus API names, class, function and parameter + names, protocol method and message strings (`tools/call`, + `notifications/...`), header names, error codes, SEP numbers and product + names, stays in Latin script inline. A glossary term used as a code-font + identifier stays Latin even though its prose noun is translated: + "the `sampling` capability" → `sampling` 能力. +- First-use gloss: a translated MCP concept the reader may need to map back + to the English specification carries the English in full-width parentheses + on its first occurrence on a page — 采样(sampling) — and appears alone + after that. Each glossary entry's note says whether the term takes the + gloss. +- One rendering per term per page: the glossary target, every time. Where an + entry's note marks the choice as open or provisional, still use the listed + target consistently rather than picking per sentence. + +## 6. Provisional note + +The register, voice and terminology decisions above are provisional, +pending review by native Chinese-speaking readers. To propose a change, edit +this file or `glossary.json` in a pull request; never edit the generated +pages under `pages/`, which the next translation run overwrites. diff --git a/mkdocs.yml b/mkdocs.yml index 06b293f876..f215e692ed 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -68,6 +68,7 @@ nav: - Extensions: advanced/extensions.md - MCP Apps: advanced/apps.md - Troubleshooting: troubleshooting.md + - Translations: translations.md - Migration Guide: migration.md - API Reference: api/ diff --git a/pyproject.toml b/pyproject.toml index 3c814106d1..d6cb23c740 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -102,6 +102,9 @@ docs = [ # griffe's successor distribution (same author) and still imports as # `griffe`; the old `griffe` distribution is the incompatible 1.x line. "griffelib==2.1.0", + # The docs translation tool (scripts/docs/i18n) calls the Claude API for + # the `translate` and `verify` commands; the rest of the pipeline is offline. + "anthropic>=0.120.2", ] codegen = ["datamodel-code-generator==0.57.0"] @@ -160,6 +163,10 @@ include = [ "examples/servers", "examples/snippets", "examples/clients", + "scripts/docs/i18n", + "scripts/docs/navigation.py", + "scripts/docs/build_config.py", + "scripts/docs/check_anchors.py", ] venvPath = "." venv = ".venv" @@ -172,9 +179,13 @@ extraPaths = ["examples"] # those private functions instead of testing the private functions directly. It makes it easier to maintain the code source # and refactor code that is not public. executionEnvironments = [ + # scripts/docs is the import root of the docs tooling: the translation tool + # package (i18n) imports its sibling build scripts as top-level modules. + { root = "scripts/docs" }, { root = "tests", extraPaths = [ ".", "examples", + "scripts/docs", ], reportUnusedFunction = false, reportPrivateUsage = false }, { root = "examples/stories", extraPaths = [ "examples", @@ -243,6 +254,9 @@ strict-no-cover = { git = "https://github.com/pydantic/strict-no-cover" } [tool.pytest.ini_options] log_cli = true xfail_strict = true +# tests/docs_i18n exercises the translation tool under scripts/docs/, whose +# modules import each other with scripts/docs as the import root. +pythonpath = ["scripts/docs"] markers = [ "requirement(id): links a test to the entry in tests/interaction/_requirements.py it exercises", ] @@ -278,6 +292,11 @@ MD033 = false # no-inline-html Inline HTML MD041 = false # first-line-heading/first-line-h1 MD046 = false # indented-code-blocks MD059 = false # descriptive-link-text +# link-fragments: heading anchors are pinned in the docs source ({#id}, +# scripts/docs/check_anchors.py). The custom-anchor pattern here can't express +# an underscore that isn't word-internal, so those fragments are left to the +# docs build's own link validation. +MD051.ignored_pattern = "(^|-)_|_-" # https://coverage.readthedocs.io/en/latest/config.html#run [tool.coverage.run] diff --git a/scripts/docs/build.sh b/scripts/docs/build.sh index 8f545761bc..71c98f456a 100755 --- a/scripts/docs/build.sh +++ b/scripts/docs/build.sh @@ -1,15 +1,34 @@ #!/usr/bin/env bash # -# Build the v2 documentation site for this checkout into `site/`. +# Build the v2 documentation site for this checkout into `site/`: the +# English site at the root plus one machine-translated site per enabled +# language under `site//` (see i18n/languages.yml). # -# Zensical runs no MkDocs plugins or hooks, so the build is three steps: -# materialise the API reference pages and the concrete config, build the -# site strictly (plus the order-independence and cross-reference checks -# Zensical doesn't do itself), then generate llms.txt and the per-page -# markdown renditions. This script is the single owner of that recipe, dependency -# sync included — CI (shared.yml, docs-preview.yml) and scripts/build-docs.sh -# all call it. The toolchain detection in docs-preview.yml and build-docs.sh -# keys on this file's path and expects the site under site/. +# Zensical runs no MkDocs plugins or hooks, so the English build is three +# steps: materialise the API reference pages and the concrete config, build +# the site strictly (plus the anchor, order-independence and cross-reference +# checks Zensical doesn't do itself), then generate llms.txt and the per-page +# markdown renditions. A language site is the lighter recipe against a staged +# docs tree (English pages overlaid with that language's translations and +# stamped with status banners): it carries no API reference of its own — its +# nav entry and its prose links into `api/` point at the English one — so it +# has no mkdocstrings pass and none of the API generation or render-order +# work. This script is the single owner of both recipes, dependency sync +# included — CI (shared.yml, docs-preview.yml) and scripts/build-docs.sh all +# call it. The toolchain detection in docs-preview.yml and build-docs.sh keys +# on this file's path and expects the site under site/. +# +# Environment: +# DOCS_LANGUAGES=en-only build only the English site (fast local loop) +# DOCS_LANGUAGES=ja,ko build only these language sites after English +# DOCS_SITE_URL= the URL the built site is served from. Defaults +# to mkdocs.yml's site_url (production); a PR +# preview passes its own host so that every +# absolute link the build bakes — each site's +# site_url, the language switcher, the banners' +# English-page links, the language sites' links +# into the English API reference — resolves on the +# host the reader is actually browsing. # # Usage: # scripts/docs/build.sh @@ -26,21 +45,67 @@ uv sync --frozen --group docs # Zensical's incremental cache is unsound: a warm rebuild where only some # pages re-render silently drops cross-references to cache-hit pages, and # HTML for since-deleted pages lingers in site/. Build cold so the output -# (and the checks below) are deterministic. -rm -rf .cache site +# (and the checks below) are deterministic. The staged language trees are +# rebuilt from scratch for the same reason. +rm -rf .cache site .build/i18n + +# Fast, no-network gates before anything is generated or built: every prose +# heading carries an explicit anchor (so anchors survive translation and +# rewording), and the translation config plus the committed translations are +# structurally valid, so an invalid page can never reach a site. +uv run --frozen --no-sync python scripts/docs/check_anchors.py +uv run --frozen --no-sync python scripts/docs/i18n check + +# The language sites this build produces: every enabled language, a chosen +# subset (DOCS_LANGUAGES=ja,ko), or none (DOCS_LANGUAGES=en-only). Decided once +# so the English config and every language config get the same switcher list, +# and the switcher can never link a site the build didn't make. +if [[ "${DOCS_LANGUAGES:-}" == "en-only" ]]; then + languages="" +elif [[ -n "${DOCS_LANGUAGES:-}" ]]; then + languages="${DOCS_LANGUAGES//,/ }" +else + languages="$(uv run --frozen --no-sync python scripts/docs/i18n languages | tr '\n' ' ')" +fi +switcher="$(echo $languages | tr ' ' ',')" + +# The URL the site is served from (see DOCS_SITE_URL above), decided once and +# handed to every config and to staging so all baked links agree on it. +site_url="${DOCS_SITE_URL:-}" +if [[ -z "$site_url" ]]; then + site_url="$(uv run --frozen --no-sync python -c 'import yaml; print(yaml.safe_load(open("mkdocs.yml", encoding="utf-8"))["site_url"])')" +fi + +uv run --frozen --no-sync python scripts/docs/build_config.py --site-url "$site_url" --switcher-languages "$switcher" -uv run --frozen --no-sync python scripts/docs/build_config.py uv run --frozen --no-sync zensical build -f mkdocs.gen.yml --strict # The build above renders pages in one arbitrary (filesystem-dependent) # order; prove the API reference renders in hostile orders too — see the -# check's docstring for the failure mode this guards. +# check's docstring for the failure mode this guards. Only the English site +# builds the API reference, so the property is checked only here. uv run --frozen --no-sync python scripts/docs/check_render_order.py # Zensical stays green even under --strict when a cross-reference fails to # resolve (rendered as literal bracket text) or an objects.inv inventory # fails to download (every link through it silently degrades to plain text); # MkDocs strict mode aborted on both. Validate the built site instead. -uv run --frozen --no-sync python scripts/docs/check_crossrefs.py --site-dir site +uv run --frozen --no-sync python scripts/docs/check_crossrefs.py --site-dir site --config mkdocs.gen.yml uv run --frozen --no-sync python scripts/docs/llms_txt.py --site-dir site + +for lang in $languages; do + echo "=== Building language site: ${lang} ===" + # Stage the language's docs tree (English + translations + banners, API + # links pointed at the English reference), generate its config, then + # build it cold and check its cross-references. + uv run --frozen --no-sync python scripts/docs/i18n stage --lang "$lang" --site-url "$site_url" + uv run --frozen --no-sync python scripts/docs/build_config.py --lang "$lang" \ + --site-url "$site_url" --switcher-languages "$switcher" + rm -rf .cache + uv run --frozen --no-sync zensical build -f "mkdocs.${lang}.gen.yml" --strict + uv run --frozen --no-sync python scripts/docs/check_crossrefs.py \ + --site-dir ".build/i18n/${lang}/site" --config "mkdocs.${lang}.gen.yml" + mkdir -p "site/${lang}" + cp -a ".build/i18n/${lang}/site/." "site/${lang}/" +done diff --git a/scripts/docs/build_config.py b/scripts/docs/build_config.py index daba648344..7a5ef5f6f5 100644 --- a/scripts/docs/build_config.py +++ b/scripts/docs/build_config.py @@ -2,46 +2,57 @@ Zensical builds from `mkdocs.yml` directly, but it has no equivalent of mkdocs-literate-nav: the "API Reference" navigation has to be materialised -as explicit entries. This script regenerates the `docs/api/` tree (via +as explicit entries. This script regenerates the API reference tree (via gen_ref_pages) and writes `mkdocs.gen.yml` with the real API nav spliced in — that generated file is what `zensical build`/`serve` consumes. +With `--lang ` it writes `mkdocs..gen.yml` for one translated +site instead: `docs_dir` pointed at that language's staged tree (see +`scripts/docs/i18n`), the theme language switched, the sidebar labels swapped +for the language's generated renderings (a label without one stays English), +and the site published under `//`. A language site does not rebuild +the API reference — its "API Reference" nav entry links to the single English +one — so it runs no mkdocstrings pass. + +Every config, English included, carries the same `extra.alternate` list (the +language switcher and each page's `hreflang` alternates), and that list is the +set of sites the build actually produces: `--switcher-languages` names them +(the build script passes the same value to every config it writes), so a +partial or English-only build never links to a site it did not make. With +the flag omitted the list is every enabled language. + +`--site-url` names the URL the site is served from — production +(`mkdocs.yml`'s `site_url`) unless the build script says otherwise (a PR +preview at its own host) — and everything absolute this build bakes derives +from it: each config's `site_url`, the switcher links' path prefix, and the +API-reference entry a language site's nav points at. + Usage: - python scripts/docs/build_config.py + python scripts/docs/build_config.py [--site-url URL] [--switcher-languages ja,ko] + python scripts/docs/build_config.py --lang [--docs-dir DIR] [--site-url URL] [--switcher-languages ...] """ from __future__ import annotations +import argparse import posixpath -import re from pathlib import Path +from urllib.parse import urlsplit # Both scripts live in this directory, which Python puts on sys.path[0] when # `build_config.py` is run directly (its documented invocation). import gen_ref_pages import yaml +from llms_txt import page_url +from navigation import NavEntry, nav_pages, relabel_nav -ROOT = Path(__file__).parent.parent.parent - -# A scheme-prefixed nav value (https:, mailto:, ...) is an external link, not -# a page path (same classifier as llms_txt.py; a `://` test would misread -# scheme-only URIs as pages). -_EXTERNAL = re.compile(r"[a-zA-Z][a-zA-Z0-9+.-]*:") +from i18n.config import ConfigError, Registry, load_registry, nav_path, staged_docs_dir, staged_site_dir +from i18n.nav import load_nav - -def _nav_pages(nav: list) -> set[str]: - """Collect every local page reference in the nav (external links excluded).""" - pages: set[str] = set() - for entry in nav: - value = next(iter(entry.values())) if isinstance(entry, dict) else entry - if isinstance(value, list): - pages |= _nav_pages(value) - elif not _EXTERNAL.match(value): - pages.add(value) - return pages +ROOT = Path(__file__).parent.parent.parent -def _validate_nav(nav: list, docs_dir: Path) -> None: +def _validate_nav(nav: list[NavEntry], docs_dir: Path) -> None: """Fail on nav/page drift in either direction. Zensical (0.0.48) ships a nav entry for a nonexistent page as a broken @@ -52,39 +63,168 @@ def _validate_nav(nav: list, docs_dir: Path) -> None: exempt from the orphan check: its nav is spliced in from the same generator that writes the files, so it cannot drift. """ - pages = _nav_pages(nav) + pages = set(nav_pages(nav)) # Containment before existence: `docs_dir / page` would happily resolve # an absolute value or a `../` escape against the wrong root. if escaping := sorted(p for p in pages if p.startswith("/") or posixpath.normpath(p).startswith("..")): raise SystemExit(f"build_config: nav references pages outside docs/: {escaping}") if missing := sorted(page for page in pages if not (docs_dir / page).is_file()): - raise SystemExit(f"build_config: nav references pages that don't exist under docs/: {missing}") + raise SystemExit(f"build_config: nav references pages that don't exist under {docs_dir}: {missing}") # Dot-directories (e.g. `.overrides` theme files) are not pages: the site # builder ignores them, so the orphan check must too. relative = (page.relative_to(docs_dir) for page in docs_dir.rglob("*.md")) on_disk = {page.as_posix() for page in relative if not any(part.startswith(".") for part in page.parts)} if orphaned := sorted(page for page in on_disk - pages if not page.startswith("api/")): - raise SystemExit(f"build_config: pages under docs/ that no nav entry reaches: {orphaned}") - + raise SystemExit(f"build_config: pages under {docs_dir} that no nav entry reaches: {orphaned}") -def build_config() -> None: - config = yaml.safe_load((ROOT / "mkdocs.yml").read_text(encoding="utf-8")) - api_nav = gen_ref_pages.generate() - if not api_nav: - raise SystemExit("build_config: gen_ref_pages produced no API pages — did the src/ layout move?") - for entry in config["nav"]: +def _api_nav_entry(nav: list[NavEntry]) -> dict[str, str | list[NavEntry]]: + """The `mkdocs.yml` placeholder nav entry the API reference is spliced into.""" + for entry in nav: if isinstance(entry, dict) and "API Reference" in entry: - entry["API Reference"] = api_nav - break - else: - raise SystemExit("build_config: no 'API Reference' entry found in mkdocs.yml nav") + return entry + raise SystemExit("build_config: no 'API Reference' entry found in mkdocs.yml nav") - _validate_nav(config["nav"], ROOT / "docs") - output = ROOT / "mkdocs.gen.yml" +def _alternate(registry: Registry, codes: list[str] | None, base: str) -> list[dict[str, str]]: + """The language-switcher entries: English at the site root, then each built language site. + + `codes` are the language sites this build produces (None means every + enabled language); the switcher must never offer a site that isn't built. + Each `link` is the site root's path plus the language code (`base` is + the site URL), so the switcher follows whatever host and path the site + is served under. + + Raises: + SystemExit: A named language is not an enabled entry of the registry. + """ + if codes is None: + languages = registry.enabled + else: + try: + languages = [registry.language(code) for code in codes] + except ConfigError as exc: + raise SystemExit(f"build_config: --switcher-languages: {exc}") from exc + if disabled := [language.code for language in languages if not language.enabled]: + raise SystemExit(f"build_config: --switcher-languages names disabled languages: {disabled}") + root = urlsplit(base).path.rstrip("/") + "/" + entries = [{"name": "English", "link": root, "lang": "en"}] + entries += [ + {"name": language.name, "link": f"{root}{language.code}/", "lang": language.hreflang} for language in languages + ] + return entries + + +def _repo_relative(path: Path, what: str) -> str: + """`path` as a repo-root-relative posix path (Zensical resolves config paths against the config file). + + Both sides are resolved before the comparison, so a repository reached + through a symlinked path still yields the same relative form. + + Raises: + SystemExit: `path` does not live inside the repository. + """ + try: + return path.resolve().relative_to(ROOT.resolve()).as_posix() + except ValueError as exc: + raise SystemExit(f"build_config: {what} {path} must live inside the repository") from exc + + +def _translated_labels(lang: str) -> dict[str, str]: + """The language's translated nav labels; empty when it has no nav map yet.""" + try: + nav = load_nav(nav_path(lang)) + except ConfigError as exc: + raise SystemExit(f"build_config: {exc}") from exc + return {} if nav is None else nav.labels + + +def build_config( + lang: str | None = None, + docs_dir: Path | None = None, + site_url: str | None = None, + switcher_languages: list[str] | None = None, +) -> Path: + """Write the concrete build config; returns its path. + + `switcher_languages` are the language sites this build produces (None + means every enabled language); a language config must be one of them. + """ + config = yaml.safe_load((ROOT / "mkdocs.yml").read_text(encoding="utf-8")) + registry = load_registry() + if lang is not None and switcher_languages is not None and lang not in switcher_languages: + raise SystemExit(f"build_config: --switcher-languages must include the site being built ({lang})") + base = (site_url or config["site_url"]).rstrip("/") + + if lang is None: + if docs_dir is not None: + raise SystemExit("build_config: --docs-dir applies only to a language build (pass --lang)") + docs = ROOT / "docs" + api_nav = gen_ref_pages.generate() + if not api_nav: + raise SystemExit("build_config: gen_ref_pages produced no API pages — did the src/ layout move?") + _api_nav_entry(config["nav"])["API Reference"] = api_nav + else: + docs = docs_dir if docs_dir is not None else staged_docs_dir(lang, ROOT) + # A language site links the one English API reference instead of + # rebuilding it: no mkdocstrings pass, and the nav entry is an + # external link to the reference on the English site (staging points + # prose links into `api/` at the same place). + config["plugins"] = [p for p in config["plugins"] if not (isinstance(p, dict) and "mkdocstrings" in p)] + _api_nav_entry(config["nav"])["API Reference"] = f"{base}/{page_url(gen_ref_pages.ENTRY_PAGE)}" + if not docs.is_dir(): + raise SystemExit(f"build_config: docs directory {docs} does not exist (stage the language first)") + + _validate_nav(config["nav"], docs) + + config.setdefault("extra", {})["alternate"] = _alternate(registry, switcher_languages, base) + if lang is None: + config["site_url"] = f"{base}/" + output = ROOT / "mkdocs.gen.yml" + else: + language = registry.language(lang) + # The sidebar reads in the site's language: every label the language's + # generated nav map covers ("API Reference" included) is swapped for + # its rendering; the rest stay English. Never applied to the English config. + config["nav"] = relabel_nav(config["nav"], _translated_labels(lang)) + # Zensical resolves these against the config file's directory and + # requires them relative to it; an absolute path aborts the build. + config["docs_dir"] = _repo_relative(docs, "docs directory") + config["site_dir"] = _repo_relative(staged_site_dir(lang, ROOT), "site directory") + config["site_url"] = f"{base}/{lang}/" + config["theme"]["language"] = language.theme_language + output = ROOT / f"mkdocs.{lang}.gen.yml" + output.write_text(yaml.safe_dump(config, sort_keys=False, allow_unicode=True), encoding="utf-8") + return output + + +def _language_codes(value: str) -> list[str]: + """The comma-separated language codes of `--switcher-languages` (empty means English only).""" + return [code.strip() for code in value.split(",") if code.strip()] + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--lang", metavar="CODE", help="Build config for this language site.") + parser.add_argument( + "--docs-dir", type=Path, help="Docs tree for a --lang build (default: the language's staged tree)." + ) + parser.add_argument( + "--site-url", + help="URL the site is served from; every absolute link the build bakes derives from it" + " (default: mkdocs.yml site_url).", + ) + parser.add_argument( + "--switcher-languages", + type=_language_codes, + metavar="CODES", + help="Comma-separated codes of the language sites this build produces, listed by the " + "language switcher (empty: English only; default: every enabled language).", + ) + args = parser.parse_args() + print(build_config(args.lang, args.docs_dir, args.site_url, args.switcher_languages)) if __name__ == "__main__": - build_config() + main() diff --git a/scripts/docs/check_anchors.py b/scripts/docs/check_anchors.py new file mode 100644 index 0000000000..1c31364339 --- /dev/null +++ b/scripts/docs/check_anchors.py @@ -0,0 +1,248 @@ +"""Require an explicit anchor on every prose heading; `--fix` pins them. + +Every heading in the hand-written docs carries its own id in the source +(`## Ask with a resolver {#ask-with-a-resolver}`, attr_list syntax) instead +of leaving the theme to slugify the heading text. That decouples the anchor +from the wording — a heading can be reworded or translated without moving +the `#fragment` links that point at it, and the ids are one grep away in the +source rather than a property of a build. The compact `{#id}` spelling is the +one markdownlint's fragment check recognises as a custom anchor. + +Default mode is the check: for every ATX heading under `docs/**/*.md` (the +generated `docs/api/` tree and dot-directories exempt) it fails on a heading +without a trailing `{#anchor}`, on an anchor id using characters outside +letters, digits, `_` and `-`, and on an anchor a page uses twice — the anchor +rules the translation validator also enforces, defined once in +`i18n.markdown` (`anchor_problems`). `--fix` appends `{#}` to +unanchored headings, slugifying and de-duplicating the way the theme's `toc` +extension does. `--fix --from-site ` instead copies the ids the built +site rendered for those headings — the mode used to pin the existing corpus, +since it cannot move an anchor by construction. + +Usage: + python scripts/docs/check_anchors.py + python scripts/docs/check_anchors.py --fix + python scripts/docs/check_anchors.py --fix --from-site site +""" + +from __future__ import annotations + +import argparse +import html +import re +import sys +import unicodedata +from html.parser import HTMLParser +from pathlib import Path + +# The heading/anchor/fence parsing and the anchor rules are the one +# implementation shared with the translation tool (this directory is the +# import root of the docs tooling), so this check, the translation validator +# and the mechanical repairer can never disagree about what an anchor is, how +# it is escaped, or which anchors are wrong. +from i18n.markdown import ( + HEADING_ATTRS, + AnchorProblem, + MalformedAnchor, + MissingAnchor, + UnspacedAnchor, + anchor_problems, + anchor_source_form, + parse_headings, + unescape, +) + +ROOT = Path(__file__).parent.parent.parent +DOCS = ROOT / "docs" + +# Inline markup that is not part of the rendered heading text the theme +# slugifies: images vanish, links keep their label, raw tags are stripped, +# underscore emphasis loses its markers (asterisks vanish in the slug anyway), +# and backslash escapes yield their literal character. +_CODE_SPAN = re.compile(r"(?`+)(?P.+?)(?[^\]]*)\](?:\([^)]*\)|\[[^\]]*\])") +_TAG = re.compile(r"]*>") +_UNDERSCORE_EM = re.compile(r"(?_{1,2})(?=\S)(?P.+?)(?<=\S)(?P=mark)(?!\w)") + +_DEDUPE_SUFFIX = re.compile(r"^(.*)_([0-9]+)$") +_HEADING_TAG = re.compile(r"h[1-6]") + + +def _plain_text(heading_text: str) -> str: + """Reduce inline markdown to the text the rendered heading would carry.""" + codes: list[str] = [] + + def stash(match: re.Match[str]) -> str: + codes.append(match["code"].strip()) + return f"\x00{len(codes) - 1}\x00" + + text = _CODE_SPAN.sub(stash, heading_text) + text = _IMAGE.sub("", text) + text = _LINK.sub(lambda m: m["label"], text) + text = _TAG.sub("", text) + text = _UNDERSCORE_EM.sub(lambda m: m["inner"], text) + text = html.unescape(unescape(text)) + return re.sub(r"\x00(\d+)\x00", lambda m: codes[int(m[1])], text) + + +def slugify(heading_text: str) -> str: + """The id the theme's `toc` extension derives from a heading's text.""" + value = unicodedata.normalize("NFKD", _plain_text(heading_text)) + value = value.encode("ascii", "ignore").decode("ascii") + value = re.sub(r"[^\w\s-]", "", value).strip().lower() + return re.sub(r"[-\s]+", "-", value) + + +def unique(anchor: str, used: set[str]) -> str: + """De-duplicate `anchor` against `used` the way `toc` does: `id`, `id_1`, `id_2`, ...""" + while anchor in used or not anchor: + match = _DEDUPE_SUFFIX.match(anchor) + anchor = f"{match[1]}_{int(match[2]) + 1}" if match else f"{anchor}_1" + used.add(anchor) + return anchor + + +class _HeadingIdParser(HTMLParser): + """Collect the `(level, id)` of every heading inside a page's `
`.""" + + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self._depth = 0 + self.headings: list[tuple[int, str | None]] = [] + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + if tag == "article": + self._depth += 1 + elif self._depth and _HEADING_TAG.fullmatch(tag): + self.headings.append((int(tag[1]), dict(attrs).get("id"))) + + def handle_endtag(self, tag: str) -> None: + if tag == "article" and self._depth: + self._depth -= 1 + + +def rendered_ids(site_dir: Path, page: Path) -> list[tuple[int, str | None]]: + """The heading levels and ids the built site rendered for prose `page`.""" + stem = page.relative_to(DOCS).with_suffix("") + html_file = site_dir / (stem.parent if stem.name == "index" else stem) / "index.html" + if not html_file.is_file(): + raise SystemExit(f"check_anchors: no rendered page for {_repo_path(page)} at {html_file} — build first") + parser = _HeadingIdParser() + parser.feed(html_file.read_text(encoding="utf-8")) + return parser.headings + + +def prose_pages(docs: Path = DOCS) -> list[Path]: + """Hand-written pages under `docs/`: the generated `api/` tree and dot-directories aside. + + Only the path *inside* `docs/` decides that: a checkout that itself lives + under a dot-directory (a worktree) still has all its prose pages, and + finding none at all means the docs tree is missing, never that it passed. + + Raises: + SystemExit: There is no prose page under `docs`. + """ + parts = ((page, page.relative_to(docs).parts) for page in docs.rglob("*.md")) + pages = sorted(page for page, rel in parts if rel[0] != "api" and not any(p.startswith(".") for p in rel)) + if not pages: + raise SystemExit(f"check_anchors: found no prose pages under {docs} — is the docs tree in place?") + return pages + + +def _repo_path(path: Path, *, root: Path = ROOT) -> str: + """`path` as a repository-relative posix path, so messages read the same on every platform.""" + return path.relative_to(root).as_posix() + + +def check_pages(pages: list[Path], *, docs: Path = DOCS) -> tuple[int, list[str]]: + """The heading count and every problem found: unanchored, malformed, glued and reused anchors. + + Problems name the page relative to the directory holding `docs`, so they + read as repository paths (`docs/handlers/context.md:14: ...`). + """ + total = 0 + problems: list[str] = [] + for page in pages: + rel = _repo_path(page, root=docs.parent) + headings = parse_headings(page.read_text(encoding="utf-8")) + total += len(headings) + problems += [f"{rel}:{problem.heading.line + 1}: {_describe(problem)}" for problem in anchor_problems(headings)] + return total, problems + + +def _describe(problem: AnchorProblem) -> str: + """One shared anchor problem, worded for a source page (the heading text or id, an earlier line).""" + if isinstance(problem, MissingAnchor): + return f"heading has no {{#anchor}}: {problem.heading.text!r}" + if isinstance(problem, MalformedAnchor): + return f'#{problem.heading.anchor} may only use letters, digits, "_", "-"' + if isinstance(problem, UnspacedAnchor): + return f"{{#{problem.heading.anchor}}} must follow a space, or the block renders as heading text" + return f"#{problem.heading.anchor} already anchors line {problem.first.line + 1}" + + +def _pin(page: Path, site_dir: Path | None) -> int: + """Append `{#id}` to each unanchored heading of `page`; returns how many were pinned.""" + lines = page.read_text(encoding="utf-8").split("\n") + headings = parse_headings("\n".join(lines)) + if all(heading.anchor is not None for heading in headings): + return 0 + rendered = rendered_ids(site_dir, page) if site_dir is not None else None + if rendered is not None and [h.level for h in headings] != [level for level, _ in rendered]: + raise SystemExit( + f"check_anchors: rendered headings of {_repo_path(page)} don't line up with its source" + " — rebuild the site from this tree before pinning from it" + ) + used: set[str] = {heading.anchor for heading in headings if heading.anchor} + pinned = 0 + for index, heading in enumerate(headings): + if heading.anchor is not None: + continue + if rendered is not None: + anchor = rendered[index][1] + if not anchor: + raise SystemExit(f"check_anchors: rendered heading at {_repo_path(page)}:{heading.line + 1} has no id") + else: + anchor = unique(slugify(heading.text), used) + line = lines[heading.line].rstrip() + attrs = HEADING_ATTRS.search(line) + # A heading may already carry classes/attributes (`{ .cls }`) without an id. + lines[heading.line] = ( + f"{line[: attrs.start('body')]}#{anchor_source_form(anchor)} {attrs['body']}{line[attrs.end('body') :]}" + if attrs + else f"{line} {{#{anchor_source_form(anchor)}}}" + ) + pinned += 1 + if pinned: + page.write_text("\n".join(lines), encoding="utf-8") + return pinned + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--fix", action="store_true", help="Append an anchor to every heading that lacks one.") + parser.add_argument( + "--from-site", + metavar="SITE_DIR", + type=Path, + help="With --fix, pin the ids the built site rendered instead of computing slugs.", + ) + args = parser.parse_args() + if args.from_site and not args.fix: + parser.error("--from-site only makes sense with --fix") + + pages = prose_pages() + if args.fix: + pinned = sum(_pin(page, args.from_site) for page in pages) + print(f"check_anchors: pinned {pinned} heading anchors") + total, problems = check_pages(pages) + if problems: + print("error: every prose heading needs an explicit anchor (run with --fix):", file=sys.stderr) + print("\n".join(problems), file=sys.stderr) + raise SystemExit(1) + print(f"check_anchors: {total} headings across {len(pages)} pages all carry anchors") + + +if __name__ == "__main__": + main() diff --git a/scripts/docs/check_crossrefs.py b/scripts/docs/check_crossrefs.py index 39f866a00f..97670bb1d7 100644 --- a/scripts/docs/check_crossrefs.py +++ b/scripts/docs/check_crossrefs.py @@ -14,16 +14,18 @@ disarm the check: an unresolved reference leaves a tell-tale bracket sequence in prose text (code blocks legitimately contain `][`, e.g. dict indexing, so only text outside `
`/`` counts), and every inventory
-declared in `mkdocs.yml` must contribute at least one resolved reference —
-an `autorefs-external` anchor, which hand-authored prose links to the same
-host never carry — to the site (an inventory that contributes none is dead
-config and fails too).
+declared in the site's build config must contribute at least one resolved
+reference — an `autorefs-external` anchor, which hand-authored prose links
+to the same host never carry — to the site (an inventory that contributes
+none is dead config and fails too). A language site declares no inventories
+(it links the English API reference instead of building it), so `--config`
+selects the config the site was built from.
 
 Offline contributors can skip the inventory check by setting
 `DOCS_ALLOW_INVENTORY_FAILURE=1`; CI (`CI=true`) never skips it.
 
 Usage:
-    python scripts/docs/check_crossrefs.py --site-dir site
+    python scripts/docs/check_crossrefs.py --site-dir site [--config mkdocs.gen.yml]
 """
 
 from __future__ import annotations
@@ -113,9 +115,9 @@ def unresolved_refs(html: str) -> list[str]:
     return [fragment.replace("\x00", "") for fragment in _UNRESOLVED.findall("".join(parser.chunks))]
 
 
-def _inventory_origins() -> set[str]:
-    """The scheme+host origins of the inventories declared in mkdocs.yml."""
-    config = yaml.safe_load((ROOT / "mkdocs.yml").read_text(encoding="utf-8"))
+def _inventory_origins(config_path: Path) -> set[str]:
+    """The scheme+host origins of the inventories declared in the build config."""
+    config = yaml.safe_load(config_path.read_text(encoding="utf-8"))
     for plugin in config["plugins"]:
         if isinstance(plugin, dict) and "mkdocstrings" in plugin:
             inventories = plugin["mkdocstrings"]["handlers"]["python"].get("inventories", [])
@@ -131,6 +133,7 @@ def _origin(url: str) -> str:
 def main() -> None:
     parser = argparse.ArgumentParser(description=__doc__)
     parser.add_argument("--site-dir", default=str(ROOT / "site"), help="The built site directory to scan.")
+    parser.add_argument("--config", default=str(ROOT / "mkdocs.yml"), help="The build config the site was built from.")
     args = parser.parse_args()
 
     site_dir = Path(args.site_dir)
@@ -139,7 +142,7 @@ def main() -> None:
     if not site_dir.is_dir():
         raise SystemExit(f"check_crossrefs: {site_dir} not found (run the build first)")
 
-    unlinked = _inventory_origins()
+    unlinked = _inventory_origins(Path(args.config))
     failures: list[str] = []
     for page in sorted(site_dir.rglob("*.html")):
         html = page.read_text(encoding="utf-8")
diff --git a/scripts/docs/gen_ref_pages.py b/scripts/docs/gen_ref_pages.py
index 26916e8c39..93ba8f4594 100644
--- a/scripts/docs/gen_ref_pages.py
+++ b/scripts/docs/gen_ref_pages.py
@@ -17,10 +17,8 @@
 
 import griffe
 
-# A MkDocs/Zensical nav is a list of entries, each either `{title: url}` for a
-# page or `{title: [children]}` for a section (a bare `url` string attaches
-# a section index page, courtesy of the `navigation.indexes` feature).
-NavItem = "str | dict[str, str | list[NavItem]]"
+# Sibling module (scripts/docs is the import root); owns the nav shape.
+from navigation import NavEntry
 
 ROOT = Path(__file__).parent.parent.parent
 API_DIR = ROOT / "docs" / "api"
@@ -30,6 +28,11 @@
 # it from `src/` would emit the unimportable `mcp-types.mcp_types.*`.
 PACKAGES = (ROOT / "src" / "mcp", ROOT / "src" / "mcp-types" / "mcp_types")
 
+# The reference has no bare `api/` page: it opens on the first package's
+# index (the nav lists packages in name order), so this is the page a link
+# to the API reference as a whole points at.
+ENTRY_PAGE = f"api/{min(package.name for package in PACKAGES)}/index.md"
+
 # Alias packages that mirror another package's namespaces (`mcp.types` mirrors
 # `mcp_types`, `mcp.types.version` mirrors `mcp_types.version`): the mirrored
 # package's pages are the canonical rendering, so an alias, and every module
@@ -55,11 +58,11 @@ def __init__(self) -> None:
     def child(self, name: str) -> _Node:
         return self.children.setdefault(name, _Node())
 
-    def to_nav(self, title: str) -> NavItem:
+    def to_nav(self, title: str) -> NavEntry:
         if not self.children:
             assert self.url is not None
             return {title: self.url}
-        items: list[NavItem] = []
+        items: list[NavEntry] = []
         if self.url is not None:
             items.append(self.url)
         items.extend(self.children[name].to_nav(name) for name in sorted(self.children))
@@ -165,7 +168,7 @@ def _stub(title: str, body: str) -> str:
     return f'---\ntitle: "{title}"\n---\n\n{body.rstrip()}\n'
 
 
-def generate() -> list[NavItem]:
+def generate() -> list[NavEntry]:
     """Write `docs/api/**.md` stubs and return the API-section navigation."""
     if API_DIR.exists():
         shutil.rmtree(API_DIR)
diff --git a/scripts/docs/i18n/__init__.py b/scripts/docs/i18n/__init__.py
new file mode 100644
index 0000000000..a82c37b3d0
--- /dev/null
+++ b/scripts/docs/i18n/__init__.py
@@ -0,0 +1,8 @@
+"""Machine translation of the documentation into the language sites.
+
+The English pages under `docs/` are the source; each language listed in
+`i18n/languages.yml` gets generated translations under
+`i18n/languages//pages/`, steered by that language's `instructions.md`
+and `glossary.json`. `i18n/README.md` documents the workflow; run the tool as
+`python scripts/docs/i18n --help` from the repo root for the commands.
+"""
diff --git a/scripts/docs/i18n/__main__.py b/scripts/docs/i18n/__main__.py
new file mode 100644
index 0000000000..5ec2012ed6
--- /dev/null
+++ b/scripts/docs/i18n/__main__.py
@@ -0,0 +1,18 @@
+"""Entrypoint: `python scripts/docs/i18n ` from the repository root.
+
+Running the package directory puts this directory on `sys.path`; the tool's
+modules and the sibling docs scripts are imported through `scripts/docs`, so
+that directory is added ahead of the imports.
+"""
+
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+
+from i18n.cli import main
+
+if __name__ == "__main__":
+    raise SystemExit(main())
diff --git a/scripts/docs/i18n/cli.py b/scripts/docs/i18n/cli.py
new file mode 100644
index 0000000000..7a443907d3
--- /dev/null
+++ b/scripts/docs/i18n/cli.py
@@ -0,0 +1,543 @@
+"""Command line for the documentation translation tool.
+
+Run from the repository root:
+
+    uv run --frozen --group docs python scripts/docs/i18n  [options]
+
+Commands mirror the workflow — `status` shows what each language needs,
+`translate` produces or refreshes pages and the sidebar's label map
+(`--nav`), `check` and `verify` gate them, `prune` drops translations whose
+English source is gone, and `stage` assembles the tree a language site is
+built from. Exit codes: 0 success, 1 validation or verification failures,
+2 usage or configuration errors.
+
+Commands that call the model (`translate`, `verify`) authenticate from the
+environment: set exactly one of ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN
+(setting both, or neither, is a configuration error).
+"""
+
+from __future__ import annotations
+
+import argparse
+import os
+import subprocess
+import sys
+from collections.abc import Mapping, Sequence
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any, cast
+
+import yaml
+
+from i18n.client import AnthropicTranslator, Translator, TranslatorError
+from i18n.config import (
+    BANNER_KINDS,
+    NAV_FILE,
+    ROOT,
+    ConfigError,
+    Language,
+    banner_path,
+    glossary_path,
+    instructions_path,
+    load_registry,
+    nav_path,
+    pages_dir,
+    resolve_models,
+    state_path,
+)
+from i18n.files import write_text_atomically
+from i18n.glossary import load_glossary
+from i18n.nav import save_nav
+from i18n.pages import split_blocks, translatable_pages
+from i18n.stage import StageStatus, stage_language
+from i18n.state import PageState, PageStatus, save_state
+from i18n.status import LanguageStatus, language_status
+from i18n.translate import (
+    Prompt,
+    RunContext,
+    build_nav_prompt,
+    build_prompt,
+    semantic_gate,
+    translate_nav,
+    translate_page,
+)
+from i18n.validate import Finding, validate_nav_labels, validate_page, validate_page_standalone
+
+EXIT_OK, EXIT_FAILURES, EXIT_USAGE = 0, 1, 2
+_STATUSES = ("missing", "outdated", "current", "removable")
+
+
+class _Environment:
+    """The repository the commands operate on."""
+
+    def __init__(self, root: Path) -> None:
+        self.root = root
+        self.docs = root / "docs"
+        self.i18n_dir = root / "i18n"
+        self.registry = load_registry(self.i18n_dir / "languages.yml")
+        self.mkdocs = _mkdocs_config(root / "mkdocs.yml")
+        self.nav: list[Any] = self.mkdocs["nav"]
+
+    @property
+    def site_url(self) -> str:
+        return str(self.mkdocs["site_url"])
+
+    @property
+    def site_name(self) -> str:
+        return str(self.mkdocs["site_name"])
+
+    def languages(self, code: str | None) -> list[Language]:
+        return [self.registry.language(code)] if code else self.registry.enabled
+
+    def status(self, language: Language) -> LanguageStatus:
+        return language_status(
+            language,
+            self.nav,
+            self.registry.exclude_pages,
+            site_name=self.site_name,
+            docs=self.docs,
+            i18n_dir=self.i18n_dir,
+        )
+
+
+def _mkdocs_config(path: Path) -> dict[str, Any]:
+    """The parsed `mkdocs.yml`: the nav, site name and site URL the commands read.
+
+    Raises:
+        ConfigError: The file is missing or unreadable.
+    """
+    try:
+        config: object = yaml.safe_load(path.read_text(encoding="utf-8"))
+    except (OSError, yaml.YAMLError) as exc:
+        raise ConfigError(f"cannot read {path}: {exc}") from exc
+    if not isinstance(config, dict):
+        raise ConfigError(f"{path}: not an mkdocs config mapping")
+    return cast("dict[str, Any]", config)
+
+
+def _positive_int(value: str) -> int:
+    """An argparse type: a whole number of pages, at least one.
+
+    Raises:
+        argparse.ArgumentTypeError: `value` is not an integer of at least one.
+    """
+    try:
+        number = int(value)
+    except ValueError as exc:
+        raise argparse.ArgumentTypeError(f"{value!r} is not a number") from exc
+    if number < 1:
+        raise argparse.ArgumentTypeError(f"must be at least 1, got {number}")
+    return number
+
+
+def _parser() -> argparse.ArgumentParser:
+    parser = argparse.ArgumentParser(
+        prog="i18n", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
+    )
+    parser.add_argument("--repo-root", type=Path, default=ROOT, help="Repository root (default: this repository).")
+    commands = parser.add_subparsers(dest="command", required=True)
+
+    commands.add_parser("languages", help="Print the enabled language codes, one per line.")
+
+    status = commands.add_parser(
+        "status", help="List missing, outdated, current and removable pages, and the nav map's state."
+    )
+    status.add_argument("--lang", metavar="CODE", help="Only this language (default: every enabled one).")
+
+    translate = commands.add_parser("translate", help="Translate the selected pages (calls the model).")
+    translate.add_argument("--lang", metavar="CODE", required=True)
+    translate.add_argument("--pages", nargs="+", metavar="PAGE", default=[], help="Page paths under docs/.")
+    translate.add_argument("--nav", action="store_true", help="Translate (or refresh) the sidebar label map.")
+    translate.add_argument(
+        "--all-missing", action="store_true", help="Add every untranslated page (and the nav map if missing)."
+    )
+    translate.add_argument(
+        "--all-outdated", action="store_true", help="Add every outdated page (and the nav map if outdated)."
+    )
+    translate.add_argument(
+        "--limit", type=_positive_int, metavar="N", help="Translate at most N of the selected pages."
+    )
+    translate.add_argument("--dry-run", action="store_true", help="Print the assembled prompts; call nothing.")
+    translate.add_argument("--no-verify", action="store_true", help="Skip the semantic review gate.")
+    translate.add_argument("--model", help="Translation model (overrides languages.yml and the environment).")
+    translate.add_argument("--verify-model", help="Semantic-review model (same override rules).")
+
+    check = commands.add_parser(
+        "check",
+        help="Validate the committed translations and nav label maps: current pages against the English source,"
+        " outdated ones on their own (no network).",
+    )
+    check.add_argument("--lang", metavar="CODE", help="Only this language (default: every enabled one).")
+    check.add_argument("--pages", nargs="+", metavar="PAGE", default=[], help="Only these pages.")
+
+    verify = commands.add_parser("verify", help="Run the semantic review gate over pages (calls the model).")
+    verify.add_argument("--lang", metavar="CODE", required=True)
+    verify.add_argument("--pages", nargs="+", metavar="PAGE", required=True)
+    verify.add_argument("--verify-model", help="Semantic-review model (overrides languages.yml and env).")
+
+    prune = commands.add_parser("prune", help="Delete translations whose English page is gone.")
+    prune.add_argument("--lang", metavar="CODE", required=True)
+
+    stage = commands.add_parser(
+        "stage", help="Assemble a language's staged docs tree for the build under .build/i18n/CODE/docs."
+    )
+    stage.add_argument("--lang", metavar="CODE", required=True)
+    stage.add_argument(
+        "--site-url",
+        metavar="URL",
+        help="Site URL the staged links point at — banner links and API-reference links (default: mkdocs.yml).",
+    )
+    return parser
+
+
+def main(
+    argv: Sequence[str] | None = None,
+    *,
+    translator: Translator | None = None,
+    environ: Mapping[str, str] | None = None,
+) -> int:
+    """Run one command; returns the process exit code."""
+    args = _parser().parse_args(argv)
+    environ = os.environ if environ is None else environ
+    try:
+        env = _Environment(args.repo_root)
+        if args.command == "languages":
+            print("\n".join(language.code for language in env.registry.enabled))
+            return EXIT_OK
+        if args.command == "status":
+            return _status(env, args)
+        if args.command == "translate":
+            return _translate(env, args, translator, environ)
+        if args.command == "check":
+            return _check(env, args)
+        if args.command == "verify":
+            return _verify(env, args, translator, environ)
+        if args.command == "prune":
+            return _prune(env, args)
+        return _stage(env, args)
+    except ConfigError as exc:
+        print(f"i18n: {exc}", file=sys.stderr)
+        return EXIT_USAGE
+
+
+def _status(env: _Environment, args: argparse.Namespace) -> int:
+    for language in env.languages(args.lang):
+        status = env.status(language)
+        counts = ", ".join(f"{len(status.select(kind))} {kind}" for kind in _STATUSES)
+        print(f"{language.code} ({language.name}): {counts}")
+        print(f"  {status.nav_status.status:<9} {NAV_FILE} (navigation labels){_reasons(status.nav_status.reasons)}")
+        for page in status.pages:
+            print(f"  {page.status:<9} {page.page}{_reasons(page.reasons)}")
+    return EXIT_OK
+
+
+def _reasons(reasons: tuple[str, ...]) -> str:
+    return f"  ({'; '.join(reasons)})" if reasons else ""
+
+
+def _select_pages(status: LanguageStatus, args: argparse.Namespace) -> list[str]:
+    """The pages a `translate` run works on, in nav order, deduplicated."""
+    listed = {page.page for page in status.pages}
+    selected = list(args.pages)
+    if unknown := [page for page in selected if page not in listed]:
+        raise ConfigError(f"not translatable pages (not in the nav, or excluded): {unknown}")
+    if args.all_missing:
+        selected += status.select("missing")
+    if args.all_outdated:
+        selected += status.select("outdated")
+    ordered = [page.page for page in status.pages if page.page in set(selected)]
+    return ordered if args.limit is None else ordered[: args.limit]
+
+
+def _select_nav(status: LanguageStatus, args: argparse.Namespace) -> bool:
+    """Whether a `translate` run (re)writes the nav map: asked for, or picked up as missing/outdated.
+
+    Raises:
+        ConfigError: `--nav` was passed but the nav has no translatable labels.
+    """
+    if args.nav and not status.labels:
+        raise ConfigError("mkdocs.yml has no translatable navigation labels")
+    return bool(
+        args.nav
+        or (args.all_missing and status.nav_status.status == "missing")
+        or (args.all_outdated and status.nav_status.status == "outdated")
+    )
+
+
+def _translate(
+    env: _Environment, args: argparse.Namespace, translator: Translator | None, environ: Mapping[str, str]
+) -> int:
+    language = env.registry.language(args.lang)
+    status = env.status(language)
+    pages = _select_pages(status, args)
+    with_nav = _select_nav(status, args)
+    if not pages and not with_nav:
+        raise ConfigError("nothing selected: pass --pages, --nav, --all-missing or --all-outdated")
+    models = resolve_models(env.registry.models, environ=environ, translate=args.model, verify=args.verify_model)
+    translations = pages_dir(language.code, env.i18n_dir)
+
+    if args.dry_run:
+        if with_nav:
+            _print_prompt(
+                f"{NAV_FILE} (navigation labels)",
+                build_nav_prompt(status.labels, status.inputs, status.nav),
+                models.translate,
+            )
+        for page in pages:
+            _print_prompt(
+                page, build_prompt(_english(env, page), status.inputs, *_previous(env, status, page)), models.translate
+            )
+        return EXIT_OK
+
+    if translator is None:
+        translator = AnthropicTranslator(environ)
+    context = RunContext(
+        translator=translator,
+        model=models.translate,
+        verify_model=models.verify,
+        verify=not args.no_verify,
+        source_commit=_source_commit(env.root),
+        now=datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
+    )
+    failed = False
+    if with_nav:
+        nav_result = translate_nav(status.labels, status.inputs, context, previous=status.nav)
+        if nav_result.nav is None:
+            failed = True
+            print(f"error: {NAV_FILE} failed", file=sys.stderr)
+            _print_findings(nav_result.findings)
+            if nav_result.error:
+                print(f"  {nav_result.error}", file=sys.stderr)
+        else:
+            save_nav(nav_path(language.code, env.i18n_dir), nav_result.nav)
+            print(f"translated: {NAV_FILE} ({len(nav_result.nav.labels)} labels)")
+    for page in pages:
+        previous, previous_state = _previous(env, status, page)
+        result = translate_page(
+            page, _english(env, page), status.inputs, context, previous=previous, previous_state=previous_state
+        )
+        if result.text is None or result.state is None:
+            failed = True
+            print(f"error: {page} failed", file=sys.stderr)
+            _print_findings(result.findings)
+            for blocker in result.blockers:
+                print(
+                    f'  [meaning] "{blocker.source_span}" -> "{blocker.target_span}": {blocker.explanation}',
+                    file=sys.stderr,
+                )
+            if result.error:
+                print(f"  {result.error}", file=sys.stderr)
+            continue
+        write_text_atomically(translations / page, result.text)
+        status.state.pages[page] = result.state
+        save_state(state_path(language.code, env.i18n_dir), status.state)
+        print(f"translated: {page}")
+        for drift in result.drifts:
+            print(f'  [drift] "{drift.source_span}" -> "{drift.target_span}": {drift.explanation}')
+    return EXIT_FAILURES if failed else EXIT_OK
+
+
+def _print_prompt(subject: str, prompt: Prompt, model: str) -> None:
+    """Show one assembled prompt (the `--dry-run` output for a page or the nav map)."""
+    print(f"===== {subject} ({prompt.mode}, model {model}) =====")
+    print("----- system prompt -----")
+    print(prompt.system)
+    for message in prompt.messages:
+        print(f"----- {message.role} message -----")
+        print(message.content)
+
+
+def _check(env: _Environment, args: argparse.Namespace) -> int:
+    """The no-network gate: config, glossaries, nav maps and translated pages are valid.
+
+    Each translated page is checked as what it is. A current page — made
+    from the English page and prompt inputs as they stand — is compared with
+    the English in full. An outdated page is never measured against the
+    English or the glossary that have since moved on (that would fail the
+    gate on every English or glossary edit, when staleness only earns a
+    banner and a refresh): it only has to be well-formed by itself, and is
+    reported so the next translation run refreshes it. A translation whose
+    English page is gone, or with no state record, is reported for
+    `prune`/`translate` and not read at all. Only real integrity failures
+    fail the check.
+
+    A nav map may lag the nav: labels it lacks are not translated yet and
+    labels the nav has dropped are stale entries, so neither fails the check
+    (`status` reports the map outdated); only the renderings it holds are
+    validated. The nav map is skipped when `--pages` narrows the run.
+    """
+    findings: list[Finding] = []
+    for language in env.registry.languages:
+        for path in (instructions_path(language.code, env.i18n_dir), glossary_path(language.code, env.i18n_dir)):
+            if language.enabled and not path.is_file():
+                raise ConfigError(f"{language.code} is enabled but {path} is missing")
+        if glossary_path(language.code, env.i18n_dir).is_file():
+            load_glossary(glossary_path(language.code, env.i18n_dir))
+        for kind in BANNER_KINDS:
+            if not banner_path(kind, language.code, env.i18n_dir).is_file():
+                raise ConfigError(f"missing banner {kind!r} for {language.code} (and no English fallback)")
+    for language in env.languages(args.lang):
+        status = env.status(language)
+        translations = pages_dir(language.code, env.i18n_dir)
+        glossary, script = status.inputs.glossary, language.script
+        checked = _checked_pages(language.code, status, translations, args.pages)
+        for page in checked:
+            name = f"{language.code}:{page.page}"
+            output = (translations / page.page).read_text(encoding="utf-8")
+            if page.status == "current":
+                findings += validate_page(name, _english(env, page.page), output, glossary, script=script)
+            elif page.status == "outdated":
+                findings += validate_page_standalone(name, output)
+        listed = {kind: [p.page for p in checked if p.status == kind] for kind in ("outdated", "removable", "missing")}
+        _note(language.code, "outdated", listed["outdated"], "refreshed by the next translation run")
+        _note(language.code, "removable", listed["removable"], f"gone from docs/; run `prune --lang {language.code}`")
+        _note(
+            language.code, "untracked", listed["missing"], "no state.json record; a translation run replaces the file"
+        )
+        if status.nav is not None and not args.pages:
+            findings += validate_nav_labels(
+                f"{language.code}:{NAV_FILE}",
+                status.labels,
+                status.nav.labels,
+                status.inputs.glossary,
+                exact_keys=False,
+            )
+            if stale := [label for label in status.nav.labels if label not in set(status.labels)]:
+                print(f"note: {language.code}:{NAV_FILE} has stale entries no longer in mkdocs.yml: {stale}")
+    _print_findings(findings)
+    if findings:
+        return EXIT_FAILURES
+    print("i18n check: configuration, nav maps and translated pages are valid")
+    return EXIT_OK
+
+
+def _checked_pages(code: str, status: LanguageStatus, translations: Path, selected: Sequence[str]) -> list[PageStatus]:
+    """The translated pages `check` looks at: every page with a file, or the `--pages` selection.
+
+    Raises:
+        ConfigError: A selected page has no translation file.
+    """
+    present = [page for page in status.pages if (translations / page.page).is_file()]
+    if not selected:
+        return present
+    by_page = {page.page: page for page in present}
+    if missing := [page for page in selected if page not in by_page]:
+        raise ConfigError(f"{code} has no translation of {', '.join(missing)}")
+    return [by_page[page] for page in selected]
+
+
+def _note(code: str, kind: str, pages: Sequence[str], remedy: str) -> None:
+    """Report pages that need no fix here, only a translation or prune run (never a failure)."""
+    if pages:
+        count = f"{len(pages)} page" + ("" if len(pages) == 1 else "s")
+        print(f"note: {code}: {kind} ({count}) — {remedy}: {', '.join(pages)}")
+
+
+def _verify(
+    env: _Environment, args: argparse.Namespace, translator: Translator | None, environ: Mapping[str, str]
+) -> int:
+    language = env.registry.language(args.lang)
+    status = env.status(language)
+    models = resolve_models(env.registry.models, environ=environ, verify=args.verify_model)
+    if translator is None:
+        translator = AnthropicTranslator(environ)
+    failed = False
+    for page in args.pages:
+        translated = pages_dir(language.code, env.i18n_dir) / page
+        if not translated.is_file():
+            raise ConfigError(f"{language.code} has no translation of {page}")
+        english = _english(env, page)
+        output = translated.read_text(encoding="utf-8")
+        if findings := validate_page(page, english, output, status.inputs.glossary, script=language.script):
+            failed = True
+            print(f"error: {page} is not structurally valid (run `check`) — review skipped", file=sys.stderr)
+            _print_findings(findings)
+            continue
+        blocks = list(range(len(split_blocks(english))))
+        try:
+            blockers, drifts = semantic_gate(english, output, blocks, translator=translator, model=models.verify)
+        except TranslatorError as exc:
+            failed = True
+            print(f"error: {page}: {exc}", file=sys.stderr)
+            continue
+        for finding in [*blockers, *drifts]:
+            stream = sys.stderr if finding.severity == "blocker" else sys.stdout
+            quotes = f'"{finding.source_span}" -> "{finding.target_span}"'
+            print(f"{page} [{finding.severity}] {quotes}: {finding.explanation}", file=stream)
+        failed = failed or bool(blockers)
+    print(f"verify: {len(args.pages)} page(s) reviewed with {models.verify}")
+    return EXIT_FAILURES if failed else EXIT_OK
+
+
+def _prune(env: _Environment, args: argparse.Namespace) -> int:
+    language = env.registry.language(args.lang)
+    status = env.status(language)
+    translations = pages_dir(language.code, env.i18n_dir)
+    removable = status.select("removable")
+    tracked = {page.page for page in status.pages}
+    strays = (
+        [p.relative_to(translations).as_posix() for p in translations.rglob("*") if p.is_file()]
+        if translations.is_dir()
+        else []
+    )
+    for page in sorted(set(removable) | {stray for stray in strays if stray not in tracked}):
+        (translations / page).unlink(missing_ok=True)
+        status.state.pages.pop(page, None)
+        print(f"pruned: {page}")
+    save_state(state_path(language.code, env.i18n_dir), status.state)
+    return EXIT_OK
+
+
+def _stage(env: _Environment, args: argparse.Namespace) -> int:
+    language = env.registry.language(args.lang)
+    status = env.status(language)
+    statuses: dict[str, StageStatus] = {}
+    for page in status.pages:
+        if page.status != "removable":
+            statuses[page.page] = page.status
+    # Pages excluded from translation are English by design and carry their
+    # own note ("available in English only"), distinct from "not translated yet".
+    for page in translatable_pages(env.nav, ()):
+        statuses.setdefault(page, "excluded")
+    site_url = args.site_url or env.site_url
+    staged = stage_language(language.code, statuses, english_site_url=site_url, root=env.root)
+    for withdrawn in staged.withdrawn:
+        links = ", ".join(withdrawn.dead_links)
+        print(
+            f"warning: {language.code}: {withdrawn.page} not published — its translation links {links},"
+            " which the English tree no longer has; staged the English page with the stale banner"
+            " (refreshed by the next translation run)",
+            file=sys.stderr,
+        )
+    print(f"staged {language.code} docs at {staged.docs}")
+    return EXIT_OK
+
+
+def _english(env: _Environment, page: str) -> str:
+    try:
+        return (env.docs / page).read_text(encoding="utf-8")
+    except OSError as exc:
+        raise ConfigError(f"cannot read English page {page}: {exc}") from exc
+
+
+def _previous(env: _Environment, status: LanguageStatus, page: str) -> tuple[str | None, PageState | None]:
+    """The prior translation and its state record, if the page was translated before."""
+    record = status.state.pages.get(page)
+    translated = pages_dir(status.inputs.language.code, env.i18n_dir) / page
+    if record is None or not translated.is_file():
+        return None, None
+    return translated.read_text(encoding="utf-8"), record
+
+
+def _print_findings(findings: Sequence[Finding]) -> None:
+    for finding in findings:
+        print(f"  {finding}", file=sys.stderr)
+
+
+def _source_commit(root: Path) -> str:
+    """The current git commit, recorded so a translation can be traced to its source tree."""
+    try:
+        completed = subprocess.run(["git", "rev-parse", "HEAD"], cwd=root, check=True, capture_output=True, text=True)
+    except (OSError, subprocess.CalledProcessError):
+        return "unknown"
+    return completed.stdout.strip()
diff --git a/scripts/docs/i18n/client.py b/scripts/docs/i18n/client.py
new file mode 100644
index 0000000000..43e5bfb72a
--- /dev/null
+++ b/scripts/docs/i18n/client.py
@@ -0,0 +1,144 @@
+"""The model client behind translation and review calls.
+
+The pipeline talks to a `Translator`: one method taking a system prompt, a
+message history and a model, returning the reply text. Tests supply a fake;
+`AnthropicTranslator` is the real implementation, authenticated from the
+environment only and streaming every request so long pages never hit the
+non-streaming request timeout. Everything the API can throw becomes a tool
+error: an unusable reply is a `TranslatorError` (a per-page failure), bad
+credentials are a `ConfigError` (the whole run cannot proceed).
+"""
+
+from __future__ import annotations
+
+from collections.abc import Mapping, Sequence
+from dataclasses import dataclass
+from typing import Literal, Protocol, TypedDict
+
+import anthropic
+from anthropic.types import Message as ApiMessage
+from anthropic.types import MessageParam, TextBlockParam
+
+from i18n.config import ConfigError
+
+Role = Literal["user", "assistant"]
+
+API_KEY_ENV = "ANTHROPIC_API_KEY"
+AUTH_TOKEN_ENV = "ANTHROPIC_AUTH_TOKEN"
+
+
+@dataclass(frozen=True)
+class Message:
+    """One turn of the conversation replayed to the model."""
+
+    role: Role
+    content: str
+
+
+class TranslatorError(Exception):
+    """The model call did not produce a usable reply (kept out of retries the SDK handles)."""
+
+
+class Translator(Protocol):
+    """Anything that can answer a prompt; the tests inject a scripted fake."""
+
+    def complete(self, *, model: str, system: str, messages: Sequence[Message], max_tokens: int) -> str:
+        """Return the model's reply text for this conversation."""
+        ...
+
+
+class Credentials(TypedDict, total=False):
+    """The single credential a client is built with, keyed as the SDK expects it."""
+
+    api_key: str
+    auth_token: str
+
+
+class MessageRequest(TypedDict):
+    """The parameters of one Messages API call."""
+
+    model: str
+    max_tokens: int
+    system: list[TextBlockParam]
+    messages: list[MessageParam]
+
+
+def credentials(environ: Mapping[str, str]) -> Credentials:
+    """The credential to authenticate with, taken from the environment.
+
+    Raises:
+        ConfigError: Neither or both of `ANTHROPIC_API_KEY` and `ANTHROPIC_AUTH_TOKEN` are set.
+    """
+    api_key, auth_token = environ.get(API_KEY_ENV), environ.get(AUTH_TOKEN_ENV)
+    if api_key and auth_token:
+        raise ConfigError(f"both {API_KEY_ENV} and {AUTH_TOKEN_ENV} are set; set exactly one of them")
+    if api_key:
+        return {"api_key": api_key}
+    if auth_token:
+        return {"auth_token": auth_token}
+    raise ConfigError(f"set {API_KEY_ENV} or {AUTH_TOKEN_ENV} to run a command that calls the model")
+
+
+def message_request(*, model: str, system: str, messages: Sequence[Message], max_tokens: int) -> MessageRequest:
+    """The parameters of one call: a cached system block and no sampling parameters.
+
+    The system block is byte-identical across a language's pages, so every
+    page after the first reads it from the prompt cache. Sampling parameters
+    (`temperature` and friends) are never sent — current models reject them,
+    and repeatability comes from the pipeline's carry-forward, not the sampler.
+    """
+    return {
+        "model": model,
+        "max_tokens": max_tokens,
+        "system": [{"type": "text", "text": system, "cache_control": {"type": "ephemeral"}}],
+        "messages": [{"role": message.role, "content": message.content} for message in messages],
+    }
+
+
+def reply_text(reply: ApiMessage, *, max_tokens: int) -> str:
+    """The reply's text, or the reason there is none.
+
+    Raises:
+        TranslatorError: The reply was cut off at `max_tokens`, or the model declined to answer.
+    """
+    if reply.stop_reason == "max_tokens":
+        raise TranslatorError(f"reply truncated at the {max_tokens}-token output limit")
+    if reply.stop_reason == "refusal":
+        raise TranslatorError("model declined to answer this request")
+    return "".join(block.text for block in reply.content if block.type == "text")
+
+
+def api_failure(error: anthropic.APIError) -> ConfigError | TranslatorError:
+    """The tool error an API failure becomes: bad credentials are configuration, the rest are per-call."""
+    if isinstance(error, anthropic.AuthenticationError | anthropic.PermissionDeniedError):
+        return ConfigError(
+            f"the API rejected the credentials ({error.message}); check {API_KEY_ENV} / {AUTH_TOKEN_ENV}"
+        )
+    return TranslatorError(f"API request failed: {error.message}")
+
+
+class AnthropicTranslator:
+    """`Translator` backed by the Claude Messages API."""
+
+    def __init__(self, environ: Mapping[str, str]) -> None:
+        """Authenticate from exactly one of `ANTHROPIC_API_KEY` and `ANTHROPIC_AUTH_TOKEN`.
+
+        Raises:
+            ConfigError: Neither or both credentials are present in the environment.
+        """
+        self._client = anthropic.Anthropic(**credentials(environ))
+
+    def complete(self, *, model: str, system: str, messages: Sequence[Message], max_tokens: int) -> str:
+        """Send the conversation and return the reply text.
+
+        Raises:
+            ConfigError: The API rejected the credentials.
+            TranslatorError: The call failed, the reply was truncated, or the model declined.
+        """
+        request = message_request(model=model, system=system, messages=messages, max_tokens=max_tokens)
+        try:
+            with self._client.messages.stream(**request) as stream:
+                reply = stream.get_final_message()
+        except anthropic.APIError as exc:
+            raise api_failure(exc) from exc
+        return reply_text(reply, max_tokens=max_tokens)
diff --git a/scripts/docs/i18n/config.py b/scripts/docs/i18n/config.py
new file mode 100644
index 0000000000..e30d39d770
--- /dev/null
+++ b/scripts/docs/i18n/config.py
@@ -0,0 +1,243 @@
+"""The translation registry (`i18n/languages.yml`) and the tool's paths.
+
+The registry is the single list of language sites: the docs build reads it to
+know what to build and what the language switcher offers, and this tool reads
+it to know what to translate and with which models. Everything path-shaped
+about a language (its inputs, generated pages, state file, banner snippets)
+derives from its code so a new language is one registry entry plus its
+directory.
+"""
+
+from __future__ import annotations
+
+import re
+from collections.abc import Mapping
+from dataclasses import dataclass, replace
+from pathlib import Path
+from typing import Any, Literal, cast, get_args
+
+import yaml
+
+ROOT = Path(__file__).resolve().parents[3]
+DOCS = ROOT / "docs"
+I18N_DIR = ROOT / "i18n"
+REGISTRY_PATH = I18N_DIR / "languages.yml"
+GENERAL_PROMPT_PATH = I18N_DIR / "general-prompt.md"
+
+MODEL_ENV = "MCP_DOCS_I18N_MODEL"
+VERIFY_MODEL_ENV = "MCP_DOCS_I18N_VERIFY_MODEL"
+
+BANNER_KINDS = ("disclosure", "outdated", "untranslated", "stale", "english-only")
+
+# The generated map of translated sidebar labels, next to `state.json`.
+NAV_FILE = "nav.yml"
+
+# The writing system of a language's prose. The validator keys checks on the
+# target's script (e.g. an untranslated-English scan only makes sense for a
+# non-Latin target), never on the character mix of the output.
+Script = Literal["latin", "cjk", "hangul"]
+SCRIPTS: tuple[Script, ...] = get_args(Script)
+
+_CODE = re.compile(r"^[A-Za-z0-9-]+$")
+_LANGUAGE_KEYS = {"code", "name", "theme_language", "hreflang", "script", "enabled"}
+_TOP_KEYS = {"languages", "exclude_pages", "models"}
+_MODEL_KEYS = {"translate", "verify"}
+
+
+class ConfigError(Exception):
+    """A malformed registry, glossary, or missing input file (CLI exit code 2)."""
+
+
+@dataclass(frozen=True)
+class Language:
+    """One language site."""
+
+    code: str
+    name: str
+    theme_language: str
+    hreflang: str
+    script: Script
+    enabled: bool
+
+
+@dataclass(frozen=True)
+class Models:
+    """The translation model and the (stronger) semantic-review model."""
+
+    translate: str
+    verify: str
+
+
+@dataclass(frozen=True)
+class Registry:
+    """The parsed `languages.yml`."""
+
+    languages: tuple[Language, ...]
+    exclude_pages: tuple[str, ...]
+    models: Models
+
+    @property
+    def enabled(self) -> list[Language]:
+        """The languages that get a site, in registry order."""
+        return [language for language in self.languages if language.enabled]
+
+    def language(self, code: str) -> Language:
+        """The registry entry for `code`.
+
+        Raises:
+            ConfigError: `code` is not a registered language.
+        """
+        for language in self.languages:
+            if language.code == code:
+                return language
+        codes = ", ".join(language.code for language in self.languages)
+        raise ConfigError(f"unknown language {code!r} (registered: {codes})")
+
+
+def _keys(section: str, value: object, expected: set[str]) -> Mapping[str, Any]:
+    """`value` as a mapping whose keys are exactly `expected`."""
+    if not isinstance(value, dict):
+        raise ConfigError(f"languages.yml: {section} must be a mapping")
+    mapping = cast("dict[str, Any]", value)
+    if unknown := sorted(set(mapping) - expected):
+        raise ConfigError(f"languages.yml: {section} has unknown keys {unknown}")
+    if missing := sorted(expected - set(mapping)):
+        raise ConfigError(f"languages.yml: {section} is missing keys {missing}")
+    return mapping
+
+
+def _string(section: str, value: object) -> str:
+    if not isinstance(value, str) or not value:
+        raise ConfigError(f"languages.yml: {section} must be a non-empty string")
+    return value
+
+
+def _strings(section: str, value: object) -> tuple[str, ...]:
+    if not isinstance(value, list) or not all(isinstance(item, str) and item for item in cast("list[object]", value)):
+        raise ConfigError(f"languages.yml: {section} must be a list of non-empty strings")
+    return tuple(cast("list[str]", value))
+
+
+def _script(section: str, value: object) -> Script:
+    for script in SCRIPTS:
+        if value == script:
+            return script
+    raise ConfigError(f"languages.yml: {section} must be one of {list(SCRIPTS)}, found {value!r}")
+
+
+def load_registry(path: Path = REGISTRY_PATH) -> Registry:
+    """Parse and validate the language registry.
+
+    Raises:
+        ConfigError: The file is missing or does not match the schema.
+    """
+    try:
+        raw: object = yaml.safe_load(path.read_text(encoding="utf-8"))
+    except OSError as exc:
+        raise ConfigError(f"cannot read {path}: {exc}") from exc
+    except yaml.YAMLError as exc:
+        raise ConfigError(f"cannot parse {path}: {exc}") from exc
+    top = _keys("top level", raw, _TOP_KEYS)
+
+    entries = top["languages"]
+    if not isinstance(entries, list) or not entries:
+        raise ConfigError("languages.yml: languages must be a non-empty list")
+    languages: list[Language] = []
+    for entry in cast("list[object]", entries):
+        fields = _keys("a languages entry", entry, _LANGUAGE_KEYS)
+        code = _string("languages[].code", fields["code"])
+        if not _CODE.match(code):
+            raise ConfigError(f"languages.yml: language code {code!r} may only use letters, digits and '-'")
+        enabled = fields["enabled"]
+        if not isinstance(enabled, bool):
+            raise ConfigError(f"languages.yml: languages[{code}].enabled must be true or false")
+        languages.append(
+            Language(
+                code=code,
+                name=_string(f"languages[{code}].name", fields["name"]),
+                theme_language=_string(f"languages[{code}].theme_language", fields["theme_language"]),
+                hreflang=_string(f"languages[{code}].hreflang", fields["hreflang"]),
+                script=_script(f"languages[{code}].script", fields["script"]),
+                enabled=enabled,
+            )
+        )
+    codes = [language.code for language in languages]
+    if duplicates := sorted({code for code in codes if codes.count(code) > 1}):
+        raise ConfigError(f"languages.yml: duplicate language codes {duplicates}")
+
+    models = _keys("models", top["models"], _MODEL_KEYS)
+    return Registry(
+        languages=tuple(languages),
+        exclude_pages=_strings("exclude_pages", top["exclude_pages"]),
+        models=Models(
+            translate=_string("models.translate", models["translate"]),
+            verify=_string("models.verify", models["verify"]),
+        ),
+    )
+
+
+def resolve_models(
+    models: Models,
+    *,
+    environ: Mapping[str, str],
+    translate: str | None = None,
+    verify: str | None = None,
+) -> Models:
+    """Apply overrides to the registry's models: CLI flags win over env vars, which win over the file."""
+    return replace(
+        models,
+        translate=translate or environ.get(MODEL_ENV) or models.translate,
+        verify=verify or environ.get(VERIFY_MODEL_ENV) or models.verify,
+    )
+
+
+def language_dir(code: str, i18n_dir: Path = I18N_DIR) -> Path:
+    """The directory holding one language's inputs and generated pages."""
+    return i18n_dir / "languages" / code
+
+
+def instructions_path(code: str, i18n_dir: Path = I18N_DIR) -> Path:
+    return language_dir(code, i18n_dir) / "instructions.md"
+
+
+def glossary_path(code: str, i18n_dir: Path = I18N_DIR) -> Path:
+    return language_dir(code, i18n_dir) / "glossary.json"
+
+
+def pages_dir(code: str, i18n_dir: Path = I18N_DIR) -> Path:
+    """The generated translations, mirroring the page paths under `docs/`."""
+    return language_dir(code, i18n_dir) / "pages"
+
+
+def state_path(code: str, i18n_dir: Path = I18N_DIR) -> Path:
+    return language_dir(code, i18n_dir) / "state.json"
+
+
+def nav_path(code: str, i18n_dir: Path = I18N_DIR) -> Path:
+    """The generated translated navigation labels for one language."""
+    return language_dir(code, i18n_dir) / NAV_FILE
+
+
+def general_prompt_path(i18n_dir: Path = I18N_DIR) -> Path:
+    return i18n_dir / "general-prompt.md"
+
+
+def banner_path(kind: str, code: str, i18n_dir: Path = I18N_DIR) -> Path:
+    """The banner snippet for `code`, falling back to the English snippet when the language has none."""
+    localised = i18n_dir / "banners" / code / f"{kind}.md"
+    return localised if localised.is_file() else i18n_dir / "banners" / "en" / f"{kind}.md"
+
+
+def _build_dir(root: Path) -> Path:
+    """The throwaway directory of a repository root holding staged docs trees and language sites."""
+    return root / ".build" / "i18n"
+
+
+def staged_docs_dir(code: str, root: Path) -> Path:
+    """Where a language's docs tree is assembled (under repository `root`) before its site is built."""
+    return _build_dir(root) / code / "docs"
+
+
+def staged_site_dir(code: str, root: Path) -> Path:
+    """Where a language's site is built (under repository `root`) before being copied into `site//`."""
+    return _build_dir(root) / code / "site"
diff --git a/scripts/docs/i18n/files.py b/scripts/docs/i18n/files.py
new file mode 100644
index 0000000000..bf2b5f650f
--- /dev/null
+++ b/scripts/docs/i18n/files.py
@@ -0,0 +1,30 @@
+"""Atomic writes for the tool's generated files.
+
+A translation run or a `prune` that dies half-way through a plain
+`write_text` would leave a truncated `state.json`, `nav.yml` or translated
+page behind, and the next command would then refuse to parse it. Writing the
+new bytes to a sibling temporary file, syncing it, and renaming it over the
+target makes every generated file either its old content or its new content,
+never a torn one — the rename is atomic on the same filesystem.
+"""
+
+import contextlib
+import os
+import tempfile
+from pathlib import Path
+
+
+def write_text_atomically(path: Path, text: str) -> None:
+    """Write `text` to `path` such that an interruption leaves the old file or the new one, never a torn one."""
+    path.parent.mkdir(parents=True, exist_ok=True)
+    descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent, text=True)
+    try:
+        with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
+            stream.write(text)
+            stream.flush()
+            os.fsync(stream.fileno())
+        os.replace(temporary, path)
+    except BaseException:
+        with contextlib.suppress(OSError):
+            os.unlink(temporary)
+        raise
diff --git a/scripts/docs/i18n/glossary.py b/scripts/docs/i18n/glossary.py
new file mode 100644
index 0000000000..51a2e1a239
--- /dev/null
+++ b/scripts/docs/i18n/glossary.py
@@ -0,0 +1,119 @@
+"""A language's `glossary.json`: schema, loading and the fingerprint it feeds.
+
+The glossary is the machine-checkable half of a language's rules. It rides
+along in every prompt, and the validator enforces two of its lists after the
+fact: terms that must stay in English appear verbatim, and banned renderings
+never appear. The schema is closed — a misspelt key is an error, not a
+silently ignored field.
+"""
+
+from __future__ import annotations
+
+import json
+from collections.abc import Mapping
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any, cast
+
+from i18n.config import ConfigError
+
+SCHEMA_VERSION = 1
+_TOP_KEYS = {"version", "keep_in_source_language", "terms"}
+_TERM_KEYS = {"source", "target", "note", "avoid", "enforce"}
+_REQUIRED_TERM_KEYS = {"source", "target"}
+
+
+@dataclass(frozen=True)
+class Term:
+    """A required rendering (`target`) and banned renderings (`avoid`) for one English term."""
+
+    source: str
+    target: str
+    note: str = ""
+    avoid: tuple[str, ...] = field(default_factory=tuple)
+    enforce: bool = False
+
+
+@dataclass(frozen=True)
+class Glossary:
+    keep_in_source_language: tuple[str, ...]
+    terms: tuple[Term, ...]
+
+    @property
+    def enforced_avoids(self) -> list[tuple[str, str]]:
+        """Every `(source, banned rendering)` pair the validator must reject."""
+        return [(term.source, avoid) for term in self.terms if term.enforce for avoid in term.avoid]
+
+
+def _fail(path: Path, message: str) -> ConfigError:
+    return ConfigError(f"{path}: {message}")
+
+
+def _keys(path: Path, section: str, value: object, allowed: set[str], required: set[str]) -> Mapping[str, Any]:
+    if not isinstance(value, dict):
+        raise _fail(path, f"{section} must be an object")
+    mapping = cast("dict[str, Any]", value)
+    if unknown := sorted(set(mapping) - allowed):
+        raise _fail(path, f"{section} has unknown keys {unknown}")
+    if missing := sorted(required - set(mapping)):
+        raise _fail(path, f"{section} is missing keys {missing}")
+    return mapping
+
+
+def _strings(path: Path, section: str, value: object) -> tuple[str, ...]:
+    if not isinstance(value, list) or not all(isinstance(item, str) and item for item in cast("list[object]", value)):
+        raise _fail(path, f"{section} must be a list of non-empty strings")
+    return tuple(cast("list[str]", value))
+
+
+def load_glossary(path: Path) -> Glossary:
+    """Parse and validate a glossary file.
+
+    Raises:
+        ConfigError: The file is missing, malformed, or off-schema.
+    """
+    try:
+        raw: object = json.loads(path.read_text(encoding="utf-8"))
+    except OSError as exc:
+        raise _fail(path, f"cannot read: {exc}") from exc
+    except json.JSONDecodeError as exc:
+        raise _fail(path, f"invalid JSON: {exc}") from exc
+
+    top = _keys(path, "top level", raw, _TOP_KEYS, _TOP_KEYS)
+    if top["version"] != SCHEMA_VERSION:
+        raise _fail(path, f"version must be {SCHEMA_VERSION}")
+    keep = _strings(path, "keep_in_source_language", top["keep_in_source_language"])
+    entries = top["terms"]
+    if not isinstance(entries, list):
+        raise _fail(path, "terms must be a list")
+
+    terms: list[Term] = []
+    for index, entry in enumerate(cast("list[object]", entries)):
+        fields = _keys(path, f"terms[{index}]", entry, _TERM_KEYS, _REQUIRED_TERM_KEYS)
+        source, target, note = fields["source"], fields["target"], fields.get("note", "")
+        if not all(isinstance(value, str) for value in (source, target, note)) or not source or not target:
+            raise _fail(path, f"terms[{index}]: source, target and note must be strings")
+        avoid = _strings(path, f"terms[{index}].avoid", fields.get("avoid", []))
+        enforce = fields.get("enforce", False)
+        if not isinstance(enforce, bool):
+            raise _fail(path, f"terms[{index}].enforce must be true or false")
+        terms.append(Term(source=source, target=target, note=note, avoid=avoid, enforce=enforce))
+
+    return Glossary(keep_in_source_language=keep, terms=tuple(terms))
+
+
+def glossary_prompt(glossary: Glossary) -> str:
+    """The glossary rendered as the prompt section the model sees."""
+    lines = ["## Glossary", "", "These terms always stay in English, spelled exactly like this:", ""]
+    lines += [f"- {term}" for term in glossary.keep_in_source_language]
+    if glossary.terms:
+        lines += ["", "Use these renderings; the notes are binding:", ""]
+    for term in glossary.terms:
+        entry = f"- {term.source} → {term.target}"
+        if term.avoid:
+            entry += f" (never: {', '.join(term.avoid)})"
+        if term.note:
+            entry += f". {term.note}"
+        lines.append(entry)
+    lines.append("")
+    return "\n".join(lines)
diff --git a/scripts/docs/i18n/inputs.py b/scripts/docs/i18n/inputs.py
new file mode 100644
index 0000000000..1deec82fa8
--- /dev/null
+++ b/scripts/docs/i18n/inputs.py
@@ -0,0 +1,85 @@
+"""The prompt inputs of one language and the fingerprints they contribute.
+
+A language's translations are a pure function of four texts — the English
+page, the general prompt, the language's instructions and its glossary — and
+the pipeline version. Hashing all of them into every block hash is what makes
+`status` notice an instructions or glossary edit as an outdated translation
+without any bookkeeping beyond the state file.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+from dataclasses import dataclass
+from pathlib import Path
+
+from i18n.config import ConfigError, Language, general_prompt_path, glossary_path, instructions_path
+from i18n.glossary import Glossary, load_glossary
+from i18n.nav import NavFingerprint, labels_hash
+from i18n.pages import block_hashes, block_salt, sha256, split_blocks
+from i18n.state import Fingerprint
+
+
+@dataclass(frozen=True)
+class LanguageInputs:
+    """Everything a language's prompts are assembled from, plus its fingerprints."""
+
+    language: Language
+    general_prompt: str
+    instructions: str
+    glossary: Glossary
+    general_prompt_hash: str
+    instructions_hash: str
+    glossary_hash: str
+
+    @property
+    def salt(self) -> str:
+        """The salt every block hash for this language mixes in."""
+        return block_salt(self.general_prompt_hash, self.instructions_hash, self.glossary_hash)
+
+    def fingerprint(self, english: str) -> Fingerprint:
+        """What a translation of `english` made right now would record."""
+        return Fingerprint(
+            source_hash=sha256(english),
+            blocks=tuple(block_hashes(split_blocks(english), self.salt)),
+            general_prompt_hash=self.general_prompt_hash,
+            instructions_hash=self.instructions_hash,
+            glossary_hash=self.glossary_hash,
+        )
+
+    def nav_fingerprint(self, labels: Sequence[str]) -> NavFingerprint:
+        """What a translation of the nav `labels` made right now would record."""
+        return NavFingerprint(
+            labels_hash=labels_hash(labels),
+            general_prompt_hash=self.general_prompt_hash,
+            instructions_hash=self.instructions_hash,
+            glossary_hash=self.glossary_hash,
+        )
+
+
+def _read(path: Path) -> str:
+    try:
+        return path.read_text(encoding="utf-8")
+    except OSError as exc:
+        raise ConfigError(f"cannot read {path}: {exc}") from exc
+
+
+def load_inputs(language: Language, i18n_dir: Path) -> LanguageInputs:
+    """Read and fingerprint one language's prompt inputs.
+
+    Raises:
+        ConfigError: A required input file is missing or invalid.
+    """
+    general = _read(general_prompt_path(i18n_dir))
+    instructions = _read(instructions_path(language.code, i18n_dir))
+    glossary_file = glossary_path(language.code, i18n_dir)
+    glossary = load_glossary(glossary_file)
+    return LanguageInputs(
+        language=language,
+        general_prompt=general,
+        instructions=instructions,
+        glossary=glossary,
+        general_prompt_hash=sha256(general),
+        instructions_hash=sha256(instructions),
+        glossary_hash=sha256(_read(glossary_file)),
+    )
diff --git a/scripts/docs/i18n/markdown.py b/scripts/docs/i18n/markdown.py
new file mode 100644
index 0000000000..3161c5014d
--- /dev/null
+++ b/scripts/docs/i18n/markdown.py
@@ -0,0 +1,322 @@
+"""Markdown structure the docs tooling looks at: headings, anchors, fences, spans, links.
+
+This is the one place a heading, an anchor and a code fence are defined, so
+the anchor pinning check, the translation validator and the mechanical
+repairer cannot disagree about what is a heading, what its `{#id}` block pins,
+which anchor rules a heading breaks (`anchor_problems`), or what is code.
+Prose-level scans (glossary terms, untranslated runs) must never look inside
+code, URLs or HTML tags, so `mask` blanks those regions to same-length
+whitespace: positions and line numbers survive, but code can never match a
+prose rule. Structural extraction (fences, links, admonitions, tables) works
+on the same fence rules so every check agrees on what is code.
+"""
+
+import re
+from collections.abc import Callable, Iterator, Sequence
+from dataclasses import dataclass
+from typing import TypeAlias
+
+# A leading YAML front matter block, as MkDocs/Zensical parse it: the closer
+# is a line that is exactly `---` or `...` (a `----` line does not end it),
+# and the match runs through that line's newline. Shared by the page model
+# and the validator so both agree on where the front matter ends.
+FRONTMATTER = re.compile(r"\A---[ \t]*\n(?P.*?)^(?:---|\.\.\.)[ \t]*(?:\n|\Z)", re.MULTILINE | re.DOTALL)
+# An ATX heading, matched the way the docs' markdown parser matches it: one
+# to six hashes at column 0 (no leading indent, no space required after
+# them), an optional closing hash run that is not part of the text, and
+# backslash escapes honoured so an escaped `\#` is text rather than a closer.
+HEADING = re.compile(r"^(?P#{1,6})(?!#)(?P(?:\\.|[^\\\n])*?)#*[ \t]*$")
+# The trailing attr_list block a heading may carry (`Heading {#id .cls}`),
+# read with or without the whitespace before it: attr_list only honours a
+# block that follows whitespace (a glued `Title{#id}` renders as literal
+# text), so the parser sees the id either way and `Heading.attrs_spaced`
+# records whether the block would actually render.
+HEADING_ATTRS = re.compile(r"[ \t]*\{:?[ \t]*(?P[^}\n]*?)[ \t]*\}[ \t]*$")
+# The markdown backslash-escape rule: a backslash before ASCII punctuation
+# yields that character. It is also the anchor escape rule — the inline pass
+# resolves `{#foo\_bar}` to the id `foo_bar` before attr_list reads the block.
+ESCAPE = re.compile(r"\\(?P[!\"#$%&'()*+,\-./:;<=>?@\[\\\]^_`{|}~])")
+# An underscore that is not word-internal must be written escaped inside a
+# `{#...}` block, or the inline emphasis pass consumes it before attr_list
+# sees the block (see `anchor_source_form`).
+_BOUNDARY_UNDERSCORE = re.compile(r"(?`{3,}|~{3,})(?P[^\n]*)$")
+# Inline code span; cannot cross a blank line, so a stray unpaired backtick
+# cannot swallow the paragraphs after it.
+CODE_SPAN = re.compile(r"(?s)(?[^)\s]*)(?P[^)]*)\)")
+# A bare URL runs over printable ASCII only (minus the `< > ) ]` that close
+# a link): non-ASCII text is prose, so CJK written flush against a URL — no
+# space between them in that script — ends the URL instead of being masked
+# with it.
+_URL = re.compile(r"[a-zA-Z][a-zA-Z0-9+.-]*://(?:(?![<>)\]])[!-~])+")
+_TAG = re.compile(r"\n]*>|", flags=re.DOTALL)
+# Every link and image with its label: `!`? `[label](target ...)`.
+_LINK = re.compile(r"(?P!?)\[(?P