cd08c50ca6
Co-authored-by: Y.anbe <yoshitaka.anbe@aot.it.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
1282 lines
70 KiB
Markdown
1282 lines
70 KiB
Markdown
# Internationalization (i18n)
|
||
|
||
Kandev's web UI is internationalized with [i18next](https://www.i18next.com) +
|
||
[react-i18next](https://react.i18next.com). English is the source locale,
|
||
European Portuguese (`pt-pt`), Simplified Chinese (`zh-cn`), Traditional Chinese
|
||
Taiwan (`zh-tw`), Traditional Chinese Hong Kong (`zh-hk`), and Japanese (`ja`,
|
||
endonym `日本語`) are the shipped human locales, and a generated **pseudo-locale**
|
||
is used for QA. Requirements live in
|
||
[`docs/specs/platform/requirements/i18n.md`](specs/platform/requirements/i18n.md). Traditional Chinese
|
||
catalogs are derived from `zh-cn` via OpenCC plus a product glossary; see
|
||
[`docs/specs/platform/requirements/traditional-chinese-locales.md`](specs/platform/requirements/traditional-chinese-locales.md)
|
||
and [`docs/plans/traditional-chinese-locales/`](plans/traditional-chinese-locales).
|
||
|
||
The migration is **complete**. Every routed web UI surface renders through `t()`
|
||
/ `<Trans>`, and `i18nGuardFiles` now enumerates the directories that hold them.
|
||
Running the guard across `components app hooks lib` reports literals in only the
|
||
unrouted QA pages under `app/demo/`. New copy is still guarded everywhere, so a
|
||
hardcoded literal is now a regression rather than unfinished migration.
|
||
|
||
Two enforcement layers, and it matters which is which:
|
||
|
||
- **New code is guarded everywhere.** Copy you add — in a new file, or on a line
|
||
you changed — must go through `t()` / `<Trans>` whatever directory it is in.
|
||
See [the new-code ratchet](#but-new-code-is-guarded-everywhere).
|
||
- **Whole files are guarded everywhere.** The eslint literal rule is an error on
|
||
every `.ts`/`.tsx` file (tests and `e2e/` excluded), and `check-nonjsx-copy.mjs`
|
||
scans the whole tree by exclusion. Both were scoped to `i18nGuardFiles` while
|
||
the migration ran; that list is now the migration record, not the bound. See
|
||
[The guard is scoped](#the-guard-is-scoped-on-purpose) and
|
||
[Externalizing a path](#externalizing-a-path).
|
||
- **The five real locales gate the build.** `pt-pt`, `zh-cn`, `zh-hk`, `zh-tw`
|
||
and `ja` are complete, and adding an `en` key without translating it fails
|
||
`pnpm run i18n:check`. See [Real locales](#real-locale-catalogs-gate).
|
||
|
||
## TL;DR for adding a string
|
||
|
||
1. Add the English text to the right namespace file, e.g.
|
||
`apps/web/src/locales/en/settings.json`:
|
||
```json
|
||
{ "deleteExecutor": "Delete executor" }
|
||
```
|
||
2. Reference it by `namespace:key`:
|
||
```tsx
|
||
import { useTranslation } from "react-i18next";
|
||
const { t } = useTranslation();
|
||
<Button>{t("settings:deleteExecutor")}</Button>
|
||
<input placeholder={t("settings:searchExecutors")} />
|
||
toast.success(t("settings:executorSaved"));
|
||
```
|
||
3. Regenerate the pseudo catalog: `pnpm run i18n:pseudo`.
|
||
|
||
**Namespaces** map to product areas. Current namespaces are `agents`,
|
||
`automations`, `azuredevops`, `chat`, `common` (shared/anything cross-cutting), `configChat`,
|
||
`executors`, `github`, `gitlab`, `jira`, `linear`, `sentry`, `settings`,
|
||
`sidebar`, `system`, `workflows`, and `workspaces`. Each migrated area
|
||
can add the namespace it needs (`statusBar`, `kanban`, `task`, …). A string used
|
||
in two or more areas belongs in `common` so it is translated once. A new
|
||
namespace needs no registration: `lib/i18n/index.ts` discovers catalogs by glob.
|
||
|
||
Adding the same namespace to every real locale in the same change is the ideal,
|
||
but it is **not required and does not gate** — translation is handled out of
|
||
band, so `i18n:check` reports a real-locale gap as a warning. See
|
||
[Real locales warn, they do not gate](#real-locales-warn-they-do-not-gate).
|
||
|
||
### Interpolation
|
||
|
||
```json
|
||
{ "deletedItems": "Deleted {{count}} items" }
|
||
```
|
||
|
||
```tsx
|
||
t("common:deletedItems", { count });
|
||
```
|
||
|
||
### Plurals
|
||
|
||
i18next uses suffixed keys; the call site passes `count`:
|
||
|
||
```json
|
||
{ "sessions_one": "{{count}} session", "sessions_other": "{{count}} sessions" }
|
||
```
|
||
|
||
```tsx
|
||
t("kanban:sessions", { count: sessionCount });
|
||
```
|
||
|
||
### Copy containing markup
|
||
|
||
Use react-i18next's `<Trans>`. Tag indices count **every** child (text nodes
|
||
included), and the tag body is the element's text:
|
||
|
||
```tsx
|
||
<Trans i18nKey="settings:typeToConfirm" values={{ phrase }}>
|
||
Type <strong>{phrase}</strong> to continue.
|
||
</Trans>
|
||
```
|
||
|
||
```json
|
||
{ "typeToConfirm": "Type <1>{{phrase}}</1> to continue." }
|
||
```
|
||
|
||
Pass `values` explicitly whenever the message interpolates.
|
||
|
||
Because the indices are positional, **the message and the JSX are coupled**:
|
||
adding or removing a child renumbers everything after it, and the message then
|
||
points at the wrong node. That does not fail loudly — the copy renders as
|
||
duplicated fragments with empty tags. Even a prettier reflow can trigger it, by
|
||
changing which whitespace-only text nodes JSX keeps.
|
||
|
||
`scripts/check-trans-indices.mjs` (part of `i18n:check`) fails when a `<n>` does
|
||
not land on an element child, and `scripts/fix-trans-indices.mjs --write` remaps
|
||
drifted indices onto the actual element positions. Never put a `t()` call inside
|
||
`<Trans>` children: it adds an expression child, shifting every index after it.
|
||
|
||
### Outside React
|
||
|
||
`.ts` helpers and non-component functions cannot use the hook — import the
|
||
module-level `t`, which binds to the shared instance:
|
||
|
||
```ts
|
||
import { t } from "@/lib/i18n";
|
||
export const label = () => t("common:saved");
|
||
```
|
||
|
||
Note this resolves at call time; a value captured at module load will not update
|
||
on a locale switch.
|
||
|
||
## Plurals
|
||
|
||
Never write the plural ending yourself. i18next selects a form from `count`:
|
||
|
||
```ts
|
||
t("task:openTasks", { count: n });
|
||
// task:openTasks_one = "{{count}} open task"
|
||
// task:openTasks_other = "{{count}} open tasks"
|
||
```
|
||
|
||
Passing the morpheme as a value — `t("task:openTasks", { n, s: n === 1 ? "" : "s" })`
|
||
— renders correctly in English and is untranslatable everywhere else: the plural
|
||
rule ends up at the call site, so a language with three or six forms has nowhere
|
||
to express them, and a translator editing the catalog cannot fix it.
|
||
`scripts/check-inline-plurals.mjs` (part of `i18n:check`) rejects that shape, and
|
||
`scripts/fix-inline-plurals.mjs --write` converts existing ones. Add `_one`/`_other`
|
||
to _every_ message that interpolates `{{count}}`, even where English does not
|
||
inflect (`{{count}} selected`) — other languages agree the participle with the
|
||
number, and identical forms cost nothing.
|
||
|
||
When the varying word is a whole word rather than an ending (a verb, `"shown"` vs
|
||
`"hidden"`), it is copy: give each case its own key and select between them, rather
|
||
than interpolating the word.
|
||
|
||
## Do not translate
|
||
|
||
User/domain data (task titles, chat, diffs, repo names), code identifiers,
|
||
`data-testid`, route strings, enum/sentinel values, and brand nouns (Kandev,
|
||
GitHub, Jira, …).
|
||
|
||
**Never translate a string that is also compared with `===` or used as a map
|
||
key.** The worst case is a type-to-confirm dialog: the user must type `DELETE ALL
|
||
NOW` verbatim and the handler compares it with `===`, so translating the copy
|
||
tells them to type a word the gate can never accept — the dialog becomes
|
||
impossible to satisfy, and nothing fails until a second language ships. Keep the
|
||
token in a constant and interpolate it, so the shown and compared values cannot
|
||
drift:
|
||
|
||
```tsx
|
||
const CONFIRM_TOKEN = "RESET"; // sentinel, never translated
|
||
t("settings:typeToConfirm", { token: CONFIRM_TOKEN });
|
||
```
|
||
|
||
The guard's `words.exclude` carries a multi-word SCREAMING-CASE pattern for this
|
||
shape. Note that `eslint-plugin-i18next` wraps every entry as `^…$`, so a
|
||
prefix-only pattern silently never matches — write the full match.
|
||
|
||
The same hazard, milder, applies to any display string that doubles as a
|
||
comparison key or `Map` key (command-palette group names, header titles). Key off
|
||
a stable id and translate only at render. `kanban-header.tsx` shipped
|
||
`title === "Home"` for a while — true only in English, and silently the wrong
|
||
branch everywhere else.
|
||
|
||
### …and to any display string that is also PERSISTED
|
||
|
||
A string that is both shown to the user and written to storage cannot simply be
|
||
wrapped in `t()`: translating it in place writes whichever locale happened to be
|
||
active into the user's saved record, where it stays after a switch. Three places
|
||
in the app are like this, and all three use the same split — a canonical English
|
||
value for storage beside a catalog key for display:
|
||
|
||
| Module | Persisted | Displayed |
|
||
| ---------------------------------------------- | ---------------------------------- | ------------------------------------------ |
|
||
| `lib/state/layout-manager/constants.ts` | `PANEL_REGISTRY[id].title` | `titleKey` via `panelTitle()` |
|
||
| `lib/layout/layout-profiles.ts` | `BUILT_IN_LAYOUT_PROFILES[i].name` | `nameKey` via `builtInLayoutProfileName()` |
|
||
| `lib/state/slices/ui/sidebar-view-builtins.ts` | `DEFAULT_VIEW.name` | `sidebarViewName()` |
|
||
|
||
Get the **direction** right, and write a test that pins it. Dockview panel
|
||
titles pass through the serializer twice: `toSerializedDockview` feeds
|
||
`api.fromJSON` and so must localize, while `panelFromDockviewPanel` is what
|
||
`fromDockviewApi` captures into a saved layout and so must canonicalize. The
|
||
first version of that split had them reversed; every unit test passed, because
|
||
the test had been written to the same wrong model. What caught it was the
|
||
pseudo-locale oracle — which could only see it because the layouts screen's
|
||
`allow` list had stopped claiming those titles were expected-English.
|
||
|
||
The other seeded-preset modules (`my-github/search-bar.ts`, `my-jira/presets.ts`,
|
||
`azure-devops-workspace-defaults.ts`) are deliberately English for the same
|
||
reason and have **not** been split, because their values are also sent to an
|
||
agent or matched against a server-side default. Each says so in a header comment.
|
||
|
||
## Commands
|
||
|
||
> Requires **Node 24** (`apps/package.json` `engines`).
|
||
|
||
```bash
|
||
cd apps/web
|
||
pnpm run i18n:check # the five repo-wide gates (runs in CI: Frontend Tests)
|
||
pnpm run i18n:pseudo # regenerate the pseudo catalog from en
|
||
pnpm run lint:i18n <path> # preview the guard on a path that is NOT yet migrated
|
||
pnpm run i18n:ratchet # new-code guard + allowlist-only-grows (pre-commit + CI)
|
||
node scripts/check-nonjsx-copy.mjs # copy lint cannot see (part of i18n:check)
|
||
pnpm run i18n:removed # did this change rewrite any English? (diff-based, advisory)
|
||
pnpm run i18n:parity # per-locale missing/extra keys (report, never a gate)
|
||
pnpm run i18n:sweep <path> # copy the jsx-only rule cannot see (report, never a gate)
|
||
|
||
# Codemods — always inspect the diff; each declines what it cannot prove safe.
|
||
node scripts/externalize-strings.mjs --write components app hooks lib
|
||
node scripts/fix-inline-plurals.mjs --write components app hooks lib
|
||
node scripts/fix-trans-indices.mjs --write components app
|
||
```
|
||
|
||
`lint:i18n` uses `eslint.i18n.config.mjs`, which applies the guard to whatever
|
||
paths you pass. It predates the repo-wide rule and survives as a way to run the
|
||
guard over one directory in isolation; it never gates a merge. The real gate is
|
||
`pnpm lint`, where the rule is an **error on every source file**.
|
||
|
||
`i18n:check` is not scoped either — its checks scan `components`, `app`, `hooks`,
|
||
`lib`, `src`, and published Markdown in `docs/public` repo-wide, and it runs in
|
||
CI (Frontend Tests → _Run i18n checks_). `check-nonjsx-copy.mjs` is no longer the
|
||
exception it used to be: it defaulted to `i18nGuardFiles`, which left 1141 of
|
||
2564 source files judged by neither gate, and the non-JSX positions it exists to
|
||
find are exactly what lives in the plain `.ts` modules that rarely made the list.
|
||
It now scans the whole tree by exclusion. That is deliberate: each check pins a bug
|
||
that fails **silently at runtime** rather than merely leaving a string in English,
|
||
so there is no reason to let it into unmigrated code either. Unmigrated source
|
||
files pass trivially, because a file that never calls `t()` cannot break the
|
||
source checks.
|
||
|
||
The em-dash check also scans published Markdown under `docs/public`. The
|
||
published-docs workflow runs the same check through
|
||
`scripts/validate-public-docs.mjs`, so documentation-only changes are covered.
|
||
The same repository-wide scanner runs as the `public-copy-em-dash` pre-commit
|
||
hook on every commit, so violations are caught before CI. Run
|
||
`pre-commit run public-copy-em-dash --all-files` to invoke that hook directly.
|
||
|
||
`i18n:check` is a **drift** check, not a re-extraction: with key-based i18next
|
||
the English copy exists only in the catalog and cannot be recovered from source,
|
||
so regenerating would destroy it. It fails on keys missing from the catalog and
|
||
on `en`/`pseudo` divergence, warns about orphaned entries, validates every
|
||
`<Trans>` element's tag indices against its JSX children (see below), rejects
|
||
English plural endings passed as interpolation values, rejects `t()` at module
|
||
scope, and rejects hardcoded copy in the non-JSX positions the eslint rule cannot
|
||
inspect.
|
||
|
||
Module scope matters because the module-level `t` resolves when the file is
|
||
imported: `const ROWS = [{ label: t("k") }]` pins that copy to the boot locale, so
|
||
it renders correctly and simply never changes on a switch. The pseudo-locale
|
||
cannot see it — the text _is_ translated. Store the key and resolve it at render,
|
||
or make the value a component.
|
||
|
||
### Pseudo is not bundled in production
|
||
|
||
`pseudo` is the **largest** catalog we have — accented characters are multi-byte —
|
||
and no production user can ever select it, because `selectableLocales` filters it
|
||
out of the switcher. So a plain `vite build` does not compile it in at all:
|
||
`lib/i18n/index.ts` globs it separately behind `__KANDEV_PSEUDO_LOCALE_BUNDLED__`,
|
||
which `vite.config.ts` defines from `shouldBundlePseudoLocale` in
|
||
`lib/i18n/bundling.ts`. The branch folds away and rolldown never pulls those JSON
|
||
modules into the graph. Filtering the merged catalog at runtime instead would
|
||
still ship every byte, which is the entire problem.
|
||
|
||
**Lazy loading is not a substitute for this, and the two compose rather than
|
||
overlap.** Catalogs are fetched on demand (see
|
||
[Catalog loading](#catalog-loading-and-why-en-is-the-exception)), so pseudo would
|
||
be a chunk nobody requests in production — but an unrequested chunk is still
|
||
built, still deployed, and still reachable by anyone who can set the
|
||
`kandev_locale` cookie. The build-time constant is folded into the _lazy_ glob
|
||
for exactly that reason: on a plain `vite build` no pseudo chunk is emitted at
|
||
all. Nothing asserts on that absence, so check it by hand when either mechanism
|
||
changes — `grep -rl "Ŕēţŕŷ" dist/assets/*.js` must return nothing.
|
||
|
||
| invocation | pseudo? |
|
||
| ----------------------------------------------------------------------- | ------- |
|
||
| `pnpm dev`, `vitest` (Vite `serve`) | yes |
|
||
| `pnpm build` / `make build-web` / release / desktop | **no** |
|
||
| `pnpm build:e2e` / `make build-web-e2e` (`KANDEV_I18N_BUNDLE_PSEUDO=1`) | yes |
|
||
|
||
`import.meta.env.PROD` cannot make this decision: the e2e harness serves a
|
||
**production** bundle, so it is true there too — and e2e is exactly where pseudo
|
||
has to survive, because `e2e/tests/i18n/pseudo-coverage.spec.ts` is the only
|
||
oracle that sees copy the jsx-only guard structurally cannot. That is why the e2e
|
||
runner (`e2e/scripts/run-e2e.sh`) and the CI e2e build job both use the `:e2e`
|
||
target. **If you add another build path that e2e runs against, use that target** —
|
||
the coverage spec fails loudly rather than silently when pseudo is absent (every
|
||
screen falls back to `en` and reads as un-externalized), but it fails as a wall of
|
||
findings that look like a migration regression rather than a build misconfiguration.
|
||
|
||
`SUPPORTED_LOCALES` still lists `pseudo` unconditionally, and must: that type
|
||
drives the `en` ↔ `pseudo` parity gate in `i18n:check` and `i18n:pseudo`. The
|
||
exclusion is a bundling decision only.
|
||
|
||
To eyeball pseudo in a production-mode local instance (rather than `pnpm dev`),
|
||
build with `make build-web-e2e` instead of `make build-web`.
|
||
|
||
## The guard is scoped, on purpose
|
||
|
||
`i18next/no-literal-string` is an **error**, but only for the paths listed in
|
||
`i18nGuardFiles` (`apps/web/eslint.i18n.options.mjs`). Everything else is
|
||
unaffected.
|
||
|
||
This is the single most important design decision here, and it was learned the
|
||
expensive way. The first attempt shipped the rule globally over `components/**`
|
||
and `app/**`. That makes every PR authored anywhere in the app — PRs with nothing
|
||
to do with i18n — fail lint the moment they add a label, and it makes the
|
||
migration branch conflict with `main` continuously. That branch went through ten
|
||
conflict-resolution rounds in two days and could not be kept alive long enough to
|
||
merge.
|
||
|
||
Scoping inverts it. The guard can only ever tighten:
|
||
|
||
- Paths already migrated are protected, so a regression there is a hard error.
|
||
- Paths not yet migrated are invisible to it, so nobody else is blocked and there
|
||
is no treadmill.
|
||
- Every migration PR moves one path from the second group to the first.
|
||
|
||
Entries are globs, so use a file glob while a directory is partly migrated. When
|
||
it is done, leave those entries where they are: **adding a path is the only safe
|
||
edit to this list.**
|
||
|
||
`check-guard-allowlist.mjs` fails if an entry disappears while its path still
|
||
exists — globs included, resolved with `fs.globSync`. So **never remove an entry**
|
||
to make a build pass, and equally, never tidy several file globs into one
|
||
`dir/**`. Both produce the same diff to the array, the check cannot tell them
|
||
apart, and refusing to guess is the point: one of them silently un-protects
|
||
migrated copy. A broader glob added _alongside_ the existing entries passes;
|
||
swapping them for it does not.
|
||
|
||
The same check rejects a path listed **twice**. An exact duplicate changes no
|
||
behaviour, so nothing else looks for one — the eslint rule is indifferent to a
|
||
repeated pattern, and the removal check above only inspects entries that _left_
|
||
the array. That is how #2214 landed two past every gate the repo then had. What a
|
||
duplicate costs is the record: it claims an earlier migration's coverage as the
|
||
new PR's. Entries that a broader glob already covers are deliberate and stay —
|
||
they record which PR migrated which half of a group — so this is an exact-match
|
||
check, not a redundancy check.
|
||
|
||
#### An entry can be born dead, and only a glob expansion shows it
|
||
|
||
Both checks above ask what happened to an entry _between_ two states. Neither
|
||
asks the prior question: **does this entry match any file at all?** An entry that
|
||
matched nothing on the day it was added is indistinguishable from a healthy one —
|
||
it never leaves the array, it is never a duplicate, and `pnpm lint` passes
|
||
because the rule simply has no files to apply to. The list grows, the ratchet
|
||
reports "N added", and the path is unguarded.
|
||
|
||
A dynamic route is the way to hit this, because Next-style directories are named
|
||
in glob syntax. `[id]` is a **character class** matching a single `i` or `d`:
|
||
|
||
```bash
|
||
node -e 'console.log(require("fs").globSync("app/settings/workspace/[id]/automations/**/*.tsx").length)' # 0
|
||
node -e 'console.log(require("fs").globSync("app/settings/workspace/[[]id[]]/automations/**/*.tsx").length)' # 4
|
||
```
|
||
|
||
The Automations migration (#2247) shipped the unescaped form, and review caught
|
||
it. So when an entry contains `[`, escape it as `[[]…[]]`.
|
||
|
||
`check-guard-allowlist.mjs` now rejects an entry that matches no file, so this
|
||
particular class cannot recur — the three checks it runs are deliberately
|
||
separate because each is blind to the others' failure:
|
||
|
||
| check | sees |
|
||
| --------- | --------------------------------------------------------------------------- |
|
||
| removal | an entry that **left** the array while its path still exists |
|
||
| duplicate | an entry listed **twice** |
|
||
| unmatched | an entry that **never matched anything**, including on the day it was added |
|
||
|
||
The first two compare two _states_ of the array, which is why neither can see a
|
||
born-dead entry. If you add a check here, add the test that shows the existing
|
||
ones miss what it catches — `scripts/lib/guard-allowlist.test.ts` pins that for
|
||
all three.
|
||
|
||
### …but new code is guarded everywhere
|
||
|
||
Scoping the whole-file guard leaves a hole: a brand-new component under a path
|
||
that is not on the list would be unchecked. `check-new-code-i18n.mjs` closes it by
|
||
judging the **change** rather than the file:
|
||
|
||
| | Judged on |
|
||
| ------------------------------ | ------------------------------------------ |
|
||
| A file the change **added** | the whole file — it carries no legacy debt |
|
||
| A file the change **modified** | only the lines it touched |
|
||
|
||
Existing literals elsewhere in a modified file are somebody else's migration, so
|
||
this can be repo-wide without recreating the treadmill. It is the same contract
|
||
`golangci-lint --new-from-rev` already enforces for Go in
|
||
`.pre-commit-config.yaml`, and it runs in both pre-commit and CI.
|
||
|
||
So there are two independent ratchets, and both only tighten:
|
||
|
||
- **`i18nGuardFiles`** — whole files, listed paths. Grew one directory per PR
|
||
through the migration; now grows when a PR adds a path that renders copy.
|
||
- **the new-code ratchet** — new lines, everywhere. Always on.
|
||
|
||
## Externalizing a path
|
||
|
||
The directory-by-directory sweep of the screens is **finished** — #2367 localized
|
||
`app/office`, the last un-migrated area. This loop is what you still run for a
|
||
path you are adding, for a helper directory the sweep did not cover (`lib/sidebar`
|
||
is one), or to remediate a blind-spot find like the SCREAMING_CASE tables in PR
|
||
`#2409`:
|
||
|
||
```bash
|
||
cd apps/web
|
||
pnpm run lint:i18n components/<area> # 1. the work-list
|
||
node scripts/externalize-strings.mjs --write components/<area> # 2. mechanical pass
|
||
# 3. review the diff, fix what it declined
|
||
pnpm run i18n:pseudo # 4. regenerate pseudo
|
||
pnpm run lint:i18n components/<area> # 5. clean?
|
||
```
|
||
|
||
6. Add the path to `i18nGuardFiles` in `eslint.i18n.options.mjs`. **This is the
|
||
step that makes the work stick** — without it the directory drifts straight
|
||
back.
|
||
7. If the area has a screen worth crawling, add it to `SCREENS` in
|
||
`e2e/tests/i18n/pseudo-coverage.spec.ts`. That spec is a **hard CI gate** —
|
||
it runs unconditionally in the E2E `chromium` project — so adding a screen
|
||
commits the whole DOM that screen renders, not just your directory. It is a
|
||
regression floor rather than a completeness proof: it still cannot see a
|
||
hardcoded string under four letters, or one sharing a text node with migrated
|
||
copy, which is why step 9 below is still worth doing by hand.
|
||
8. `pnpm run i18n:check && pnpm lint --max-warnings 0 && pnpm exec vitest run`.
|
||
9. Switch to **Pseudo (QA)** in Settings → General → Appearance and look at the
|
||
screen. Anything still in plain English was missed — see below for why lint
|
||
cannot tell you this. Use `pnpm dev`; a production build does not bundle the
|
||
pseudo catalog and will not offer the option (see _Pseudo is not bundled in
|
||
production_).
|
||
|
||
### The guard has blind spots
|
||
|
||
`i18next/no-literal-string` only inspects **literals in JSX**. Template literals
|
||
_are_ now checked (`should-validate-template: true` — measured at +2 to +5
|
||
findings per un-migrated directory, all of them real copy), but it still cannot
|
||
see a `confirm()` argument or copy returned from a plain `.ts` helper.
|
||
|
||
It has one more blind spot worth knowing, because it is the shape config tables
|
||
take: **a literal assigned to a SCREAMING_CASE identifier is skipped entirely.**
|
||
|
||
```tsx
|
||
const HEADER = <span>Archive board</span>; // NOT flagged
|
||
const header = <span>Archive board</span>; // flagged
|
||
const ROWS = [{ label: "Disk usage" }]; // NOT flagged
|
||
```
|
||
|
||
That is why module-scope config objects survived the sweep, and why the
|
||
new-code ratchet inherits the same hole. `check-module-scope-t.mjs` catches the
|
||
adjacent case (a module-scope `t()` call, frozen at the boot locale) but not
|
||
module-scope literal copy. Reviewing a new `const TABLE = [...]` of labels by eye
|
||
is still required — and note the shape one step out from a config table is just
|
||
as invisible: **a plain function returning object literals**, e.g. a
|
||
`buildRows()` that returns `[{ label: "Disk usage" }]`, is never inspected either.
|
||
|
||
**Walk the hooks your routes call, not just the components.** A closure derived
|
||
from component imports misses them, and they hold no JSX so the string count
|
||
reports them as zero. Settings → Agents shipped that way and needed a follow-up
|
||
for a thrown `Error` and a `setError` fallback in two `hooks/domains/settings/`
|
||
modules; Settings → System → Storage caught ten toast titles in
|
||
`use-storage-maintenance.ts` only because it re-derived its closure instead of
|
||
trusting the count. Install/update flows and any maintenance action are the
|
||
toast-heavy cases. A count from `pnpm run lint:i18n` is a floor on the visible
|
||
half, never a work-list.
|
||
|
||
**Default values in a destructuring pattern are a third invisible shape, and the
|
||
fix is not the obvious one.** `ActionButtonContent` carried three:
|
||
|
||
```tsx
|
||
export function ActionButtonContent({
|
||
pendingLabel = "Running...", // NOT flagged — not a literal in JSX
|
||
successLabel = "Done",
|
||
errorLabel = "Failed",
|
||
}: Props) {
|
||
```
|
||
|
||
These are evaluated in the parameter list, which runs _before_ the component
|
||
body, so a `const { t } = useTranslation()` in the body is not in scope for them —
|
||
you cannot fix this in place. Resolve the fallback inside the body instead
|
||
(`const pending = pendingLabel ?? t("system:jobRunning")`). Prop defaults on a
|
||
shared presentational component are a common home for this.
|
||
|
||
### …and the blind spots are now a gate
|
||
|
||
Everything above is still true about `i18next/no-literal-string`. What changed is
|
||
that a second detector runs over the same files:
|
||
`scripts/check-nonjsx-copy.mjs` (logic in `scripts/lib/nonjsx-copy.mjs`), part of
|
||
`pnpm run i18n:check`. It inspects exactly the positions the rule cannot:
|
||
|
||
| shape | example |
|
||
| --------------- | ----------------------------------------------------------- |
|
||
| `config-table` | `const ROWS = [{ label: "Disk usage" }]` |
|
||
| `helper-return` | `function badge() { return "Waiting for input" }` |
|
||
| `param-default` | `function C({ label = "Running..." })` |
|
||
| `display-call` | `toast({ title: "Rename failed" })`, `setPreviewError("…")` |
|
||
| `copy-property` | `{ description: "…" }` outside a table |
|
||
|
||
It scans the **whole tree**, by exclusion (`node_modules`, `dist`, `e2e`,
|
||
`scripts`, `src/locales`, `*.test.*`, `*.test-helpers.*`, `*.test-utils.*`,
|
||
`*.d.ts`) rather than by allowlist, so a new directory is covered the day it is
|
||
created. It used to default to `i18nGuardFiles`, for the same treadmill reason
|
||
the eslint rule was scoped — but the arithmetic did not work out: 1141 of 2564
|
||
source files were judged by neither gate, and non-JSX positions are precisely
|
||
what lives in the plain `.ts` modules that rarely made the list. A run over that
|
||
missing scope found 325 findings.
|
||
|
||
Unlike a lint rule, this check has no per-PR-file cost — it is one script over
|
||
the tree — so there was never much reason to scope it.
|
||
|
||
The exemption marker must be a `//` LINE comment. `exemptionsFor` matches
|
||
`/i18n-exempt:?(.*)$/` without the `m` flag, so `$` binds to the end of the
|
||
comment's whole text: a marker on the first line of a `/** */` block never
|
||
matches and is silently ignored.
|
||
|
||
`check-new-code-i18n.mjs` runs the same detector over added files and changed
|
||
lines everywhere, which is what guards a directory before anyone thinks about it.
|
||
|
||
Template literals are read too, judged on their **longest static chunk** rather
|
||
than on the chunks joined: `` `Every day at ${at} ${zone}` `` is copy, while
|
||
`` `${w}px ${h}em` `` is not.
|
||
|
||
That rule has a known blind spot, and it is worth naming because it is not
|
||
obvious from the outside: a template whose static chunks are each individually
|
||
too small still reads as non-copy even when the sentence plainly is copy.
|
||
`` `${n} tasks · ${m} sessions · ${d}` `` is the worked example — its longest
|
||
chunk is `" tasks · "`, a single lowercase word, which `looksLikeCopy` rejects.
|
||
Judging the chunks joined would fix this one and break `` `${w}px ${h}em` ``,
|
||
so the floor stays where it is. What catches this class is a human reading the
|
||
diff, and the pseudo-locale.
|
||
|
||
#### Silencing one, and why the reason is mandatory
|
||
|
||
```ts
|
||
// i18n-exempt: persisted preset seed; localizing needs a key/value split.
|
||
const DEFAULT_PR_PRESETS = [
|
||
{ label: "Review", hint: "Read the diff, flag issues" },
|
||
];
|
||
```
|
||
|
||
The marker attaches to the enclosing **statement** or **function** — one comment
|
||
covers a whole config table, or a whole prompt builder with four `return`s. A
|
||
marker with no reason after the colon is _reported_, not honoured.
|
||
|
||
That is the load-bearing part. Every shape this check finds has a legitimate
|
||
form, and they are not distinguishable from the string: a value persisted in
|
||
whichever locale created it, a prompt sent verbatim to a model, a token compared
|
||
with `===`, a brand noun. The ~60 exemptions in the tree today are each one of
|
||
those, and every one says which.
|
||
|
||
#### What it still cannot see
|
||
|
||
It reads literals, so copy assembled at runtime from fragments is invisible, and
|
||
it takes the author's word for an exemption. The pseudo-locale remains the
|
||
completeness check. Two limits are worth knowing concretely:
|
||
|
||
- A **single lowercase word** (`"ended"`, `"watching"`) is below the copy
|
||
threshold — too little signal to separate from an enum value. Its neighbours in
|
||
the same helper usually are above it, which is how those two were found.
|
||
- A string built by concatenation (`prefix + name`) is not a literal.
|
||
|
||
`externalize-strings.mjs`
|
||
covers those shapes — it walks `.ts` as well as `.tsx` and handles template
|
||
literals and dialog/toast arguments — but **a zero from `pnpm lint` does not by
|
||
itself mean a file is fully externalized**. The pseudo-locale is the real
|
||
completeness check: anything still rendering plain English under it was missed.
|
||
`EXCLUDED` in that script lists paths whose strings are deliberately left alone
|
||
(`lib/api`, `lib/types`, layout serialization, fixtures) and why.
|
||
|
||
### Sweeping for what the guards cannot see
|
||
|
||
`pnpm run i18n:sweep <dir>` (`scripts/i18n-sweep.mjs`) is a **human** tool for the
|
||
blind spots above — deliberately not wired into CI or pre-commit, and it always
|
||
exits 0. It prints two separate sections, because mixing "this is broken" with
|
||
"look at this" gets a report read as neither:
|
||
|
||
1. **English plural concatenation** — `{n} file{n === 1 ? "" : "s"}`, and the
|
||
hoisted `const plural = count === 1 ? "" : "s"` form. These are defects, and
|
||
**no gate we own sees them**: `no-literal-string` is jsx-only, and
|
||
`check-inline-plurals.mjs` only inspects morphemes passed _through_ `t()`. A
|
||
repo-wide sweep found 44 of them across 36 files. One helper had 2 of its 11
|
||
call sites visible to lint — migrating the visible two would have left nine
|
||
broken while the directory reported clean. **30 of those 44 are under
|
||
`components/task`**, the largest directory still to migrate, so run the sweep
|
||
on every `components/task` batch specifically rather than treating it as a
|
||
generic step.
|
||
2. **Prose literals in non-JSX positions** — for eye review, never automatically
|
||
a defect. Two caveats. **Prompt builders are deliberately English**: their
|
||
content is sent verbatim to a model, so translating it would be a bug
|
||
(`components/github/pr-checks-section.tsx` and `pr-ci-popover.tsx` are the
|
||
known-good examples, and the tool prints this caveat itself). And the section
|
||
is a _narrow sample_, not an inventory — its noise filter drops any literal
|
||
starting with a letter, so it surfaces bracketed log prefixes and media
|
||
queries but not a `{ label: "Disk usage" }` config table. A short section is
|
||
not evidence a directory is clean.
|
||
|
||
Because lint is jsx-only, finding counts from `pnpm run lint:i18n` **understate
|
||
the real work by roughly 15%** — measured at ~14 extra strings on a 99-string
|
||
batch. Budget for that when sizing a directory, and keep the pseudo-locale as
|
||
the completeness check.
|
||
|
||
## How it's wired
|
||
|
||
- **Runtime**: `apps/web/lib/i18n/` — the i18next instance, `activateLocale()`,
|
||
`<I18nProvider>` (mounted outermost in `app-shell.tsx`), `resolveInitialLocale`
|
||
(payload → cookie → `en`), and `formats.ts` (locale-aware date/number/relative
|
||
time; `timeAgo` is a deprecated alias for `formatRelative`).
|
||
- **Catalogs**: `src/locales/<locale>/<namespace>.json`, discovered through
|
||
Vite's glob import — no macro transform. **`en` is bundled eagerly; every
|
||
other locale is a lazily-fetched chunk** (see below). The one build plugin is
|
||
`kandev:i18n-pseudo-locale-bundling` in `vite.config.ts`, which decides
|
||
whether the `pseudo` catalog is compiled in at all.
|
||
- **Locale source of truth**: the `kandev_locale` cookie. The Go shell
|
||
(`apps/backend/internal/webapp/shell.go`) reads it to set `<html lang>` and
|
||
seed the boot payload, so first paint matches with no flash.
|
||
|
||
### Catalog loading, and why `en` is the exception
|
||
|
||
Bundling every locale put all of them in the initial download — 1.4 MB raw /
|
||
389 KB gzipped, growing linearly with each locale added, so that a user could
|
||
read one language they had downloaded four of. Only `en` is eager now; the rest
|
||
are fetched per locale.
|
||
|
||
Two things make that safe rather than a source of flashing keys:
|
||
|
||
- **`en` must be synchronous.** It is `DEFAULT_LOCALE` and `fallbackLng`, and
|
||
`returnNull: false` means a missing key renders as _the key itself_. If `en`
|
||
were not in memory when `i18next.init()` returns, every key on screen would
|
||
paint as `common:save` until a chunk landed — silent and ugly rather than
|
||
loud. Keeping it eager also keeps `ensureInitialized` synchronous, which the
|
||
boot path depends on: react-i18next suspends on an uninitialized instance and
|
||
there is no Suspense boundary above the React root.
|
||
- **The active locale is known before React mounts.** The Go shell resolves it
|
||
into the boot payload, so `main.tsx` awaits exactly ONE locale
|
||
(`preloadLocale`) inside the promise it already waited on for the payload,
|
||
_before_ `createRoot().render()`. Nothing renders against a half-loaded
|
||
catalog. For `en` that await resolves immediately and the boot is unchanged.
|
||
|
||
A runtime switch goes through `activateLocale`, which loads **before**
|
||
`changeLanguage` rather than on the `languageChanged` event — `changeLanguage`
|
||
re-renders the whole tree, so registering afterwards would show one frame of
|
||
English. A failed chunk never throws: the app degrades to English under the
|
||
requested `<html lang>`, and `installVitePreloadRecovery` reloads once on
|
||
`vite:preloadError`.
|
||
|
||
`vite.config.ts` groups each locale's namespaces into a single chunk. i18next is
|
||
seeded with every namespace and any surface may use any of them, so a locale is
|
||
always loaded whole; a chunk per namespace would cost a non-English boot 29 round
|
||
trips for the same bytes.
|
||
|
||
Unit tests are the one place that still wants everything in memory — suites call
|
||
`i18n.changeLanguage("pseudo")` and assert on the next line — so
|
||
`vitest.setup.ts` awaits `loadAllLocalesForTests()` once.
|
||
|
||
`pseudo-coverage.spec.ts` already tolerates this, and it is worth knowing why it
|
||
does not need a change here. Its `waitForScreen` anchors assert per screen that
|
||
the route rendered **and** that the anchor's copy went accented — the second half
|
||
was written as insurance against exactly this, a catalog arriving asynchronously,
|
||
back when it was still hypothetical. `<html lang>` alone would not have done:
|
||
`shell.go` writes it server-side, so it is correct before any script runs.
|
||
|
||
`e2e/tests/i18n/lazy-catalogs.spec.ts` is the gate. It asserts against a real
|
||
production bundle that an English boot fetches no other catalog, that activating
|
||
one fetches exactly its chunk, and that a cold `zh-cn` boot renders translated
|
||
rather than key names.
|
||
|
||
- **Shared UI**: `@kandev/ui` primitives carry their own hardcoded accessibility
|
||
labels ("Close", "Toggle Sidebar", "Loading", "Go to next page"). The package
|
||
must not import the app's i18n runtime — it has to stay usable standalone — so
|
||
these are localized at the **call site**, not in the package. Check the shape
|
||
before inventing a seam, because the two halves differ:
|
||
- **A label in an ATTRIBUTE needs nothing.** Every one of them sits on an
|
||
element that spreads `{...props}` _after_ it, so the overridable string prop
|
||
the spec asks for already exists and the English literal is only its default.
|
||
`Breadcrumb`'s `aria-label="breadcrumb"` was this shape: the fix was
|
||
`aria-label={t("common:breadcrumb")}` at its three consumers (`page-topbar`,
|
||
`task-top-bar-title`, `OfficeSimplePane`) plus a comment in the package
|
||
recording the contract. A `label?: string` prop would have duplicated a prop
|
||
React already gives you. Same shape, still un-overridden: `SidebarRail`'s
|
||
`aria-label`/`title`, `Spinner`'s `aria-label="Loading"`, and `Pagination`'s
|
||
three.
|
||
- **A label that is a TEXT CHILD does need a seam**, because a consumer cannot
|
||
reach it through `{...props}`: the `<span className="sr-only">` in
|
||
`DialogContent`, `SidebarTrigger` and `BreadcrumbEllipsis` ("Close", "Toggle
|
||
Sidebar", "More"), and `PaginationPrevious`/`PaginationNext`'s visible
|
||
"Previous"/"Next". Each wants an optional label prop defaulting to today's
|
||
text. Open — the dialog "Close" is the one a pseudo walk keeps surfacing,
|
||
because every integration settings page renders it.
|
||
|
||
A third-party primitive is the same call: sonner names its toast region
|
||
`Notifications alt+T` and accepts `containerAriaLabel`, which `AppToaster` in
|
||
`src/app-shell.tsx` passes. That has to be its own component — `<I18nProvider>`
|
||
is mounted _by_ `AppShell`, so a `t()` call in `AppShell`'s own render sits
|
||
outside the provider and would not re-render on a locale switch.
|
||
|
||
- **Tests**: `vitest.setup.ts` calls `initI18nForTests()`. react-i18next falls
|
||
back to the default instance when no provider is present, so no render wrapper
|
||
is needed and every test's render tree is unchanged.
|
||
|
||
## Backend
|
||
|
||
`apps/backend/internal/i18n` localizes the strings the **backend renders directly
|
||
to a browser**. That is two surfaces, and between them they are now complete: the
|
||
plain-text error pages served when the SPA bundle cannot be delivered
|
||
(`webapp.*`), and the shared-task artifacts — `share.html`, the gist README, and
|
||
the gist description — that Go writes as HTML/Markdown (`share.*`). Catalogs are
|
||
embedded JSON (`locales/<locale>.json`, flat `key: message`) mirroring the
|
||
frontend's shape; `pseudo` is generated from `en` by the frontend's
|
||
`pnpm run i18n:pseudo`, which writes both catalogs so the QA locale is a complete
|
||
oracle.
|
||
|
||
```go
|
||
locale := i18n.FromRequest(r) // cookie -> Accept-Language -> "en"
|
||
http.Error(w, i18n.T(locale, "webapp.shellUnavailable"), http.StatusServiceUnavailable)
|
||
```
|
||
|
||
### Interpolation and plurals
|
||
|
||
`Tf` is the only supported way to build a message that carries a value. Never
|
||
`fmt.Sprintf` a translated string — that freezes the argument order in English,
|
||
and a translator cannot move a placeholder that is not in the catalog.
|
||
|
||
```go
|
||
i18n.Tf(locale, "share.pageTitle", map[string]any{"title": title}) // "{{title}} — kandev share"
|
||
i18n.Tf(locale, "share.messageCount", map[string]any{"count": n}) // share.messageCount_one / _other
|
||
```
|
||
|
||
Plurals use the same `_one` / `_other` suffixes as the frontend, selected from a
|
||
`count` var; a key with no plural forms falls back to the base message. Values
|
||
are substituted verbatim, so escaping is the call site's job. Interpolate
|
||
anything the pseudo-locale must not transliterate — filenames, URLs, brand names —
|
||
rather than writing it into the catalog, or the QA locale turns it into a dead
|
||
pointer.
|
||
|
||
### Locale for artifacts that outlive the request
|
||
|
||
A shared task is a static file on GitHub: when a reader opens the link they make
|
||
no request to us, so there is no cookie or `Accept-Language` to read. The locale
|
||
is resolved once from the **creating** request and threaded as an ordinary
|
||
argument down `httpCreate → CreateShare → Backend.Upload → the builders`. Not a
|
||
context value, not package state — the builders stay pure functions of
|
||
(snapshot, locale), which is what makes the per-locale render tests possible. See
|
||
[ADR 2026-08-01](decisions/2026-08-01-share-artifact-locale.md).
|
||
|
||
`i18n.Normalize` is the single source of truth for locale support, shared with
|
||
`webapp.NormalizeLocale`, so `<html lang>` and message lookup can never disagree.
|
||
A missing key echoes the key (never blank), and an unsupported locale falls back
|
||
to `en` rather than erroring.
|
||
|
||
### What the backend deliberately does NOT translate
|
||
|
||
Almost everything else. The ~1,100 `http.Error` / `gin.H{"error": …}` strings are
|
||
**diagnostics**, not display copy: the SPA maps API failures onto its own
|
||
translated toasts rather than rendering the server's text. Translating them would
|
||
make logs and support harder while helping no user. Also untranslated: log lines,
|
||
agent/ACP output, and CLI output.
|
||
|
||
Inside the localized surfaces, three categories look like copy but are not, and
|
||
the share builders mix all of them into the same lines:
|
||
|
||
- **Wire values.** `roleUser` / `roleAssistant` / `roleSystem` come from the
|
||
message store. `messageRoleAttrs` returns a CSS class, a label, and an emoji
|
||
together — only the label is copy, and its fallback branch echoes the raw role.
|
||
- **Identifiers.** Brand names, URLs, filenames (`share.html`, `snapshot.json`),
|
||
and CSS class names. In "Built with [kandev](…)", only "Built with" is copy.
|
||
- **Emoji**, which stay in the Go format string rather than the catalog.
|
||
|
||
**Contract for new user-facing backend output:** prefer returning a stable error
|
||
_code_ the frontend translates. Only use `i18n.T` when Go itself renders the bytes
|
||
a user reads.
|
||
|
||
## Pseudo-locale QA (the completeness oracle)
|
||
|
||
Switch to **Pseudo (QA)** in Settings → General → Appearance (dev/e2e builds
|
||
only). Every catalog message renders accented, e.g. `Cancel` → `Ćàńćēĺ`. Any
|
||
remaining **plain-English** text on screen is a string that was never routed
|
||
through `t()` — that is the acceptance check for a migrated screen, and the only
|
||
one that sees the guard's blind spots. Interpolation placeholders and `<n>` tag
|
||
markers are preserved by the generator.
|
||
|
||
The by-hand walk is for a screen you are migrating. For the screens already
|
||
migrated, `e2e/tests/i18n/pseudo-coverage.spec.ts` automates most of this check
|
||
and **runs as a hard gate in CI** — it is no longer behind an env var. A red run
|
||
there names the strings and the screen; externalize them rather than allowlisting
|
||
them.
|
||
|
||
It does not replace the walk. The spec reports a plain-ASCII run of four letters
|
||
or more in a text node with no accented character in it — so a hardcoded string
|
||
shorter than that, or one sitting in the same text node as migrated copy, still
|
||
reaches you only by eye. Both limits are load-bearing rather than oversights;
|
||
the spec's header records what happens when you relax them.
|
||
|
||
One thing it shows you is **not** a miss: an interpolated API or network payload
|
||
stays English by design, so it renders unaccented and is still correct. See
|
||
[the interpolated-value limit](#and-it-is-weakest-at-an-interpolated-value).
|
||
|
||
Two things it cannot show you at all — the second is detailed below:
|
||
|
||
1. **Module-scope `t()`.** That copy _is_ translated, just frozen at the boot
|
||
locale, so it renders accented and looks correct while never responding to a
|
||
switch. `check-module-scope-t.mjs` is what catches it.
|
||
2. **An un-migrated value interpolated into a migrated frame** — it renders
|
||
inside accented copy and reads as done.
|
||
|
||
A third used to belong on that list — copy that never becomes a text node — and
|
||
the spec now reads it directly. That pass is described next, because what it does
|
||
_not_ check still matters when you migrate.
|
||
|
||
### It reads attribute copy, which nothing on screen shows you
|
||
|
||
**Copy carried in an attribute — `aria-label`, `aria-description`, `title`,
|
||
`placeholder`, `alt` — renders nothing visible, so a human eyeballing the
|
||
pseudo-locale can never catch it.** The Settings → System → Storage review
|
||
demonstrated the gap by reverting an `aria-label` to an English template literal:
|
||
nothing failed.
|
||
|
||
`findUnlocalizedCopy` in `e2e/tests/i18n/pseudo-coverage.spec.ts` therefore runs a
|
||
second pass over `document.querySelectorAll("[aria-label],…")` alongside the
|
||
`NodeFilter.SHOW_TEXT` walk, applying the same accented/word-like tests and the
|
||
same allowlist stripping. Findings name the attribute and the element that carries
|
||
it, since a bare string gives you nowhere to look:
|
||
|
||
```text
|
||
aria-label on button#language-select: "Display language (pseudo)"
|
||
```
|
||
|
||
**Read the control line before you trust a green screen.** A migrated attribute
|
||
renders as `Mōŕē ĩńfōŕmàţĩōń`, which has no 4-letter ASCII run and so is dropped
|
||
at the `wordlike` gate before the accented test ever sees it. Soundness is fine —
|
||
a real miss is pure ASCII — but it means a working pass and a pass whose selector
|
||
matched _nothing_ produce the same empty output. One agent scanning by hand
|
||
concluded their scan was broken on exactly this. The spec therefore asserts
|
||
`inspectedAttributes > 0` per screen, prints how many rendered fully accented, and
|
||
pins accent detection once against `#language-select`'s known-migrated
|
||
`aria-label`. If you scan by hand, emit a known-migrated attribute as your own
|
||
positive control.
|
||
|
||
**But note exactly what that control establishes, because it is less than it
|
||
looks.** `inspectedAttributes > 0` proves the pass _inspected something_. It does
|
||
not prove it inspected _the screen under test_ — the sidebar, topbar and status
|
||
bar contribute over a hundred attributes on every route, and they are accented
|
||
long before a `SCREENS` route mounts out of its lazy chunk (`settings-routes` is
|
||
2.8 MB; Office has its own). For a while the only thing separating "route
|
||
rendered" from "scan the DOM" was a `waitForTimeout(1_000)`, and with both chunks
|
||
blocked so that _no screen rendered at all_, 13-16 of the 21 tests still reported
|
||
clean — varying run to run, because it was a race the settle could not see. That is the worst direction for a
|
||
gate to fail in: under CI load a slower render makes a green result _more_ likely,
|
||
so the check is most permissive exactly when the tree is most likely to be broken.
|
||
|
||
Each `SCREENS` entry now carries a required `anchor` — a selector that cannot
|
||
match until that route has rendered — and the walk does not start until it
|
||
matches _and_ the matched element's text is accented. Settings screens anchor on
|
||
a `data-testid` their own component renders; Office screens anchor on
|
||
`main[data-office-route="<path>"]`, the route outlet stamped with the resolved
|
||
path, because the SPA mounts Office pages with empty collections and any anchor
|
||
inside the populated branch would never match. Adding a screen to `SCREENS`
|
||
means adding its anchor, and the anchor is the part to think about.
|
||
|
||
**Generalize the lesson rather than the fix: a positive control asserting that a
|
||
check inspected something does not establish that it inspected the thing under
|
||
test.** When you add a control, ask what the _weakest_ state of the system is
|
||
that still satisfies it — here, "the app shell painted" satisfied it, and that
|
||
state is reached on every route whether or not the route ever arrives.
|
||
|
||
Four things about that pass are deliberate, and worth knowing before you read its
|
||
output:
|
||
|
||
- **It does not reuse the text pass's zero-size-rect check.** A visually hidden
|
||
control with an `aria-label` is the exact case the pass exists to catch, and
|
||
every `sr-only` node measures as good as zero. It uses `checkVisibility()`
|
||
instead — `display: none`, `visibility: hidden` and skipped `content-visibility`
|
||
subtrees are dropped, because then nobody receives the label; opacity and
|
||
off-screen positioning are kept, because the accessibility tree keeps them.
|
||
- **An empty value is never reported.** `alt=""` is load-bearing markup — it marks
|
||
an image as decorative so screen readers skip it — and flagging it would push
|
||
authors into writing alt text for spacers.
|
||
- **Findings are keyed by attribute and value, not by element**, with a `(×N)`
|
||
count. One un-migrated shared component rendered on twenty rows would otherwise
|
||
bury every other string on the screen.
|
||
- **One accented character does not clear an attribute**, unlike the text pass.
|
||
An `aria-label` built as `` `Collapse ${label}` `` renders `Collapse Ĝēńēŕàĺ` —
|
||
an un-migrated English _frame_ around a migrated value, which accent-clears-all
|
||
would call done. Allowlist stripping is what separates that from the legitimate
|
||
case, where the ASCII fragment is allowlisted _data_ rather than the frame
|
||
(`Àćţĩōńś ƒōŕ Agent` on the layouts screen strips to nothing). Such findings are
|
||
tagged `— English frame, migrated value`.
|
||
|
||
The guard is still the primary defence: those five attributes are in
|
||
`jsx-attributes.include`, so on an allowlisted path the lint rule flags the
|
||
literal before it ever renders. The oracle is the second check that attribute copy
|
||
previously had none of — on a path not yet allowlisted, or for a fragment the
|
||
`words.exclude` patterns skip. Prefer a `t()` call over a template literal in an
|
||
attribute either way, so the value stays greppable.
|
||
|
||
### …and it is weakest at an interpolated value
|
||
|
||
Plain-English-on-screen is not a complete signal. When an un-migrated value is
|
||
interpolated into a **migrated** frame, the frame renders accented and the
|
||
payload reads as part of the sentence:
|
||
|
||
```text
|
||
agents:unableToStartUpdate = "Unable to start update: {{error}}"
|
||
→ Ữńàƀĺē ţō śţàŕţ ūþďàţē: Agent installation is already in progress.
|
||
```
|
||
|
||
That scans as done. Both instances found this way survived a twelve-screen pseudo
|
||
walk of the migrated surface before anyone noticed them.
|
||
|
||
So when a message interpolates a value, ask **where the value originates**. That
|
||
distinction is the whole check, and it is a two-minute grep over the paths you
|
||
migrated:
|
||
|
||
```bash
|
||
cd apps/web
|
||
grep -rnE 't\("[^"]+", \{ (error|message)' components app hooks | grep -v '\.test\.'
|
||
```
|
||
|
||
- **An API or network error payload stays English by design.** `String(err)` from
|
||
a failed request, or a `result.error` the backend returned, is a diagnostic —
|
||
see [Backend](#what-the-backend-deliberately-does-not-translate) for why the
|
||
~1,100 server error strings are out of scope. Around 37 catalog keys already
|
||
interpolate one; none of them is a miss. Where such a call needs a fallback for
|
||
a missing payload, the _fallback_ is copy and is translated
|
||
(`t("linear:testFailed", { error: result.error || t("linear:unknownError") })`)
|
||
while the payload itself is not.
|
||
- **A client-side literal reaching a translated frame is a miss.** A string
|
||
thrown, `setError`'d, or assigned in one of your own hooks or helpers is copy
|
||
you wrote, and neither the lint rule nor the pseudo-locale can see it.
|
||
|
||
**A translated toast followed by an English `throw` is correct, not a miss.**
|
||
This shape recurs across the migrated GitHub and Jira settings and should not be
|
||
churned:
|
||
|
||
```tsx
|
||
} catch {
|
||
toast({ description: t("github:failedToSaveQuickActions"), variant: "error" });
|
||
// Signals failure to the settings save coordinator, which only records the
|
||
// contributor id — this message never reaches a user, so it is not copy.
|
||
throw new Error("Failed to save quick actions");
|
||
}
|
||
```
|
||
|
||
The user already saw translated copy; the throw is control flow for
|
||
`useSettingsSaveContributor`. Translating it would change nothing on screen.
|
||
|
||
While the migration is in flight, an unmigrated screen is expected to be full of
|
||
plain English — pseudo only proves anything about screens whose components are on
|
||
the `i18nGuardFiles` allowlist.
|
||
|
||
### Walk it by consumer, not by route
|
||
|
||
Which screens you walk matters as much as what you look for. A component's copy
|
||
is verified by walking **every route that mounts it** — not the routes your
|
||
migration owns.
|
||
|
||
The natural method fails here. Walking the import closure _from your own pages_
|
||
traverses the graph in the wrong direction: it finds what your routes render, and
|
||
by construction never finds a route that renders _you_. One migration hit it in
|
||
both directions — the cards it converted mount only on a sibling migration's
|
||
route, and a card that sibling owned mounts on its own. Neither could have been
|
||
verified from its own pages alone.
|
||
|
||
`job-progress-indicator.tsx` is the worked example. It reported **0** findings,
|
||
because its `Queued` / `Running` / `Done` / `Failed` come out of a plain `switch`
|
||
in a non-JSX function, and it has four consumers spanning two directories
|
||
(`database-stats-card`, `disk-usage-card`, `backups-table`, and Storage's
|
||
`storage-quarantine-card`). Three separate migrations rendered it, and each left
|
||
it — a component every one of them touched, and none of them owned. The System
|
||
migration finally took it and walked Storage's already-merged route to confirm.
|
||
|
||
Per component you touch:
|
||
|
||
```bash
|
||
grep -rn '<JobProgressIndicator' apps/web/app apps/web/components
|
||
```
|
||
|
||
then walk each of those routes under pseudo, whoever owns them. If a consumer
|
||
belongs to an unmigrated directory, note it in the guard comment rather than
|
||
silently leaving it — the next migration inherits the answer instead of
|
||
rediscovering the question.
|
||
|
||
### Copy that belongs to no directory
|
||
|
||
A file shared by _every_ area has no natural owner and so gets skipped by all of
|
||
them. `src/settings-routes.tsx` holds the live title and description for every
|
||
settings route in one `SETTINGS_ROUTES` map — a SCREAMING_CASE identifier the
|
||
guard skips entirely, in a 650-line file no single migration's closure claims.
|
||
|
||
The rule: a migration converts the entries it owns but must **not** add such a
|
||
file to `i18nGuardFiles`, because allowlisting it claims a completeness nobody
|
||
has. The last area to migrate adds it. Say so in your guard comment, naming which
|
||
entries are still English and who owns them.
|
||
|
||
### An existing key is not automatically the right key
|
||
|
||
When the same copy is rendered from two places and one of them is already
|
||
migrated, reaching for the key that already exists looks like the careful move.
|
||
It is the one shape in this guide that can **silently rewrite user-visible
|
||
English**, and every gate passes while it does.
|
||
|
||
Two surfaces rendering "the same" sentence have usually **drifted**. Settings →
|
||
System had an unreferenced `app/settings/system/<route>/page.tsx` twin for every
|
||
route, and the twins were migrated at a different time by a different PR:
|
||
|
||
| surface | copy |
|
||
| --------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
|
||
| `SETTINGS_ROUTES` — live, what users see | `Create a diagnostic ZIP with frontend and backend logs.` |
|
||
| `app/settings/system/logs/page.tsx` — dead → `settings:logsPageDescription` | `Download a bounded diagnostic ZIP containing frontend and backend logs.` |
|
||
|
||
Pointing the live route at the existing key changed the sentence on a shipping
|
||
page. `pnpm lint`, `pnpm run i18n:check`, the ratchet and 8,000+ unit tests were
|
||
all green — the key resolved, the catalogs matched, nothing was hardcoded. The
|
||
copy was simply _different_. `feature-toggles` had drifted the same way in the
|
||
same directory.
|
||
|
||
Nothing in the toolchain can catch this, because every check verifies that a key
|
||
resolves, not that it resolves to the string the route used to render. The only
|
||
thing that caught it was a single Playwright assertion:
|
||
|
||
```
|
||
e2e/tests/system/logs-page.spec.ts:16
|
||
getByText('Create a diagnostic ZIP with frontend and backend logs.')
|
||
```
|
||
|
||
One of nine routes happened to pin its description. The other eight could have
|
||
drifted with nothing failing.
|
||
|
||
**Diff the two surfaces before reusing a key**, and when a route group's headers
|
||
are duplicated anywhere, pin them:
|
||
|
||
```bash
|
||
# What does the live surface actually render today?
|
||
git show origin/main:apps/web/src/settings-routes.tsx | grep -A6 '"/settings/<area>/<route>"'
|
||
# What does the existing key say?
|
||
node -e 'console.log(require("./apps/web/src/locales/en/<ns>.json").<key>)'
|
||
```
|
||
|
||
`apps/web/components/settings/system/system-route-copy.test.ts` is the shape of
|
||
the guard: a table of every route's title and description as English, asserted
|
||
byte-for-byte. It converts "I diffed them carefully" into something that stays
|
||
true after you leave.
|
||
|
||
`pnpm run i18n:removed` is the **mechanical form of that diff**, and it needs no
|
||
foresight about which strings to pin. It compares the strings present before and
|
||
after your change and reports any that left the source without reappearing as a
|
||
catalog value — so it covers every file in the change, not the one that happened
|
||
to have a spec. On the case above it reports the live sentence immediately,
|
||
because `Create a diagnostic ZIP…` is nowhere in any catalog.
|
||
|
||
It parses with Babel rather than matching quoted strings, because **JSX text
|
||
carries no quotes** — `<Link>Back to Workspaces</Link>` is most migrated copy and
|
||
a regex cannot see it. Three acceptance rules keep it quiet on a clean
|
||
migration: a value extracted into `{{interpolation}}`, either half of a `<Trans>`
|
||
sentence split around an element, and an `_one`/`_other` pair.
|
||
|
||
**The hazard has two shapes, and only one of them is above.** The case in this
|
||
section is an _attribute_ — `description="…"` on a route shell — which is
|
||
quoted, so even a crude scan finds it. The other is _element text_, which is
|
||
not quoted and is where most migrated copy lives; a reused key that rewrites a
|
||
button or a link label leaves nothing for a quoted-string search to find. Do not
|
||
read this section as "route descriptions drift". Any copy rendered from two
|
||
places can.
|
||
|
||
It is **advisory and deliberately not in CI**: it is diff-based, so it needs a
|
||
base ref (`i18n:check` is repo-wide on purpose), and a finding is a prompt to
|
||
_classify_ rather than proof of a bug — each is a value extraction, a literal
|
||
hoisted to a constant, or the real thing. It narrows the diff for a human; it
|
||
does not decide. `--base <ref>` overrides the `origin/main` fork point, and
|
||
`--all` widens past prose to identifier-shaped strings.
|
||
|
||
Some benign findings recur and are worth recognising rather than investigating:
|
||
an array of fragments collapsed into one comma-separated value is accepted
|
||
because every fragment survives verbatim inside the join — the safe property is
|
||
that the collapse is **concatenative**, not that it is an array, and a collapse
|
||
that also _rewords_ is reported, correctly. The shape to be wary of is not
|
||
"array collapsed to string", it is "collapse that also edits".
|
||
|
||
### Prove it can fail, before you believe it passed
|
||
|
||
**A pass from this check is worth exactly what its ability to fail is worth.**
|
||
After a clean run, reword one string your diff removed, confirm the check
|
||
reports it, and restore. This is not diligence; it is the only way to tell a
|
||
check that read your diff and found nothing from one that could not have failed.
|
||
Both print the same tick.
|
||
|
||
That distinction is not hypothetical. Every defect found in this tool during
|
||
review had the same signature — _it returned a pass it had not earned_ — and not
|
||
one was visible to its own unit tests, because those assume a well-formed
|
||
catalog and a committed tree. A message that was nothing but a placeholder
|
||
compiled to a match-anything pattern and accepted every string in the
|
||
repository. The "after" side read `HEAD` while the file list came from the
|
||
working tree, so uncommitted edits were invisible and it printed a tick _with
|
||
the correct file count_. Each was found by someone running it for real and
|
||
declining to believe the result.
|
||
|
||
**The probe must be a rewrite, and it must change the opening words.** Two
|
||
probes proved nothing before this was written down, and neither was the
|
||
author's fault:
|
||
|
||
- an **append** is absorbed by rule 3 by design (`message.includes(norm)`), so
|
||
`"Try asking"` → `"Try asking now"` always passes;
|
||
- a rewrite that **keeps the opening words** is absorbed by rule 2, because the
|
||
literal prefix is what the pattern anchors on — a probe on a
|
||
`"Delete {{name}}"`-shaped message that still begins `Delete ` tests nothing.
|
||
|
||
A good probe changes the whole sentence: `"What do you want to change?"` →
|
||
`"What would you like to configure?"` is reported.
|
||
|
||
**Its coverage is partial, and a clean run is evidence rather than proof.** Rule
|
||
2 is only as tight as the literal text around a message's placeholders, so a
|
||
message with too little of it earns no pattern at all — it needs two words of
|
||
prose. That threshold is not arbitrary. One short word clears any "is there a
|
||
word?" bar while leaving the pattern almost unbounded: `"Delete {{name}}"`
|
||
compiled to `/^Delete [\s\S]+?$/` and accepted _every_ sentence beginning
|
||
"Delete ", which left 19 of 43 strings unprotected in the first file this check
|
||
was pointed at.
|
||
|
||
Measured by simulating a rewrite of every removed string, requiring two words
|
||
took detection from 79 to **89 of 96** on one migration while adding **zero**
|
||
findings to either of two real diffs. The seven that remain are messages whose
|
||
prose is genuinely too thin to distinguish a rewrite from anything else, so
|
||
treat a clean run as evidence, not proof — that is why the mutation probe above
|
||
is a separate step, and why "the check was clean" is not on its own an answer to
|
||
"did the English change?".
|
||
|
||
One limit is structural rather than a tuning choice, and is worth knowing before
|
||
anyone tries to close it: `"{{count}} of {{total}}"` (a legitimate value
|
||
extraction) and `"{{a}} of {{b}}"` (far too permissive to anchor anything) both
|
||
reduce to the residue `"of"` and compile to the _same_ regex. Nothing computed
|
||
from the message can accept one and reject the other. Both are rejected, so a
|
||
string like `"3 of 9"` is reported and classified by hand — the right direction
|
||
for an advisory check, since a false positive costs someone ten seconds and a
|
||
false negative is the failure the check exists to prevent.
|
||
|
||
**The general rule is: verify against the authority, not against a copy.** The
|
||
route table is the authority for what renders — a `page.tsx` that renders a
|
||
component is not evidence that page is the live surface, and reading one led to
|
||
a changelog surface being treated as live when `SETTINGS_ROUTES` never mounted
|
||
it. `origin/main` is the authority for what ships — a fetched or pasted copy of
|
||
a script is not the script, and testing one produced a defect report against
|
||
code already fixed on the branch. The subtlest instance was an agent quoting a
|
||
colleague's description of a file while its own measurement of that file sat
|
||
unread in its terminal. All three look like diligence: something was read,
|
||
something was run. None was the thing that decides.
|
||
|
||
The underlying defect is the duplication itself. Unreferenced pages that
|
||
re-render live copy will drift again, and the next migration inherits the same
|
||
trap — which is why deleting a dead twin is a correctness change, not tidiness.
|
||
Do it in its own PR, not inside a copy-only migration.
|
||
|
||
## Adding a real language
|
||
|
||
Use a canonical lowercase locale id such as `zh-cn` or `ja`. Locale negotiation
|
||
accepts case variants such as `zh-CN` and unambiguous region suffixes such as
|
||
`ja-JP` (collapsed onto `ja`); cookies and `<html lang>` use the canonical id.
|
||
|
||
For Traditional Chinese Taiwan / Hong Kong specifically, prefer converting from
|
||
`zh-cn` with `node scripts/convert-zh-cn-to-zh-hant.mjs --write` (OpenCC +
|
||
`scripts/lib/zh-hant-glossary.json`) rather than re-translating from English.
|
||
See `docs/plans/traditional-chinese-locales/glossary.md`.
|
||
|
||
1. Add the locale to `SUPPORTED_LOCALES` and give it a fixed endonym in
|
||
`LOCALE_LABELS` in `apps/web/lib/i18n/index.ts`. Add the same id to
|
||
`supportedLocales` in `apps/backend/internal/i18n/i18n.go`. Keep the frontend
|
||
and backend lists synchronized. Also wire `date-locale.ts` when the locale
|
||
needs a non-English date-fns pack.
|
||
2. Copy every `apps/web/src/locales/en/*.json` namespace into
|
||
`apps/web/src/locales/<locale>/` (or generate from `zh-cn` for Traditional
|
||
Chinese). Translate values only: never rename keys, plural suffixes,
|
||
interpolation placeholders, `<Trans>` tags, command tokens, shortcuts,
|
||
filenames, URLs, or brand names.
|
||
3. Add `apps/backend/internal/i18n/locales/<locale>.json` with exactly the same
|
||
keys as the backend `en.json` and translate its values.
|
||
4. Add focused frontend tests for registration, selection, activation, cookie
|
||
and boot restoration, `<html lang>`, and `Intl` locale forwarding. Add backend
|
||
tests for canonicalization, cookie and `Accept-Language` negotiation, message
|
||
lookup, exact catalog parity, and the first shell response.
|
||
5. Run `pnpm run i18n:check` from `apps/web`. It discovers every real locale
|
||
except `en` and generated `pseudo`, and **fails** on any missing or extra
|
||
namespace or key, a dropped placeholder or tag, an empty value, or a value
|
||
left identical to English; see below. Regenerate `pseudo` only from English
|
||
with `pnpm run i18n:pseudo`; never copy a human translation into it.
|
||
6. In a development or E2E build, select the new language under **Settings >
|
||
General > Appearance**, reload, and switch back to English. Confirm translated
|
||
copy, the locale cookie, `<html lang>`, interpolation, layout, and then use
|
||
`pseudo` to find English literals on already migrated surfaces.
|
||
|
||
A complete locale catalog guarantees that every externalized message has a
|
||
translation. Externalization itself is complete: the literal rule is repo-wide.
|
||
|
||
### Real-locale catalogs gate
|
||
|
||
`en` and `pseudo` have always gated. Real-locale parity used to be a **warning**,
|
||
and the reasoning was sound at the time: translation happened out of band, so
|
||
gating on it made an ordinary English-only PR fail CI for work nobody in the
|
||
merge path could do. It was not hypothetical — #2243 introduced `zh-cn` together
|
||
with the parity gate, #2261 added 13 English `chat` keys twenty minutes later,
|
||
and `main` went red because the two could not have been ordered to avoid it.
|
||
|
||
The cost of that policy came due. Measured before this change, against 8317 `en`
|
||
entries:
|
||
|
||
| locale | missing keys | values identical to English | extra keys |
|
||
| ------ | -----------: | --------------------------: | ---------: |
|
||
| pt-pt | 312 | 232 | 9 |
|
||
| zh-cn | 217 | 82 | 7 |
|
||
| zh-hk | 415 | 81 | 1 |
|
||
| zh-tw | 415 | 81 | 1 |
|
||
|
||
None of it visible in a green build. The four catalogs were completed first, then
|
||
`ja` joined them, so the failure mode has flipped: the risk is no longer "a PR is
|
||
blocked on a translator" but "a PR silently re-opens the gap". So the pass
|
||
**gates**, and the cost is that adding user-facing copy means adding it in six
|
||
languages (`en` plus five real locales). That is the real cost of shipping those
|
||
languages; it was previously being deferred rather than paid.
|
||
|
||
For `zh-tw` and `zh-hk`, do not hand-translate: run `pnpm run i18n:zh-hant`,
|
||
which converts `zh-cn` through OpenCC plus the product glossary and emits proper
|
||
regional variants (`裝置` for TW, `設備` for HK). Review the output.
|
||
|
||
#### Identical-to-English, and the verbatim registry
|
||
|
||
A catalog copy-pasted wholesale from `en` passes every structural check, because
|
||
they all compare shapes. The identical-to-English check closes that, in two tiers
|
||
so the exemption mechanism never becomes a shrinking allowlist:
|
||
|
||
1. **Rule.** If the English value is not copy by `looksLikeCopy` — a brand noun,
|
||
an acronym, a wire value, a frame of nothing but `{{placeholders}}` — every
|
||
locale is expected to keep it verbatim and nothing is declared. That is the
|
||
same predicate `i18next/no-literal-string` and `check-nonjsx-copy.mjs` use, so
|
||
the three cannot disagree about what a translatable string is.
|
||
2. **Declaration.** What survives tier 1 is prose. Prose that reads the same in
|
||
the target language is a judgement someone made, so it is declared with a
|
||
reason: `src/locales/_verbatim.json` for values every locale keeps (git verbs,
|
||
`pull request`, product names), `src/locales/<locale>/_verbatim.json` for the
|
||
language-specific coincidences (`Total`, `Manual`, `Menu` in pt-PT).
|
||
|
||
Both files are self-cleaning. A reasonless entry fails. An entry for a key that
|
||
no longer exists fails. A per-locale entry whose locale has since translated the
|
||
value fails, and a shared entry fails once **no** locale keeps it verbatim. And
|
||
deleting an entry does not make anything pass — it makes the identical value
|
||
fatal again. That is what distinguishes it from an allowlist you can shrink.
|
||
|
||
#### What the check does and does not decide
|
||
|
||
A green run still does **not** mean "well translated" — it means the catalog is
|
||
structurally complete and nobody left English in a translatable slot. Measured,
|
||
one mutation at a time:
|
||
|
||
| mutation | result |
|
||
| ------------------------------------- | ------------------------------ |
|
||
| value identical to English | caught (unless declared) |
|
||
| whole catalog copy-pasted from `en` | caught |
|
||
| empty / whitespace / non-string value | caught |
|
||
| missing key, extra key | caught |
|
||
| missing namespace, extra namespace | caught |
|
||
| dropped `{{placeholder}}` | caught |
|
||
| dropped or renumbered `<n>` tag | caught |
|
||
| swapped `_one` / `_other` | **not caught** |
|
||
| a fluent but wrong translation | **not caught** — a human's job |
|
||
|
||
The identical-to-English row used to read "not caught", and the reasoning was
|
||
that brand nouns and technical literals must stay verbatim (`automations` alone
|
||
has several — `GitHub`, `Webhook`, `Webhook URL`), so a naive `value === en`
|
||
would report each of them. That is true of a naive check; the two-tier form above
|
||
does not have the problem, because `looksLikeCopy` already knows a brand noun
|
||
from a sentence. `scripts/check-i18n-keys.test.ts` pins both the caught and the
|
||
uncaught cases so this stays honest.
|