docs: publish translated docs in twelve languages and the tool that maintains them - #3280
docs: publish translated docs in twelve languages and the tool that maintains them#3280maxisbey wants to merge 6 commits into
Conversation
…tains them Adds machine-translated documentation sites for Simplified Chinese, Japanese, Korean and Brazilian Portuguese under /zh-CN/, /ja/, /ko/ and /pt-BR/, generated from the English pages, which stay the only source. scripts/docs/translations.py has three commands: status, translate and stage. Pages are split at their sections and only sections whose English changed are re-translated; the rest is carried forward byte-for-byte. Heading ids come from the site renderer and are pinned into the translation, code blocks are re-imposed from the English, and each generated page records the section hashes it reflects in its own front matter. Per-language inputs live in i18n/<lang>/instructions.md and glossary.json; corrections go there, never into the generated pages. The build stages each language (English overlaid with its translations plus a short notice after each page title), takes sidebar titles from the translated pages, links the single English API reference, and builds language sites non-strictly so English-only changes never fail on translations. A manually dispatched workflow refreshes a language and opens a draft PR for its reviewers.
📚 Documentation preview
|
Drops the dispatch workflow and the issue form, the translate flags that served them (--grep, --limit, --dry-run), the reviewers field, the extra exit-code and schema-validation plumbing, and the link-resolution machinery in stage: a translation that no longer fits the current English page is simply served in English with the notice. What remains is translate, stage and status plus the build wiring.
Adds Deutsch, español, français, हिन्दी, русский, Türkçe, українська and
繁體中文 alongside the existing four, using the same language codes and
switcher labels ("de - Deutsch") as other Python documentation sites;
zh-CN and pt-BR become zh and pt (Brazilian Portuguese). Each new language
gets its instructions and glossary plus a full set of generated pages.
The language switcher now keeps you on the current page when you change
language instead of returning to the home page.
There was a problem hiding this comment.
All reported issues were addressed across 657 files
Note: This PR contains a large number of files. cubic only reviews up to 200 files per PR, so some files may not have been reviewed. cubic prioritizes the most important files to review.
Re-trigger cubic
Matches a heading's trailing attribute blocks with a pattern that reads each block in one pass; the previous form could take a very long time on a heading carrying many blocks followed by other text. Parsed results are unchanged. No-Verification-Needed: one regular expression rewritten with identical results, covered by a new unit test
Staging lays stored sections out by their recorded hashes before anything is re-imposed, so a reordered English page can no longer pair code blocks or heading ids with the wrong section; code fences are checked and restored per section and a mismatch after carry-forward goes through the repair turns like any other finding; list items and table rows are counted per section so a shortened reply is sent back whatever language its filler is in. The client is only built when a page will actually call the model, stage clears its titles marker with the tree and stages every language in one pass, and staged pages link to the English page and the API reference relative to themselves. The language switcher keeps the fragment and query. Older generated pages drop a front-matter key the tool no longer writes.
There was a problem hiding this comment.
All reported issues were addressed across 209 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
stage no longer re-imposes the current English structure onto a stored translation: a page is served exactly as it was generated (with the outdated notice once its English has moved on) or in English when there is none, so prose can never be paired with another section's code or heading ids. The switcher entries and the language sites' API entry are emitted relative to the site being built, so a mirrored copy of the build stays self-contained; the switcher script resolves the rendered roots before pointing them at the current page. Model replies end with the English's trailing newline, glossary files of the wrong shape are rejected up front, and the contributor notes describe what actually happens to a hand-edited generated page.
| ``` | ||
|
|
||
| `status` is offline: per language it lists missing, outdated (with the sections that changed), current and removable pages (translations whose English page is gone — `git rm` them). `translate` calls the Claude API (`ANTHROPIC_API_KEY` in the environment; the registry's model, or `DOCS_TRANSLATE_MODEL` to trial another) for the missing and outdated pages, retranslating only the English sections that changed and keeping the rest byte for byte; `--pages` instead re-translates exactly the named pages from scratch, which is also how a glossary or instructions change reaches existing pages (each generated page records the English section hashes it reflects, so editing those inputs invalidates nothing). `stage` assembles the tree each language site is built from (every language's, or one with `--lang`): each generated page exactly as it was generated, under an "outdated" notice linking the current English page when the English has changed since, and the English page where nothing was generated yet; `scripts/docs/build.sh` runs it before building them. Commit the generated pages in an ordinary pull request. |
There was a problem hiding this comment.
🔴 Since 8ed9da1, stage serves an outdated translation exactly as generated — including its English code fences' old --8<-- snippet paths — so an ordinary English-only PR that renames or deletes a docs_src file leaves every language's staged page pointing at the now-deleted path, and pymdownx.snippets (check_paths: true) raises SnippetMissingError, aborting the whole docs build (docs-preview goes red, deploy-docs stops deploying even the English site) until a paid translate run across all 12 languages. Fix: override check_paths to false in build_config.py for language configs (as translations.py's own _renderer() already does at line 443), or have serve() fall back to the English page when a stored translation references snippet files that no longer exist.
Extended reasoning...
What the bug is. As of head commit 8ed9da1, serve() in scripts/docs/translations.py (lines ~953-962) returns state.translation.body verbatim for any page whose stored translation exists — including pages classified outdated. The English-page fallback fires only when state.translation is None. Since generated pages carry the English code fences byte-for-byte from generation time (e.g. i18n/de/pages/servers/tools.md contains --8<-- \"docs_src/tools/tutorial001.py\"), an outdated translation is staged still pointing at whatever docs_src paths existed when it was generated.\n\nThe triggering code path. An ordinary English-only docs PR renames or deletes a docs_src example and updates the English page's fence — exactly the kind of change that keeps the English --strict build green (mkdocs.yml's own comment at lines 148-149 says check_paths: true exists to turn a renamed/deleted example into a build failure rather than a silently empty block). That edit flips every language's translation of the page to outdated, and stage writes each language's page still containing the old, now-dead snippet path. build_config.py's build_config() only rewrites nav/plugins/theme/docs_dir/site_dir/site_url for language configs — markdown_extensions (including pymdownx.snippets: check_paths: true, mkdocs.yml:150-152) passes through unchanged into every mkdocs.<lang>.gen.yml.\n\nWhy existing safeguards miss it. (1) The 'served in English until refreshed' fallback only covers missing translations, never outdated ones. (2) The non-strict language build only softens link/anchor diagnostics — Zensical 'Warning:' lines counted by build.sh — but SnippetMissingError is a Python exception raised by the preprocessor during conversion, verified empirically by the verifiers: it exits 1 even without --strict. (3) build.sh runs under set -euo pipefail and pipes zensical build -f mkdocs.<lang>.gen.yml through tee, so pipefail propagates the non-zero exit and set -e kills the run at the first language. (4) The regression test for stale fences only exercises an edit where the old snippet file still exists. Notably, the tool's own internal renderer already guards against exactly this hazard: _renderer() at translations.py:441-443 sets snippets[\"check_paths\"] = False with a comment that a missing snippet 'is not this renderer's failure' — but the language site configs written by build_config.py get no such override.\n\nImpact. From the moment such an English-only PR is opened, docs-preview goes red on a change that touched no translation; after merge, deploy-docs fails before uploading anything, so even the English site stops deploying. Recovery requires either reverting the rename or running a paid translate across all 12 languages. This directly breaks the PR description's stated design contract that 'language builds are non-strict, so an English-only PR can't fail because of translations.' The failure mode is new in 8ed9da1: before it, stage re-imposed the current English fences onto stored translations, so staged pages always carried today's snippet paths; the commit's stated trade-off mentioned only content staleness, not a build-breaking path.\n\nStep-by-step proof (reproduced end-to-end at PR head by verifiers): (1) git mv docs_src/tools/tutorial001.py docs_src/tools/first_tool.py and update docs/servers/tools.md's fence accordingly — the English --strict build stays green. (2) translations.py status --lang de reports servers/tools.md outdated ('English changed in: ## Your first tool'). (3) translations.py stage --lang de writes .build/i18n/de/docs/servers/tools.md still containing --8<-- \"docs_src/tools/tutorial001.py\". (4) zensical build -f mkdocs.de.gen.yml (the exact build.sh invocation, no --strict) exits 1 with SnippetMissingError: Snippet at path 'docs_src/tools/tutorial001.py' could not be found. (5) set -euo pipefail in build.sh aborts the whole run; docs-preview and deploy-docs both fail.\n\nHow to fix. Either override pymdownx.snippets check_paths to false in build_config.build_config() when writing language configs (mirroring the tool's own _renderer()), or have serve() fall back to the English page when a stored translation's fences reference snippet files that no longer exist. Both are small, contained changes.
| The English pages under `docs/` are the source. This directory holds what steers their machine translation and the generated result; [`docs/translations.md`](../docs/translations.md) is the reader-facing explanation. | ||
|
|
||
| - `languages.yml` — the registry: one entry per translated site (served at `/<code>/`), the model id, and the nav pages that stay in English. | ||
| - `general-prompt.md` — translation rules shared by every language. `notices.md` — English source of the three notes staged onto the pages of a translated site. |
There was a problem hiding this comment.
🟡 render_notice() fills the notice placeholders by byte-exact replacement of (ENGLISH_PAGE) / (TRANSLATIONS_PAGE), but the validators only guarantee the link target — a reply rendering the link as [englische Seite](ENGLISH_PAGE "…") or with trailing whitespace passes reimpose() and validate() with zero findings, is accepted as a current translation, and then ships a literal dead ENGLISH_PAGE href in the notice stamped on every page of that language site. Fix by substituting by target (e.g. a regex on \]\(\s*ENGLISH_PAGE\b[^)]*\), matching the property the validators actually enforce) or by validating the exact placeholder bytes in notices.md replies so the repair loop corrects deviant forms.
Extended reasoning...
What the bug is. render_notice() (scripts/docs/translations.py:981-982) fills the two placeholder links in a translated notices.md with byte-exact text replacement: body.replace("(ENGLISH_PAGE)", ...) and body.replace("(TRANSLATIONS_PAGE)", ...). But the machinery that guarantees the placeholder survives translation enforces a strictly weaker property. LINK = r"!?\[[^\]]*\]\((?P<target>[^)\s]*)[^)]*\)" (line 89) extracts only the link target, and its trailing [^)]* deliberately tolerates anything between the target and the closing paren — a Markdown title attribute or trailing whitespace. _check_targets() (lines 769-779) and validate() compare only the multiset of targets per section, so both deviant forms match the English exactly.
The code path. A model reply for i18n/<lang>/notices.md that renders the link as [englische Seite](ENGLISH_PAGE "englische Seite") or [English page](ENGLISH_PAGE ) — both valid Markdown a model plausibly emits — produces zero findings in reimpose() and validate(): no repair turn fires, and the file is written as an accepted, hash-current translation. Later, stage() calls render_notice() to stamp the notice under the H1 of every staged page of that language, and .replace("(ENGLISH_PAGE)", ...) finds no match, so the rendered admonition keeps the literal target. Verifiers reproduced this end-to-end by executing the module's own functions at PR head: _check_targets(english, reply, findings) leaves findings == [], validate(english, reply, glossary) returns [], yet render_notice(parse_notices(reply)["outdated"], "outdated", "servers/tools.md") returns a body still containing ](ENGLISH_PAGE "englische Seite"). (TRANSLATIONS_PAGE) has the identical exposure.
Why nothing downstream catches it. The general prompt does tell the model to leave the placeholder targets exactly as they are, but this tool's design is that every prompt rule the output depends on is backed by a validator plus repair turns — exactly the class of gap 8ed9da1 closed for glossary shapes and trailing newlines. Here the validator accepts a superset (any link whose target is ENGLISH_PAGE) of what the consumer requires (the exact bytes (ENGLISH_PAGE)), and no check covers the difference: the _PLACEHOLDERS omission check explicitly exempts bracketed text followed by ( (link labels), Zensical's link validation only covers .md targets so a bare ENGLISH_PAGE href builds green, and the language builds are non-strict anyway (build.sh only counts warnings, and this produces none). Because classify() keys freshness on English section hashes only, the accepted deviant file stays "current" and is carried forward byte-for-byte on every future run.
Impact. The notices page is the one generated page whose text is stamped onto every page of its language site — one accepted deviant link form ships a dead relative href (e.g. /de/servers/tools/ENGLISH_PAGE, a 404) at the top of all ~51 pages of that site, invisible to every automated check and self-perpetuating until a human notices it in a rendered site.
Step-by-step proof. (1) A translate run for de gets a model reply for notices.md whose outdated-notice link reads [englische Seite](ENGLISH_PAGE "englische Seite"). (2) LINK extracts target ENGLISH_PAGE from both English and reply; _check_targets and validate return no findings; the file is written with current hashes. (3) stage --lang de calls render_notice(notice, "outdated", "servers/tools.md"); .replace("(ENGLISH_PAGE)", ...) matches nothing because the actual bytes are (ENGLISH_PAGE "englische Seite"). (4) The staged page — and every other de page — carries [englische Seite](ENGLISH_PAGE "englische Seite") in its notice; the renderer emits an href of ENGLISH_PAGE, a 404 relative to each page. (5) The next status/translate run sees the file as current and never retouches it.
How to fix. Substitute by link target instead of exact bytes — e.g. rewrite via a regex keyed on the target the way the existing _API_LINK rewrite (line 109) already does: \]\(\s*ENGLISH_PAGE\b[^)]*\) -> ]({english_site(page)}{page_url(page)}). Alternatively, add a validator finding when a notices.md reply carries the placeholder in any form other than the exact substitutable ](ENGLISH_PAGE) / ](TRANSLATIONS_PAGE) bytes, so the existing repair loop corrects it before the file is accepted.
Severity. This is docs tooling, the trigger is probabilistic (the currently committed notices files are all correct; a model reply must emit a title attribute or trailing whitespace against the prompt's instruction), and the failure mode is a broken link in a notice, not an SDK failure — so nit, not blocking. It is, however, exactly the validator/consumer contract-gap class the author fixed twice in 8ed9da1, so it is worth closing while the tooling is fresh.
|
|
||
| The English pages under `docs/` are the source. This directory holds what steers their machine translation and the generated result; [`docs/translations.md`](../docs/translations.md) is the reader-facing explanation. | ||
|
|
||
| - `languages.yml` — the registry: one entry per translated site (served at `/<code>/`), the model id, and the nav pages that stay in English. |
There was a problem hiding this comment.
🟡 load_registry (scripts/docs/build_config.py:74-79) silently accepts a bare string for exclude in languages.yml — the natural slip exclude: migration.md iterates per character into ['m','i','g',...], voiding the exclusion so the 198 KB migration guide (deliberately never translated) is reported 'missing' and selected as a paid translation job for all 12 languages, with no fail-fast. Apply the same isinstance shape check that 8ed9da1 added to load_glossary so this raises ValueError (exit 2) before any page work.
Extended reasoning...
What the bug is. load_registry (scripts/docs/build_config.py:74-79) parses i18n/languages.yml with [str(pattern) for pattern in raw["exclude"]]. A Python str is iterable, so the natural YAML slip exclude: migration.md (dropping the brackets from the shipped exclude: [migration.md]) loads without error as ['m','i','g','r','a','t','i','o','n','.','m','d']. None of the exceptions in the catch net (OSError/YAMLError/TypeError/KeyError) fires, so the docstring's promise — ValueError when the file is "not the shape of Registry" — is silently broken, and the tool's documented fail-fast contract (configuration errors exit 2 before any API call) is violated.\n\nThe code path that triggers it. With the exploded exclude list, _excluded() (scripts/docs/translations.py:447-449) matches nothing: each single-character pattern lacks the /** suffix, so it is compared by exact equality against full page paths, and no page path equals a single character. docs/migration.md therefore joins repo.translatable (load_repo), and classify() reports it missing for every one of the 12 languages. select_jobs() then selects it as a fresh paid job on the next translate --lang X run — the routine way corrections are shipped.\n\nWhy existing code doesn't prevent it. Commit 8ed9da1 fixed exactly this hazard class for glossary.json: load_glossary now runs bare-string values for keep/terms/avoid through the _strings() isinstance shape check (translations.py:311-331), so "a bare string exits 2 before anything is translated" (per the resolved review thread). But load_registry — which this PR owns and which the same contributors edit when adding a language — got no equivalent guard; only exclude is unguarded (languages is incidentally protected because Language(**char) raises TypeError, which IS caught). No wrong-shape registry test exists in tests/docs/test_build_config.py.\n\nImpact. docs/migration.md is 197,811 bytes / ~2,900 lines — the largest page in the corpus and the one page the registry deliberately never translates. Its translation likely exceeds OUTPUT_TOKEN_BUDGET (64,000 output tokens, translations.py:54), so the next paid run pays for a full 64k-token generation per language and then fails that page with 'the reply was cut off at 64000 output tokens' (translations.py:905-908) — repeated on every subsequent run until someone traces the failure two steps back to the malformed registry field. If the reply does fit, the run instead writes, commits, and publishes a machine translation of the v1→v2 migration guide that the registry says must stay English. Either way status only shows migration.md as 'missing', which points nowhere near the actual cause.\n\nStep-by-step proof (reproduced independently by three verifiers executing the module's own functions at PR head 8ed9da1): (1) edit i18n/languages.yml to exclude: migration.md; (2) load_registry() returns Registry(model=..., exclude=['m','i','g','r','a','t','i','o','n','.','m','d'], ...) with no exception; (3) _excluded('migration.md', registry.exclude) returns False — each pattern is a single char, none ends in /**, none equals the page path; (4) classify() lists migration.md as missing for all 12 languages and status exits 0 with no diagnostic; (5) the next translate run selects it as a paid job.\n\nHow to fix. Apply the same _strings()-style check in load_registry: if not isinstance(raw['exclude'], list): raise ValueError(...) (or reuse the helper), and arguably require raw['languages'] to be a list as well for symmetry. Two lines, matching the pattern the author already established for the glossary in 8ed9da1; the slip then raises ValueError → ConfigError → exit 2 before any page work, as the docstring already promises.\n\nMarked as a nit: this is docs tooling, it requires a future config-editing slip to trigger, and nothing breaks at merge — but the guard is cheap and closes the residual instance of a bug class already accepted and fixed for glossary.json in this same PR.
| uv run --frozen python scripts/docs/translations.py stage [--lang CODE] | ||
| ``` | ||
|
|
||
| `status` is offline: per language it lists missing, outdated (with the sections that changed), current and removable pages (translations whose English page is gone — `git rm` them). `translate` calls the Claude API (`ANTHROPIC_API_KEY` in the environment; the registry's model, or `DOCS_TRANSLATE_MODEL` to trial another) for the missing and outdated pages, retranslating only the English sections that changed and keeping the rest byte for byte; `--pages` instead re-translates exactly the named pages from scratch, which is also how a glossary or instructions change reaches existing pages (each generated page records the English section hashes it reflects, so editing those inputs invalidates nothing). `stage` assembles the tree each language site is built from (every language's, or one with `--lang`): each generated page exactly as it was generated, under an "outdated" notice linking the current English page when the English has changed since, and the English page where nothing was generated yet; `scripts/docs/build.sh` runs it before building them. Commit the generated pages in an ordinary pull request. |
There was a problem hiding this comment.
🟡 In translate_page() (scripts/docs/translations.py:910-918), the first reimpose() pass validates the model's full reply — including closed sections that carry_forward() is about to overwrite byte-for-byte with the stored prior translation — so a benign reproduction drift in a carried section (a dropped link, a merged fence) blocks assembly, burns paid repair turns, and if it persists fails the page (exit 1, all 1+MAX_REPAIRS calls consumed) with a diagnostic blaming a section this run never rewrote. Scoping pass-1 fence/link findings to job.open sections (or assembling before validating) loses no protection, since the second reimpose pass already re-checks the assembled page.
Extended reasoning...
What the bug is. For an update job (an outdated page where only some sections changed), translate_page() asks the model to retranslate the open sections and merely reproduce the closed ones line by line. The reply's closed sections are throwaway text: carry_forward() overwrites every one of them with the stored, previously-validated prior translation, byte for byte — that is the whole point of the function, and update_request() even warns the model that the previous wording "may be outdated". Yet the first reimpose() pass at scripts/docs/translations.py:910 runs over the full reply, enforcing per-section link targets (_check_targets) and fence counts (_restore_fences) on the reproduction that is about to be discarded.
The code path.
result = reimpose(english, ids, unwrap(english, completion.text)) # checks the whole reply
if isinstance(result, str): # only then is carry_forward reached
result = reimpose(english, ids, carry_forward(job, result))A Mismatch from pass 1 short-circuits the isinstance(result, str) guard, so carry_forward() at line 912 never runs, the finding goes into a paid repair turn, and if the drift persists across all 1 + MAX_REPAIRS turns the page fails outright — even though assembling the same reply would have produced a perfectly valid page.
Step-by-step proof (reproduced independently by three verifiers at PR head 8ed9da1, using the test suite's own fixtures make_repo/FakeTranslator from tests/docs/test_translations.py):
- Translate
jafully; then edit only the## Errorssection ofdocs/servers/tools.md, soclassify()yieldsjob.open == [2]with the intro and## Your first toolclosed. - Script a reply that translates the open section correctly but drops the intro's
[home](index.md#install)link markup while reproducing the intro — classic verbatim-reproduction drift. - Pass 1 returns
Mismatch(["missing links to ['index.md#install']: keep every link of the English where it is"]); assembly is never reached; with the drift persisting, the run ends witherror: tools.md: unfixed after 2 repairs: missing links to ['index.md#install']..., exit 1, all 3 paid calls consumed, page left outdated. The diagnostic blames a link in a section this run never rewrote. - Counterfactual, using the module's own functions:
carry_forward(job, unwrap(english, reply))followed byreimpose()returns a cleanstr, and_validate_open()returns[]— zero findings. The assembled intro carries the link (it comes byte-for-byte from the stored prior translation) and the Errors section is the fresh retranslation.
Why existing safeguards don't prevent it. carry_forward() only needs heading alignment (matching sections() counts, for its zip(strict=True)) to place sections; the link/fence checks in pass 1 are stricter than assembly requires. The design itself distrusts the model's reproduction of closed sections — carry_forward exists precisely because of that — so gating assembly on the faithfulness of that reproduction contradicts the tool's own premise. reimpose's docstring even notes that any assembly of passing sections passes too, the exact property that makes the counterfactual valid.
Impact. On the update path — the tool's steady-state mode — each benign drift turn wastes one paid API call, and persistent drift produces a spurious exit-1 page failure with a misleading error pointing at a section the run never rewrote. Nothing user-facing breaks (the site keeps serving the stored translation under the outdated notice), and a maintainer can recover by rerunning or using --pages, which is why this is a nit rather than blocking.
How to fix. For update jobs, treat pass-1 fence/link findings in closed sections as non-blocking — only heading count/level misalignment should prevent assembly. Concretely: restore fences, check heading alignment, assemble via carry_forward, then run the existing reimpose + _validate_open on the assembled page; or scope _restore_fences/_check_targets findings to job.open sections when job.previous is not None. The second reimpose pass already re-checks the assembled page (where closed sections come from prior validated text), so relaxing pass 1 loses no protection. Note this is the converse of the earlier fence-granularity comment (per-section fence counts and routing post-assembly mismatches into the repair loop, both done at head): this is about pre-assembly checks applied to text assembly discards.
Ships the docs in twelve languages — de, es, fr, hi, ja, ko, pt (Brazilian), ru,
tr, uk, zh and zh-hant, each at
/<code>/— machine-translated from the Englishpages, plus the small tool that keeps them current. English stays
the only source; everything under
i18n/<lang>/pages/is generated. Supersedes#3231 and #3265 (same goal; this takes the review of both into account —
notably no anchor edits to the English docs, no nav-label subsystem, no
second-model gate, no state file).
scripts/docs/translations.py— three commands:status,translate --lang X [--pages …],stage --lang X. Pages are split at##sections; only sections whose English changed are re-translatedand everything else is carried forward byte-for-byte, so a one-line English
edit is a one-line translated diff. Heading ids come from the site renderer
itself and are pinned into the translation; code blocks are re-imposed from the
English mechanically and link targets are checked against it. Provenance (section hashes) lives
in each generated page's front matter — no side state.
i18n/<lang>/instructions.md(register, voice,typography) and
glossary.json(terms, keep-list, banned renderings), thentranslate --pages …for the affected pages; the generated pages are neverhand-edited.
short notice after each H1 — machine-translated / behind the English page /
shown in English), sidebar titles come from each translated page's own H1,
and language builds are non-strict, so an English-only PR can't fail because
of translations; a translation that no longer fits its English page is served
in English until refreshed. The language sites add about a minute to the docs
build in total; the API reference is linked, not rebuilt per language.
code - nameand keeps you on the current pagewhen you change language.
docs/translations.mdexplains all this to readers.Intentional behavior changes
None to the SDK. The docs build now also produces the twelve language sites;
anthropicis added in a non-defaulttranslatedependency group (normalinstalls don't get it).
Review notes
reviewer lined up; reading order:
i18n/general-prompt.md→i18n/pt/instructions.md+glossary.json→ a few pages underi18n/pt/pages/→docs/translations.md→ the tool.packaging, tracked separately); French spacing uses ordinary rather than
no-break spaces (the model won't emit U+00A0; a small deterministic pass could); the "API Reference" nav label stays English
on language sites; a handful of terminology consistency notes for native
reviewers are listed in
i18n/<lang>/glossary.jsonnotes as provisional.ANTHROPIC_API_KEY; nothing in CI calls the API.AI Disclaimer