Repository navigation
feat(docs): ship zh-Hant, generated from zh-Hans and committed - #218
Merged
Merged
Conversation
Docs shipped seven locales; the marketing site ships eight. This adds the missing one, `zh-Hant`, the way `www.objectos.ai` already ships it in production: derived mechanically from the Simplified pages with OpenCC's `s2twp` preset, output committed, hand-editing gated. Committed output rather than conversion at request time is forced by how the locale surface is now derived. `lib/seo.ts`'s `translatedLocales()` tells a real translation from an inherited English fallback by the identity of the source file, so a conversion performed while rendering produces no `zh-Hant` file and the locale would render, switch, and appear in no sitemap entry and no hreflang alternate — invisible to the search engines this exists to reach. Fenced code blocks are copied through byte for byte; everything outside them is converted. The `translation:` stamp is carried across unchanged, which is correct rather than convenient: it records the sha256 of the English sibling the Simplified page was derived from, and the Traditional page is derived from that same English revision, so the two go stale and fresh together. `zh-TW`, `zh-HK` and `zh-MO` now negotiate to `zh-Hant` instead of Simplified, and the comment saying Traditional falls back "until zh-Hant ships" is gone with the condition it described. opencc-js is pinned to the exact version www resolves in production (1.3.1) and is a devDependency: the dictionaries decide the output bytes, so a floating range would rewrite the whole committed corpus on some future install. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G5zjYc2BoFV2NjKBBapC7C
…lish `check-translation-output.mjs` asks "is this file faithful to the thing it was produced from?", and until now that thing was always the English sibling. It is not, for a locale generated from another locale: Traditional Chinese is produced from the Simplified page, so English is two derivations away and comparing against it measures the wrong pair. Measured, not predicted: comparing the 62 generated Traditional pages against English produced 31 blocking findings, every one a verbatim re-report of a finding already open against the Simplified page it was converted from — debt that is unfixable in the file being accused, because that file is generated and the fix belongs upstream. A gate that accuses a file nobody can edit, for a defect it did not introduce, is a gate people learn to override. Against its real source the same rules become a strict check on the CONVERTER: the generator copies fences through byte for byte and rewrites only Han characters outside them, so `fence`, `url`, `frontmatter` and `component` must match the Simplified source exactly, and `length` runs at a measured 1.00x (n=62, min 1.00, max 1.02) instead of being skipped as uncalibrated. `--calibrate` uses the same base, or it would print a number the rule never compares against. `unsafe` is untouched — it needs no sibling and is never scoped. The self-test gains a three-file fixture per derived locale. Its load-bearing case reproduces the real shape (a source page whose fence differs from English) and asserts BOTH halves: silent against its source, and still red against English. Without the second assertion the case would pass for a validator that had simply stopped checking derived files at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G5zjYc2BoFV2NjKBBapC7C
Committed generated output drifts unless something compares it. This regenerates in memory and compares bytes, so a hand edit, a stale file whose Simplified source was retired, and a converter upgrade nobody re-ran all fail with the same one-line fix. It runs before `type-check` because it needs no build, and because a content drift reported as a type error is a wrong first diagnosis. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G5zjYc2BoFV2NjKBBapC7C
Both files stated a seven-locale list that is now false, and neither said what the eighth locale is. A stale fact about locale machinery is the defect this repo has spent the day removing, and here it is also a live hazard: the translation pass reads TRANSLATION.md, and a pass that translates a `zh-Hant` page from English produces a second, divergent Chinese voice that the generator then overwrites. The `--worklist` caveat is stated rather than fixed: it still emits `en -> zh-Hant` items. The bad outcome is caught loudly by `gen-zh-hant.mjs --check` rather than shipped, so this is contained; making the worklist skip derived locales is filed separately. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G5zjYc2BoFV2NjKBBapC7C
This was referenced Aug 26, 2026
os-zhuang
marked this pull request as ready for review
August 26, 2026 08:57
This was referenced Aug 26, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #172
Docs shipped seven locales;
www.objectos.aiships eight. This adds the eighth,zh-Hant, the way the marketing site already ships it in production: derived mechanically from the Simplified pages, output committed, hand-editing gated.middleware.tsno longer maps Traditional browsers onto Simplified, and the comment saying it does so "untilzh-Hantships" is gone with the condition it described.Notation: angle brackets are avoided throughout;
.LOCALE.mdxstands for a locale-suffixed sibling and->for an arrow.The mechanism claim in the dispatch is correct
Generate-and-commit is not a preference here, it is forced.
lib/seo.ts'stranslatedLocales()tells a real translation from an inherited English fallback by the identity of the source file — a real Japanese page isoperate/backup.ja.mdx, a fallback is the Englishoperate/backup.mdxunder ajalookup. A conversion performed while rendering produces nozh-Hantfile, so every Traditional page would be classified as a fallback: the locale would render and switch correctly and appear in no sitemap entry and no hreflang alternate. Verified rather than assumed — see the sitemap measurement below.Two further mechanisms were checked and needed no change:
app/llms.txt/route.tsderivesOTHER_LOCALESfromi18n.languages, sozh-Hantjoins the Other Languages list on its own. Not touched — /llms.txt tells every machine reader to build locale-prefixed .mdx URLs, and all six of them 404 #210's wording about the.mdxsurface being English-only is intact..github/scripts/check-locale-surface.mjsderives its whole oracle fromcontent/docs/andi18n.ts. Not touched. Its one hardcoded list,STATIC_PAGES, is the privacy/terms pair, which genuinely has no Traditional copy.The dependency
opencc-js, pinned to the exact versionwww.objectos.airesolves in production: 1.3.1, as a devDependency ofapps/docs. Preset{ from: 'cn', to: 'twp' }, identical tosrc/lib/zhconvert.tsthere. Nothing in the Next.js app imports it; it is build-time only and its output is committed. No runtime dependency was added.Exact rather than
^1.3.1because the dictionaries decide the output bytes: a floating range rewrites the whole committed corpus on some future install, which is the opposite of what the byte-identity gate is for.What the generator does
apps/docs/scripts/gen-zh-hant.mjs,pnpm --filter @objectos/docs gen:zh-hant,--checkfor the gate.check-translation-output.mjsuses, so the two cannot disagree about where a fence starts. Prose is converted in contiguous runs rather than line by line, so a phrase split across a soft-wrapped line does not convert differently from the same phrase on one line.translation:stamp is carried across unchanged. That is correct rather than convenient: it records the sha256 of the English sibling the Simplified page was derived from, and the Traditional page is derived from that same English revision. The freshness gate consequently judges the pair together —zh-Hansandzh-Hantreport identical 48 stale / 17 missing.@generatedmarker is stamped as the first frontmatter line (a YAML comment, matching www).meta.zh-Hant.jsongets no marker — JSON has no comments — and is covered by the same byte comparison.zh-Hantfile whose Simplified source was retired is deleted by a run and named by--check.Verification
All measurements taken on
2fc5140, the final commit of this branch.Determinism.
gen:zh-hantrun three times: in place (73 already current, 0 written), and afterfind ... -deleteof all 73 outputs. sha256 of all 73 files identical across all three runs.Sitemap and hreflang — the load-bearing check. Simplified coverage is 62 of 79, so Traditional ships at 62/79. Measured against the built
sitemap.xml, not inferred:zh-Hantlocand azh-Hanthreflang alternate.zh-Hantlocand nozh-Hanthreflang alternate. Those pages still render English under/zh-Hant/...(checked:/zh-Hant/docs/architectureanswers 200 with the English body) — they are simply never advertised.The 17:
architecture,build/packages,configure/localization,configure/notifications,configure/runtime,deploy,deploy/air-gapped,deploy/docker,deploy/kubernetes,operate,operate/audit-logs,operate/backup,operate/production,operate/troubleshooting,reference/cli,reference/environment-variables,resources/changelog.Against a running server (
next start, production build, port 3172, killed by explicit PID afterwards):Accept-LanguageLocationzh-TW/zh-Hant/docs/quickstartzh-HK/zh-Hant/docs/quickstartzh-MO/zh-Hant/docs/quickstartzh-Hant/zh-Hant/docs/quickstartzh-TW,zh;q=0.9/zh-Hant/docs/quickstartzh-CN/zh-Hans/docs/quickstart(unchanged)zh-Hans/zh-Hans/docs/quickstart(unchanged)ja-JP,ja;q=0.9/ja/docs/quickstart(unchanged)/zh-Hant/docs/quickstartserveshtml lang="zh-Hant"— #168's behaviour extends to the new locale, confirmed rather than assumed — renders Traditional text (快速開始), contains zero occurrences of the Simplified form, and the language switcher shows繁體中文.The seven existing locales are untouched. All 413 pre-existing files under
content/docs/byte-compared againste8b2062: 0 changed. Every path this branch touches undercontent/docs/matches*zh-Hant*.Gates, each verdict quoted from the gate's own output, exit codes captured before any pipe:
pnpm turbo run type-check --continue --force—Tasks: 1 successful, 1 totalpnpm turbo run build --force—Tasks: 1 successful, 1 totalpnpm turbo run test --force—3 self-test(s) passednode .github/scripts/check-locale-surface.mjs— exit 0,sitemap.xml 409/409 expected, 0 unexpected, 0 missing, 0 duplicated;llms.txtandllms-full.txt60/60node apps/docs/scripts/gen-zh-hant.mjs --check—73 generated file(s) match the zh-Hans sources byte for bytenode .github/scripts/check-translations.mjs— exit 0, no unstamped and no orphannode .github/scripts/check-translation-output.mjs --files— exit 0,138 pre-existing finding(s) reported, which is the same 138mainreports todaycheck-translation-ownership.mjs—TRANSLATION_BOT_LOGIN is not set — ownership is not enforced yet, passes; see the note at the end--forcethroughout, because the turbo cache is shared across worktrees in this container.The new gate goes red, demonstrated both ways. From the committed tree, restored and byte-verified against the HEAD blob after each:
--checkexits 1,differs from generator output: content/docs/quickstart.zh-Hant.mdxzh-Hantfile with no Simplified source: exits 1,no zh-Hans source, so nothing generates itThree changes beyond the file surface the dispatch named, each in its own commit
Called out for review rather than buried; each is revertible on its own.
1.
28c69a5—.github/scripts/check-translation-output.mjs: compare a derived locale against its source. Not optional, and measured, not predicted. That validator asks "is this file faithful to the thing it was produced from?" and hardcoded that thing as the English sibling. For a locale generated from another locale, English is two derivations away. Comparing the 62 generated Traditional pages against English produced 31 blocking findings, every one a verbatim re-report of a finding already open against the Simplified page it was converted from — unfixable in the file being accused, because that file is generated. TheTranslations / Outputjob would have been red on this PR with no edit that could turn it green.Against its real source the same rules become a strict check on the converter: fences must match
zh-Hansbyte for byte, andlengthruns at a measured 1.00x (--calibrate: n=62, min 1.00, max 1.02, 0 outside band) instead of being skipped as uncalibrated.unsafeis untouched — it needs no sibling and is never scoped. The self-test gains a three-file fixture per derived locale whose load-bearing case asserts both halves: silent against its source and still red against English, so a validator that had simply stopped checking derived files would fail it.2.
06bc25b—.github/workflows/ci.yml: one step runninggen-zh-hant.mjs --check. The dispatch asked for hand-editing to be gated. A check nothing runs is not a gate. Five lines, mirroring the existingLocale surfacestep, placed beforetype-checkbecause it needs no build and a content drift reported as a type error is a wrong first diagnosis.3.
2fc5140—AGENTS.mdanddocs/TRANSLATION.md. Both stated a seven-locale list that this PR makes false, in the file every agent reads first. Leaving it is the exact defect #203 and #210 spent the day removing.TRANSLATION.mdadditionally is the contract the translation pass runs under, and that pass must not translate a Traditional page from English.11 of the 73 generated files are
meta.zh-Hant.json, not.zh-Hant.mdx. Same generator, same conversion, zero hand-writing; without them the Traditional sidebar renders English labels beside Traditional pages. Flagged because the dispatch named only the.mdxset.One thing left unfixed, filed instead
check-translations.mjs --worklisthands the translation pass 65zh-Hantitems it must not act on #216 —check-translations.mjs --worklistemits 65en -> zh-Hantitems, instructing the translation pass to translate a locale that is generated. Acting on one produces a divergent second Chinese voice that the generator overwrites. Not fixed here because the bad outcome is contained:gen-zh-hant.mjs --checkgoes red and names the fix, so it costs a wasted pass rather than shipping anything wrong.TRANSLATION.mdcarries the warning in the meantime.normalizeLanguageonly tries the full tag and the bare base language, sode-ATandzh-Hant-TWmiss the locale they obviously mean #217 — pre-existing, untouched:normalizeLanguagetries only the full tag and the bare base language, sode-ATandzh-Hant-TWmiss the locale they obviously mean.Note for whoever sets
TRANSLATION_BOT_LOGIN(#68): the ownership check classifies.zh-Hant.mdxas a translation artifact, so this PR's shape — 73 artifacts plus 10 other files — would be rejected once that variable is set. That is a one-time bootstrap property of adding a locale, not an ongoing one: after this lands, the routine flow is English alone, then Simplified plus a regeneration, both on the account's side of the split.Diff: 83 files, 73 generated content files and 10 others (551 insertions outside
content/docs/). No file undercontent/docs/was hand-written.Generated by Claude Code