Files
kandev/docs/i18n.md
T
João Salavisa d9ea08af82 docs(i18n): consumer sweeps, destructuring defaults, and unowned shared copy (#2204)
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 16:04:02 +02:00

27 KiB

Internationalization (i18n)

Kandev's web UI is internationalized with i18next + react-i18next. English is the source locale; a generated pseudo-locale is used for QA. Requirements live in docs/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.
  • 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 and 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:
    { "deleteExecutor": "Delete executor" }
    
  2. Reference it by namespace:key:
    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

{ "deletedItems": "Deleted {{count}} items" }
t("common:deletedItems", { count })

Plurals

i18next uses suffixed keys; the call site passes count:

{ "sessions_one": "{{count}} session", "sessions_other": "{{count}} sessions" }
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:

<Trans i18nKey="settings:typeToConfirm" values={{ phrase }}>
  Type <strong>{phrase}</strong> to continue.
</Trans>
{ "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:

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:

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:

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).

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:

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?
  1. 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.
  2. If the area has a screen worth crawling, add it to SCREENS in e2e/tests/i18n/pseudo-coverage.spec.ts.
  3. pnpm run i18n:check && pnpm lint --max-warnings 0 && pnpm exec vitest run.
  4. 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.

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:

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.

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.

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.

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.

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.

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.

Three things it cannot show you at all — the second and third are 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. Copy that never becomes a text node — an attribute string leaves no trace on screen at all.
  3. An un-migrated value interpolated into a migrated frame — it renders inside accented copy and reads as done.

It cannot see copy that is not a text node

findUnlocalizedText in e2e/tests/i18n/pseudo-coverage.spec.ts walks NodeFilter.SHOW_TEXT, and a human looking at the screen is no different: copy carried in an attribute — aria-label, aria-description, title, placeholder, alt — renders nothing visible and leaves no trace under pseudo. The Settings → System → Storage review demonstrated it by reverting an aria-label to an English template literal; nothing failed.

The guard is the primary defence here, not the oracle: those five attributes are in jsx-attributes.include, so on an allowlisted path the lint rule does flag them. The consequence is that for attribute copy there is no second check — on a path not yet allowlisted, or for a fragment the words.exclude patterns skip, nothing downstream catches it. The copy only screen-reader users receive is therefore the copy with the weakest safety net. Read attribute props by eye when you migrate, and prefer a t() call over a template literal in them 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:

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:

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 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:

} 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:

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.

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.