c7be985a29
Co-authored-by: Kandev Agent <agent@kandev.dev>
413 lines
19 KiB
Markdown
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.
|