Skip to content

feat(docs): ship zh-Hant, generated from zh-Hans and committed - #218

Merged
os-zhuang merged 4 commits into
mainfrom
claude/issue-172-zh-hant
Aug 26, 2026
Merged

os-zhuang merged 4 commits into
mainfrom
claude/issue-172-zh-hant

Conversation

@hotlong

@hotlong hotlong commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Fixes #172

Docs shipped seven locales; www.objectos.ai ships 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.ts no longer maps Traditional browsers onto Simplified, and the comment saying it does so "until zh-Hant ships" is gone with the condition it described.

Notation: angle brackets are avoided throughout; .LOCALE.mdx stands 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's translatedLocales() tells a real translation from an inherited English fallback by the identity of the source file — a real Japanese page is operate/backup.ja.mdx, a fallback is the English operate/backup.mdx under a ja lookup. A conversion performed while rendering produces no zh-Hant file, 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.ts derives OTHER_LOCALES from i18n.languages, so zh-Hant joins 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 .mdx surface being English-only is intact.
  • .github/scripts/check-locale-surface.mjs derives its whole oracle from content/docs/ and i18n.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 version www.objectos.ai resolves in production: 1.3.1, as a devDependency of apps/docs. Preset { from: 'cn', to: 'twp' }, identical to src/lib/zhconvert.ts there. 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.1 because 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, --check for the gate.

  • Fenced code blocks are copied through byte for byte, fence markers and info strings included; everything outside them is converted. The fence state machine is the one check-translation-output.mjs uses, 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.
  • The 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-Hans and zh-Hant report identical 48 stale / 17 missing.
  • A pure-ASCII @generated marker is stamped as the first frontmatter line (a YAML comment, matching www). meta.zh-Hant.json gets no marker — JSON has no comments — and is covered by the same byte comparison.
  • Stray outputs are pruned: a zh-Hant file 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-hant run three times: in place (73 already current, 0 written), and after find ... -delete of 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:

  • 409 URL entries, 409 distinct, 0 duplicated (was 346; +63 = 62 docs pages + 1 locale root).
  • Of the 62 pages that have a Simplified sibling: 62/62 carry a zh-Hant loc and a zh-Hant hreflang alternate.
  • Of the 17 that do not: 17/17 have no zh-Hant loc and no zh-Hant hreflang alternate. Those pages still render English under /zh-Hant/... (checked: /zh-Hant/docs/architecture answers 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-Language Location
zh-TW /zh-Hant/docs/quickstart
zh-HK /zh-Hant/docs/quickstart
zh-MO /zh-Hant/docs/quickstart
zh-Hant /zh-Hant/docs/quickstart
zh-TW,zh;q=0.9 /zh-Hant/docs/quickstart
zh-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/quickstart serves html 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 against e8b2062: 0 changed. Every path this branch touches under content/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 total
  • pnpm turbo run build --force — Tasks: 1 successful, 1 total
  • pnpm turbo run test --force — 3 self-test(s) passed
  • node .github/scripts/check-locale-surface.mjs — exit 0, sitemap.xml 409/409 expected, 0 unexpected, 0 missing, 0 duplicated; llms.txt and llms-full.txt 60/60
  • node apps/docs/scripts/gen-zh-hant.mjs --check — 73 generated file(s) match the zh-Hans sources byte for byte
  • node .github/scripts/check-translations.mjs — exit 0, no unstamped and no orphan
  • node .github/scripts/check-translation-output.mjs --files — exit 0, 138 pre-existing finding(s) reported, which is the same 138 main reports today
  • check-translation-ownership.mjs — TRANSLATION_BOT_LOGIN is not set — ownership is not enforced yet, passes; see the note at the end

--force throughout, 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:

  • hand-edit a generated page: --check exits 1, differs from generator output: content/docs/quickstart.zh-Hant.mdx
  • add a zh-Hant file with no Simplified source: exits 1, no zh-Hans source, so nothing generates it

Three 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. The Translations / Output job 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-Hans byte for byte, and length runs at a measured 1.00x (--calibrate: n=62, min 1.00, max 1.02, 0 outside band) instead of being skipped as uncalibrated. unsafe is 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 running gen-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 existing Locale surface step, placed before type-check because it needs no build and a content drift reported as a type error is a wrong first diagnosis.

3. 2fc5140 — AGENTS.md and docs/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.md additionally 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 .mdx set.

One thing left unfixed, filed instead

Note for whoever sets TRANSLATION_BOT_LOGIN (#68): the ownership check classifies .zh-Hant.mdx as 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 under content/docs/ was hand-written.


Generated by Claude Code

claude added 4 commits August 26, 2026 05:40
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Docs ships seven locales and the marketing site ships eight — zh-Hant is the missing one, and docs already has a comment waiting for it

3 participants