返回 CodeWhale
LOCALIZATION.md
根目录 / docs / LOCALIZATION.md
1 # Localization Matrix
2
3 Canonical tracking document for every locale Codewhale ships, is actively
4 building, is planning, or has explicitly deferred.
5
6 > **Scope note (2026-07-12):** this matrix covers three surfaces — the TUI
7 > locale packs (`crates/localization/locales/`), the translated READMEs (repo root),
8 > and the website (`web/`). The three ship on different cadences, so a
9 > locale can be **shipped** on one surface and **planned** on another; the
10 > per-surface tables below are the per-surface truth. The website registry
11 > is `web/lib/i18n/config.ts` (`ALL_LOCALES`): the locale switcher and route
12 > generation both derive from it.
13 >
14 > Docs translations are **not** a locale surface: they live under
15 > `docs/zh_hans/` and `docs/id/`, and their status is tracked in
16 > `docs/zh_hans/README.md` and issue #5482, not in this matrix.
17
18 Customer-visible copy also follows the [Codewhale voice and terminal
19 charter](VOICE.md); commands, key names, and glyphs remain code-owned around
20 localized prose.
21
22 Last updated: 2026-08-18 (docs/zh_hans/ restructure; docs translation
23 status tracked outside this matrix, per #5482).
24 Source-of-truth README: `README.md` (English, post-#3087).
25
26 ## Status legend
27
28 | Status | Meaning |
29 |--------|---------|
30 | **shipped** | Live on codewhale.net and/or published as a standalone README, or a TUI pack at exact `en.json` parity |
31 | **partial** | Shipped but intentionally incomplete; missing scope falls back to English and the partial status is visible |
32 | **planned** | Explicitly prioritized for the next wave |
33 | **deferred** | Acknowledged as wanted but not yet scheduled; needs layout QA, bridge support, or community champion |
34
35 ---
36
37 ## TUI locale packs
38
39 The TUI packs under `crates/localization/locales/` are the largest translation
40 surface in the repo. `en.json` is the reference; a pack is **complete**
41 only at exact raw key parity with it, enforced by
42 `scripts/check-tui-locale-parity.py` (CI) and the parity tests in
43 `crates/localization/src/lib.rs`. See `crates/localization/locales/AGENTS.md` for the
44 authoring contract.
45
46 | Locale | File | Keys vs `en.json` (1299) | Status | Notes |
47 |--------|------|--------------------------|--------|-------|
48 | English | `en.json` | 1299/1299 | **shipped** | Reference pack. |
49 | Japanese | `ja.json` | 1299/1299 | **shipped** | Complete. |
50 | Simplified Chinese | `zh-Hans.json` | 1299/1299 | **shipped** | Complete. |
51 | Traditional Chinese | `zh-Hant.json` | 1299/1299 | **shipped** | Complete (#5143). Awaiting native-speaker review. |
52 | Brazilian Portuguese | `pt-BR.json` | 1299/1299 | **shipped** | Complete. |
53 | Latin American Spanish | `es-419.json` | 1299/1299 | **shipped** | Complete. Note the website tracks `es` — the shipped TUI pack is Latin American Spanish, not `es-ES`. |
54 | Vietnamese | `vi.json` | 1299/1299 | **shipped** | Complete. |
55 | Korean | `ko.json` | 1299/1299 | **shipped** | Complete. |
56 | Catalan | `ca.json` | 1299/1299 | **shipped** | Complete (#4749/#4788). Awaiting native-speaker review. |
57 | German | `de.json` | 1299/1299 | **shipped** | Complete (#4788). Awaiting native-speaker review. |
58 | French | `fr.json` | 1299/1299 | **shipped** | Complete (#4788). Awaiting native-speaker review. |
59 | Indonesian | `id.json` | 1299/1299 | **shipped** | Complete (#4789). Awaiting native-speaker review. |
60 | Hindi | `hi.json` | 1299/1299 | **shipped** | Complete (#4790). Devanagari shaping spike: `docs/evidence/v092-devanagari-terminal-shaping.md` — code-level guarantees only; terminal visual QA and native review still open. |
61 | Russian | `ru.json` | 1299/1299 | **shipped** | Complete (#3092). Cyrillic script fixtures guard against mixed-language copy. Awaiting native-speaker review. |
62 | Ukrainian | `uk.json` | 1299/1299 | **shipped** | Complete (#4791). Cyrillic script fixtures keep it distinct from Russian (no ы/э/ъ; і/ї/є/ґ present). Awaiting native-speaker review. |
63
64 ## Website locales
65
66 The website derives routing, the switcher, sitemap, and hreflang from
67 `ALL_LOCALES` in `web/lib/i18n/config.ts` — one canonical registry, no
68 second taxonomy. **partial** locales route and are selectable with a
69 visible `(partial)` badge in the switcher; their dictionaries
70 (`web/lib/i18n/dictionaries/<code>/`) cover shared chrome (masthead, nav,
71 mobile menu, theme toggle, live ticker, footer, switcher) and the home page,
72 held to exact key parity with the English reference by
73 `npm run check:locales` and `web/lib/i18n/dictionaries.test.ts`.
74 Everything outside that scope renders the English page copy — a deliberate
75 fallback, never a dictionary key on screen.
76
77 **As of #4934 (v0.9.4) there is one dictionary path for every routed
78 locale, Chinese included.** `web/app/[locale]/page.tsx`,
79 `web/components/nav.tsx`, and `web/components/footer.tsx` no longer carry an
80 `isZh` / `foreign` copy branch: they read `getHome(locale)` and
81 `getChrome(locale)`. `web/lib/i18n/dictionaries/zh/` now exists (it used to
82 be inline TSX), and nav/footer link sets are generated once in
83 `web/lib/i18n/links.ts` so every locale gets the identical route shape.
84
85 **Website/docs translation pipeline (General Translation CLI, 2026-08-28).**
86 Runtime stays the dictionaries above — do not add `gt-next` beside them.
87 `web/gt-catalog/[locale].json` is the local JSON interchange (`en` + live
88 `zh` first). `npm run i18n:gt -- export` writes catalogs from dictionaries;
89 `check` (hooked from `check:locales`) requires them to match; `import`
90 writes reviewed JSON back to website dictionary TS only. `translate` is
91 fail-closed unless BYOK `GT_API_KEY` and `GT_PROJECT_ID` are set in the
92 environment — never commit those values, never point this config at
93 `crates/localization/locales`, and never wrap model completions. `gt generate` is
94 not used: it is a framework JSX scanner, not a JSON-catalog tool.
95 Reference shape: **`ChromeDict` 52 keys, `HomeDict` 62 keys.** Bilingual
96 secondary nav labels, the masthead seal and issue line, the ticker live
97 label, and the per-locale `Intl` date tag are dictionary values — no locale
98 renders another language's script by accident.
99
100 | Locale | Code | Status | Notes |
101 |--------|------|--------|-------|
102 | English | `en` | **shipped** | Source text and the reference dictionary shape. Every page has an EN route. |
103 | Simplified Chinese | `zh` | **shipped** | Full parity with EN on all first-class pages. Chrome + home are dictionary-backed (`dictionaries/zh/`) as of #4934; the remaining page bodies are still inline `{ en, zh }` content modules. |
104 | Japanese | `ja` | **partial** | #3091. Chrome + home page localized via dictionary; other page bodies/metadata fall back to English. |
105 | Vietnamese | `vi` | **partial** | #3091. Same scope as Japanese. |
106 | Korean | `ko` | **partial** | #3093. Same scope as Japanese. |
107 | Russian | `ru` | **partial** | #3092. Same scope as Japanese. |
108 | Ukrainian | `uk` | **partial** | #4791 — shipped alongside Russian, same scope. |
109 | Spanish | `es` | **partial** | #3093. Same scope as Japanese. |
110 | Brazilian Portuguese | `pt-BR` | **partial** | #3093. Same scope as Japanese. |
111 | French | `fr` | **planned** | #4788 — TUI pack shipped in v0.9.2; website next wave. |
112 | German | `de` | **planned** | #4788 — TUI pack shipped in v0.9.2; website next wave. |
113 | Catalan | `ca` | **planned** | #4749/#4788 — TUI pack shipped in v0.9.2; website next wave. |
114 | Indonesian | `id` | **partial** | #4789. Same scope as Japanese. |
115 | Hindi | `hi` | **planned** | #4790 — TUI pack shipped in v0.9.2; website next wave. |
116 | Arabic | `ar` | **deferred** | RTL candidate. Deferred until layout/typography QA exists (bidirectional text, mirrored chrome, number formatting). |
117
118 Every partial locale carries the full 52/62 key set (see
119 `npm run check:locales`); the chrome and home page are genuinely translated,
120 not English pass-through — `dictionaries.test.ts` fails on an English
121 prose value in a non-English pack. The new v0.9.4 strings are
122 machine-translated to the same standard as the rest of each pack and are
123 **awaiting native-speaker review**, consistent with the TUI packs above.
124
125 Remaining website scope for the partial locales (next wave): per-page body
126 copy and `generateMetadata` titles/descriptions beyond the home page, the
127 `{ en, zh }` shared-content modules under `web/lib/content/`, the
128 TerminalPlayer scene excerpts in `web/components/thinking-trace.tsx`, and
129 the `KIND_LABEL` pairs in `web/components/feed-card.tsx`. The dictionary
130 layer, routing, hreflang, and switcher already cover them, so filling in a
131 page is a dictionary edit, not plumbing. That remaining English is exactly
132 what the `(partial)` badge is honest about.
133
134 ## README locales
135
136 | Locale | File | Status | Parity check |
137 |--------|------|--------|-------------|
138 | English | `README.md` | **shipped** | Canonical source |
139 | Simplified Chinese | `README.zh-CN.md` | **shipped** | `scripts/check-readme-translations.py` (stamp + fences + URLs + sections) |
140 | Japanese | `README.ja-JP.md` | **shipped** | Same |
141 | Vietnamese | `README.vi.md` | **shipped** | Same |
142 | Korean | `README.ko-KR.md` | **shipped** | Same |
143 | Latin American Spanish | `README.es-419.md` | **shipped** | Same |
144 | Brazilian Portuguese | `README.pt-BR.md` | **shipped** | Same |
145 | Russian | `README.ru.md` | **shipped** | Same (#3092). Awaiting native-speaker review. |
146 | Ukrainian | `README.uk.md` | **shipped** | Same (#4791). Awaiting native-speaker review. |
147 | Indonesian | `README.id.md` | **shipped** | Same (#4789). Awaiting native-speaker review. |
148 | French | `README.fr.md` | **shipped** | Same. Awaiting native-speaker review. |
149 | German | `README.de.md` | **shipped** | Same. Awaiting native-speaker review. |
150 | Traditional Chinese | `README.zh-TW.md` | **shipped** | Same. Awaiting native-speaker review. |
151 | Hindi | `README.hi.md` | **shipped** | Same. Awaiting native-speaker review. |
152 | Turkish | `README.tr.md` | **shipped** | Same. Awaiting native-speaker review. |
153 | Italian | `README.it.md` | **shipped** | Same. Awaiting native-speaker review. |
154 | Polish | `README.pl.md` | **shipped** | Same. Awaiting native-speaker review. |
155 | Arabic | `README.ar.md` | **shipped** | Same. Awaiting native-speaker review. Markdown only; no HTML `dir` attributes. |
156 | Catalan | `README.ca.md` | **shipped** | Same. Awaiting native-speaker review. |
157
158 ## Drift checks
159
160 | Check | Tool | Status |
161 |-------|------|--------|
162 | TUI pack key parity with `en.json` (complete packs) | `scripts/check-tui-locale-parity.py` + parity tests in `crates/localization/src/lib.rs` | **Shipped** (CI Lint job) |
163 | README translations stay in sync with `README.md` | `scripts/check-readme-translations.py` | **Shipped** (CI Lint job) |
164 | README locale links symmetric | `scripts/check-readme-locales.sh` | **Shipped** (CI Lint job) |
165 | Website dictionaries cover every routed locale except the `en` reference | `npm run check:locales` + `web/lib/i18n/dictionaries.test.ts` | **Shipped** (#3091, extended to `zh` in #4934) |
166 | No unmarked English prose survives in a non-English website dictionary | `leaves no unmarked English prose in any non-English dictionary` in `web/lib/i18n/dictionaries.test.ts` | **Shipped** (#4934) |
167 | Nav/footer routes stay in locale-swap parity for every routed locale | `web/lib/docs-ia.test.ts` over `web/lib/i18n/links.ts` | **Shipped** (#4934) |
168 | Accept-Language routes deterministically to all routed locales | `web/lib/i18n/detect.test.ts` (middleware delegates to `lib/i18n/detect.ts`) | **Shipped** (#3091) |
169 | Locale selector lists all routed locales with partial badges | `web/lib/i18n/config.test.ts` (switcher + router derive from one registry) | **Shipped** (#3091) |
170 | hreflang alternates cover every routed locale | `web/lib/page-meta.test.ts` | **Shipped** (#3091) |
171 | Cyrillic packs stay script-pure (no mixed-language copy, ru≠uk) | `cyrillic_packs_have_script_purity_and_no_mixed_language_fixtures` in `crates/localization/src/lib.rs` + `dictionaries.test.ts` | **Shipped** (#3092/#4791) |
172 | Devanagari grapheme-safe clip/wrap at 40/60/80 columns | `truncate_to_width_never_splits_devanagari_clusters` + width fixtures in `crates/localization/src/lib.rs` | **Shipped** (#4790) |
173 | Adding a UI locale never changes model-visible prompt bytes | `v092_locales_add_no_prompt_bookends_so_prompt_bytes_stay_stable` in `crates/tui/src/prompts.rs` | **Shipped** (cache-stability contract) |
174 | No shipped locale renders a missing-message marker | `no_shipped_locale_renders_a_missing_message_marker` in `crates/localization/src/lib.rs` | **Shipped** |
175
176 ## How to add a locale
177
178 A locale is not "added" until all three surfaces below either ship it or
179 carry an explicit `planned`/`partial`/`deferred` row in this matrix.
180
181 ### 1. TUI pack
182
183 1. Create `crates/localization/locales/<tag>.json` with every key in `en.json`,
184 following `crates/localization/locales/AGENTS.md` (placeholders stay literal;
185 product terms stay English per pack convention; preserve intentional
186 leading/trailing spaces).
187 2. Add the `Locale` variant plus its `tag`/`translation_target_name`/
188 `parse_locale`/`shipped`/`shipped_complete` arms in
189 `crates/localization/src/lib.rs`, and the `include_str!` arm in the
190 test module.
191 3. Wire the typed settings schema (`UiLocale` in
192 `crates/tui/src/config_ui.rs`) plus the pickers and displays that enumerate
193 locales: onboarding language picker
194 (`crates/tui/src/tui/onboarding/language.rs` — a test forces every shipped
195 locale to be offered), setup-wizard match arms, and the locale display arms
196 in the `/config` and changelog commands. Keep the schema/round-trip invariant
197 tied to `Locale::shipped()` so these surfaces cannot silently drift.
198 4. Run `python3 scripts/check-tui-locale-parity.py` and
199 `cargo test -p codewhale-tui localization`.
200 5. If the pack must ship incomplete, declare it partial: keep it out of
201 `shipped_complete()`, mark it in `is_partial_pack()`, and add it to
202 `PARTIAL_PACKS` in `scripts/check-tui-locale-parity.py` with a tracking
203 issue. No pack is partial today — `PARTIAL_PACKS` is empty and
204 `is_partial_pack()` returns false for every shipped locale — so a new
205 entry is the only thing that reopens the English-fallback path.
206
207 ### 2. README
208
209 1. Translate `README.md` into `README.<tag>.md`, preserving structure,
210 commands, and the #3087 factual history.
211 2. Cross-link it from the language line in `README.md` and from the other
212 translated READMEs.
213 3. Restamp per `scripts/check-readme-translations.py`, then run
214 `python3 scripts/check-readme-translations.py` and
215 `bash scripts/check-readme-locales.sh`.
216
217 ### 3. Website
218
219 1. Add/flip the locale entry in `ALL_LOCALES` in `web/lib/i18n/config.ts` —
220 the switcher, routes, middleware, sitemap, and hreflang derive from it,
221 so no per-locale switcher edit is needed. Use the `partial` status for
222 locales that ship the chrome+home dictionary scope before full page
223 parity.
224 2. Create `web/lib/i18n/dictionaries/<code>/chrome.ts` and `home.ts`
225 following the English reference shape (`dictionaries/en/`).
226 3. Middleware detection needs no change for base tags; region variants and
227 base→variant mappings live in `web/lib/i18n/detect.ts`.
228 4. Run `cd web && npm run check:locales && npm test && npm run build`.
229
230 ### 4. Matrix
231
232 Update the TUI, README, and Website tables above — one row per surface,
233 with per-surface status.
234
235 ## Assessments
236
237 ### Galician (`gl`) and Basque (`eu`) — 2026-07-25, per #4749
238
239 Assessed alongside the Catalan pack (#4749 / #4788), which asked whether
240 Galician and Basque are "similar-value European additions" worth shipping
241 in the same wave.
242
243 **Decision: defer both.** Rationale:
244
245 - The case #4788 makes for Catalan is specifically that it "has an
246 unusually strong software-localization tradition and an active volunteer
247 community" — a review-capacity argument, not a market-size one. That
248 argument does not transfer: Galician and Basque have materially smaller
249 localization communities, so a pack for either would ship with no
250 realistic path to native-speaker review.
251 - Galician speakers have a workable fallback already: the shipped
252 `es-419` pack (and `pt-BR` is lexically close). Basque is a language
253 isolate with no fallback proximity — its per-string review cost is the
254 highest of the three, and machine-translated Basque is the least
255 trustworthy of the three.
256 - There is no natural "ship together" grouping: the v0.9.2 wave already
257 bundles the locales that share acceptance criteria (Latin-script
258 fr/de/ca/id, Cyrillic uk, Devanagari hi). gl/eu share only the
259 review-capacity constraint, which neither clears.
260
261 **Cost/demand evidence behind the decision:** a complete TUI pack is
262 1,299 keys (~8–12k words) plus an ongoing obligation to retranslate every
263 changed English string in lockstep — the parity gate makes silent drift a
264 CI failure, so an unmaintained pack is worse than none. No community
265 member has requested gl or eu (no issues, no PRs, no translations offered),
266 while the gl/eu base tags already route cleanly through
267 `web/middleware.ts` the day a champion appears. We do not ship packs we
268 cannot get natively reviewed, and we do not advertise unshipped packs.
269
270 Revisit when a native-speaker champion appears for either language, or if
271 Catalan uptake after v0.9.2 suggests demand. Both base tags (`gl`, `eu`)
272 route through `web/middleware.ts` with no middleware change when that
273 happens.
274
275 ## Related issues
276
277 - #3091 — Website parity with JA + VI README locales
278 - #3092 — Russian README + website localization
279 - #3093 — Korean, Spanish, Brazilian Portuguese next-wave locales
280 - #3087 — Post-rebrand README source text refresh
281 - #4057 — `zh-Hant` scoped as a partial TUI pack with English fallback
282 - #4787 — This matrix's TUI table + the locale-drift CI gates
283 - #4788 — French, German, Catalan TUI localization
284 - #4789 — Indonesian localization
285 - #4790 — Hindi localization + Devanagari terminal-shaping spike
286 - #4791 — Ukrainian localization alongside Russian
287 - #4749 — Catalan UI language + Galician/Basque assessment
288 - #5482 — EPIC(docs): review, partially restructure, and fully localize
289 documentation to Chinese
290
290 lines MARKDOWN