Files
kandev/docs/i18n.md
T
2026-08-02 10:58:18 +02:00

413 lines
19 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; a
generated **pseudo-locale** is used for QA. Requirements live in
[`docs/specs/platform/i18n.md`](specs/platform/i18n.md).
The migration is **in progress, one directory per PR**. The runtime, the gates,
and Settings → General → Appearance are done; everything else still holds
English literals.
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 only once migrated.** The eslint literal rule errors
across an entire file only for the paths in `i18nGuardFiles`, so you are never
asked to migrate lines you did not touch. See
[The guard is scoped](#the-guard-is-scoped-on-purpose) and
[Migrating a directory](#migrating-a-directory).
## 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. Two exist so far — `common`
(shared/anything cross-cutting) and `settings` — and each migrated directory adds
the one it needs (`sidebar`, `statusBar`, `chat`, `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.
### 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.
## Commands
> Requires **Node 24** (`apps/package.json` `engines`).
```bash
cd apps/web
pnpm run i18n:check # the four 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)
# 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, ignoring the allowlist. It is the work-list generator for a
directory you are about to migrate; it never gates a merge. The real gate is
`pnpm lint`, where the rule is an **error** — but only on the allowlist.
Unlike the eslint guard, `i18n:check` is **not** scoped to the allowlist — its
four checks scan `components`, `app`, `hooks` and `lib` repo-wide, and it runs in
CI (Frontend Tests → *Run i18n checks*). That is deliberate: each one 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 files pass
trivially, because a file that never calls `t()` cannot break any of the four.
`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, and rejects `t()` at module
scope.
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.
## 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 and
collapse to `dir/**` once it is done. **Never remove an entry** to make a build
pass — that silently un-protects migrated copy. `check-guard-allowlist.mjs`
enforces this: it fails if an entry disappears while its path still exists.
### …but new code is guarded everywhere
Scoping the whole-file guard leaves a hole: a brand-new component under an
un-migrated directory 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, migrated paths. Grows one directory per PR.
- **the new-code ratchet** — new lines, everywhere. Always on.
## Migrating a directory
One directory per PR. The whole loop:
```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`.
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.
### 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.
`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.
## 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`, loaded eagerly through
Vite's glob import — no build plugin and no macro transform.
- **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.
- **Shared UI**: `@kandev/ui` primitives still carry their own hardcoded
accessibility labels ("Close", "Toggle Sidebar"). Localizing them needs a
strings-provider seam in the package; not done yet.
- **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.
It has one blind spot of its own: **module-scope `t()`**. That copy *is*
translated, just frozen at the boot locale, so it renders accented under pseudo
and looks correct while never responding to a switch. `check-module-scope-t.mjs`
is what catches it.
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.
## Adding a real language
1. Add the locale to `SUPPORTED_LOCALES` + `LOCALE_LABELS` in
`apps/web/lib/i18n/index.ts`, and to `supportedLocales` in
`apps/backend/internal/i18n/i18n.go`.
2. Copy `src/locales/en/*.json` to `src/locales/<locale>/` and translate the
values (keys stay identical).
3. Add `apps/backend/internal/i18n/locales/<locale>.json` with the same keys as
`en.json` (the backend catalog is tiny — see Backend above).
4. The switcher, formatting, and `<html lang>` pick it up automatically.