a2db334adc
Co-authored-by: Kandev Agent <agent@kandev.dev>
904 lines
46 KiB
Markdown
904 lines
46 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,
|
||
Simplified Chinese (`zh-cn`) is the second shipped human locale, and 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. Current namespaces are `agents`,
|
||
`automations`, `azuredevops`, `chat`, `common` (shared/anything cross-cutting), `configChat`,
|
||
`executors`, `github`, `gitlab`, `jira`, `linear`, `sentry`, `settings`,
|
||
`sidebar`, `slack`, `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.
|
||
|
||
## 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)
|
||
pnpm run i18n:removed # did this change rewrite any English? (diff-based, advisory)
|
||
|
||
# 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. 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 `[[]…[]]` — and prove the entry
|
||
is live rather than trusting the glob result, the same way you would prove any
|
||
other check earned its pass: put a hardcoded literal in one of the files the
|
||
entry is supposed to cover and confirm `pnpm lint` reports
|
||
`i18next/no-literal-string`, then remove it. A silent 0 errors is the tell.
|
||
|
||
### …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 — 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.
|
||
|
||
`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 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.
|
||
|
||
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.
|
||
|
||
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`. Locale negotiation accepts
|
||
case variants such as `zh-CN`, but cookies and `<html lang>` use the canonical id.
|
||
|
||
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.
|
||
2. Copy every `apps/web/src/locales/en/*.json` namespace into
|
||
`apps/web/src/locales/<locale>/`. 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 **warns** on any missing or extra
|
||
namespace or key — advisory, not a build failure; 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.
|
||
|
||
Kandev's migration remains incremental. A complete locale catalog guarantees
|
||
that every currently externalized message has a translation; it does not mean
|
||
that every screen has been externalized. Unmigrated surfaces continue to render
|
||
English until their directory joins `i18nGuardFiles`.
|
||
|
||
### Real locales warn, they do not gate
|
||
|
||
`en` and `pseudo` gate. `en` is authored in the same PR as the code and `pseudo`
|
||
is **generated** from it, so a mismatch between them is always the author's to
|
||
fix in the change that caused it. Real-locale parity is a **warning**.
|
||
|
||
That is a deliberate policy, not an oversight. Translation happens out of band,
|
||
on a third party's cadence, so gating on it makes an ordinary English-only PR
|
||
fail CI for work nobody in the merge path can do. It is not hypothetical: #2243
|
||
introduced `zh-cn` together with the parity gate, #2261 added 13 English `chat`
|
||
keys twenty minutes later, and `main` went red — not because either change was
|
||
wrong, but because the two could not have been ordered to avoid it. The gate
|
||
fired exactly as designed and blocked the wrong people.
|
||
|
||
So: **add the keys to every real locale when you can, and never let a missing
|
||
one stop you merging.** The warning is addressed to whoever owns the
|
||
translation.
|
||
|
||
#### It is a structural check, not a translation check
|
||
|
||
Worth stating plainly, because assuming otherwise has already produced two wrong
|
||
conclusions: a green run does **not** mean "translated". The pass compares
|
||
shapes. Measured, one mutation at a time:
|
||
|
||
| mutation | result |
|
||
|---|---|
|
||
| value identical to English | **not caught** |
|
||
| whole catalog copy-pasted from `en` | **not caught** |
|
||
| swapped `_one` / `_other` | **not 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 |
|
||
|
||
The identical-to-English tolerance is deliberate and cannot be tightened
|
||
naively: brand nouns and technical literals must stay verbatim (`automations`
|
||
alone has seven — `GitHub`, `Webhook`, `Webhook URL`, …), so `value === en` would
|
||
report each of them. Closing it needs a per-key exemption list or a
|
||
whole-file proportion threshold. Reviewing a translation is a human job;
|
||
`scripts/check-i18n-keys.test.ts` pins both the caught and the uncaught cases so
|
||
this stays honest.
|