Documentation i18n Sync (EN ⇄ VI)
LumiBase ships docs in two locales: docs/en (canonical) and docs/vi. The
i18n sync tooling under scripts/docs-i18n/ keeps them aligned, detects when a
file is written in the wrong language, reports which pairs are out of sync, and
stamps every managed file with a version and update timestamp.
Translations are written by hand, not machine-translated. A person — in
practice an LLM reading the source document in the editor — writes the target
side, then stamp-pair.mjs records the provenance. This is a deliberate project
decision (cost, plus reviewable quality over an unsupervised pipeline); it is
recorded in docs/.i18n/TASKS.md §6. There is no ANTHROPIC_API_KEY secret
in CI and none is wanted, so nothing in this repository translates
automatically.
The machine-translation path (--apply, Anthropic Messages API) still exists in
the code for anyone who reverses that decision, but it is unused: without a key
it now fails loudly instead of quietly doing nothing. For a while it did the
quiet thing, and CI committed "sync en/vi translations" on every docs push while
translating nothing at all.
What it does
- Language detection. A heuristic detector (
lang-detect.mjs) classifies each doc asviorenfrom its prose, ignoring code blocks, links and front matter. It is tuned for the vi/en pair only. - Source-of-truth resolution. Per file pair the tooling decides which side
is authored content and which is a translation. A file under
docs/enthat actually contains Vietnamese is treated as Vietnamese-authored. - No-loss preservation. When an
enfile holds Vietnamese and there is nodocs/vicounterpart, that Vietnamese content is copied intodocs/vibefore theenside is ever rewritten in English. When a Vietnameseenfile conflicts with an existing, differentdocs/vifile, the tooling does not overwrite anything — it copies the at-risk content todocs/.i18n/preserved/and flags a conflict for manual review. - Translation planning. Stale or missing targets are listed as pending work
— the tooling reports what needs translating and in which direction; a human
writes the text. (The unused
--applypath would translate with markdown-safe placeholder protection, so code, links, inline code and HTML are never sent to an API.) - Versioning. Each written file gets front matter:
version,lastUpdated,sourceLang,translatedFrom,sourceHash,mtEngine, andsyncStatus. A target is considered stale only when the sourcesourceHashchanges, so re-stamping is idempotent and the two locales' version numbers stay comparable. - Audit trail. Every run appends to
docs/i18n-sync-log.mdand writes a machine-readabledocs/.i18n/last-report.json.
Running it
# Detect only — classify files, write log + report, change no docs.
pnpm docs:i18n:detect
# Structural parity of every pair (or one rel). Read-only.
pnpm docs:i18n:parity
# Preserve at-risk Vietnamese content + stamp versions, but do not translate.
pnpm docs:i18n:preserve
# Not used by this project: needs ANTHROPIC_API_KEY and exits 2 without one.
pnpm docs:i18n:sync
Translating a file by hand — the actual workflow — is five steps:
# 1. Read docs/<src>/<rel>, write the translation into docs/<tgt>/<rel>.
# 2. Check the two sides are still the same document.
pnpm docs:i18n:parity <rel>
# 3. Check the doc's claims against the source tree (mandatory before stamping).
pnpm docs:i18n:verify <rel>
# 4. Record provenance for both locales (never hand-write this front matter).
node scripts/docs-i18n/stamp-pair.mjs <rel> <en|vi> --verified
# 5. Confirm the pair now reads up-to-date, then drop the machine artifacts.
pnpm docs:i18n:detect
git checkout -- docs/.i18n/last-report.json docs/i18n-sync-log.md
Three checkers, three different questions, and the split matters:
docs:i18n:detect asks same source revision?; docs:i18n:parity asks is the
translation the same document?; docs:i18n:verify asks is what it says still
true of the code? A pair can pass any one of them and fail the others.
stamp-pair.mjs runs the parity check itself and refuses to stamp a pair that
has drifted — a target still in the source language, dropped sections, translated
identifiers, broken link targets, a truncated tail. Stamping is what makes a pair
read "up-to-date" everywhere else, so it is the last point at which a bad
translation can be stopped, and there is no reviewer standing after it. Use
--allow-structure-drift only for a deliberate divergence, and prefer a
<!-- check-parity: allow <check> --> waiver in the doc so the reason lives next
to it. --verified likewise refuses while any code claim is stale, and refuses
when a doc makes no testable claim at all — "nothing to check" is not a pass, so
such a file is stamped without the marker and needs a human read.
Per-file rules, the priority order and the outstanding backlog live in
docs/.i18n/TASKS.md.
Environment variables:
| Variable | Purpose | Default |
|---|---|---|
ANTHROPIC_API_KEY | Anthropic API key — only for the unused --apply path | — |
ANTHROPIC_MODEL | Claude model id | claude-sonnet-4-6 |
ANTHROPIC_BASE_URL | API base URL (proxy override) | https://api.anthropic.com |
ANTHROPIC_MAX_TOKENS | Max output tokens per request | 8192 |
DOCS_ROOT | Override docs root (testing/CI) | repo docs/ |
Without an API key, --apply exits 2 and points you at docs:i18n:detect /
docs:i18n:preserve. It does not fall back silently.
CI
.github/workflows/docs-i18n-sync.yml runs on changes under docs/** or
scripts/docs-i18n/**:
- Pull requests: detect + code-reference + parity checks, all report-only.
Uploads the reports as artifacts; writes nothing. Report-only because the
existing corpus still carries findings — the gate that bites today is
stamp-pair.mjs, which no new translation can get past unchecked. - Push to
main: preservation + version stamps, committed back. It does not translate, and the outstanding-pair count is echoed into the job summary so the backlog stays visible.
No repository secret is involved. Translations land through pull requests, by
hand, in the same commit as the change that made them necessary — see the
bilingual-docs rule in CLAUDE.md.
Front-matter fields
| Field | Meaning |
|---|---|
version | Integer, bumped when content changes |
lastUpdated | ISO-8601 timestamp of the last write |
sourceLang | Authored language of this file |
translatedFrom | Source locale a translated file came from |
sourceHash | Hash of the source body the translation was built from |
contentHash | Hash of an authored file's own body (change detection) |
mtEngine | How the translation was produced: manual (hand-written, current) or claude (legacy --apply output) |
syncStatus | human-translated (current), or machine-translated / needs-review from the legacy path |
codeVerified | Timestamp at which the doc's code-facing claims were checked |
codeVerifiedHash | Body hash the check was pinned to — editing the body invalidates the assurance |
syncStatus/sourceHash answer "do both locales describe the same source
revision?"; codeVerified answers "were this doc's claims about the repository
checked?". Two separate questions: two translations can agree perfectly and be
wrong together. A full match needs both markers.
The docs viewer reads lastUpdated for the displayed "last modified" date,
falling back to filesystem mtime when the field is absent.
Limitations
- Detection is heuristic, not a statistical language model; borderline files
(mostly English with a few Vietnamese terms) may need a manual
sourceLangstamp. - The default authoring direction for an in-sync pair is
en → vi. Files authored in Vietnamese underdocs/enare detected and handled, but routine pairs assume English is canonical. - Translation throughput is human, so a large backlog clears slowly: run
pnpm docs:i18n:detectfor the current count rather than assuming CI caught up. - Legacy
machine-translatedpages predate the manual policy and have not all been proofread.