fix(web): externalize copy the jsx-only i18n guard cannot see, and gate it (#2711)

Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
João Salavisa
2026-08-16 12:08:59 +01:00
committed by GitHub
parent 38c94e7d00
commit 63c21686ff
140 changed files with 1534 additions and 233 deletions
+61 -4
View File
@@ -211,6 +211,7 @@ pnpm run i18n:check # the five repo-wide gates (runs in CI: Frontend Tests)
pnpm run i18n:pseudo # regenerate the pseudo catalog from en
pnpm run lint:i18n <path> # preview the guard on a path that is NOT yet migrated
pnpm run i18n:ratchet # new-code guard + allowlist-only-grows (pre-commit + CI)
node scripts/check-nonjsx-copy.mjs # copy lint cannot see (part of i18n:check)
pnpm run i18n:removed # did this change rewrite any English? (diff-based, advisory)
pnpm run i18n:parity # per-locale missing/extra keys (report, never a gate)
pnpm run i18n:sweep <path> # copy the jsx-only rule cannot see (report, never a gate)
@@ -227,9 +228,11 @@ 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
five checks scan `components`, `app`, `hooks`, `lib`, and published Markdown in
checks scan `components`, `app`, `hooks`, `lib`, and published Markdown in
`docs/public` repo-wide, and it runs in
CI (Frontend Tests → _Run i18n checks_). That is deliberate: each one pins a bug
CI (Frontend Tests → _Run i18n checks_). The one exception is
`check-nonjsx-copy.mjs`, which is scoped to `i18nGuardFiles` for the same reason
the eslint rule is (see [Non-JSX copy](#and-the-blind-spots-are-now-a-gate)). 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 source
files pass trivially, because a file that never calls `t()` cannot break the
@@ -247,8 +250,9 @@ 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.
English plural endings passed as interpolation values, rejects `t()` at module
scope, and rejects hardcoded copy in the non-JSX positions the eslint rule cannot
inspect.
Module scope matters because the module-level `t` resolves when the file is
imported: `const ROWS = [{ label: t("k") }]` pins that copy to the boot locale, so
@@ -484,6 +488,59 @@ you cannot fix this in place. Resolve the fallback inside the body instead
(`const pending = pendingLabel ?? t("system:jobRunning")`). Prop defaults on a
shared presentational component are a common home for this.
### …and the blind spots are now a gate
Everything above is still true about `i18next/no-literal-string`. What changed is
that a second detector runs over the same files:
`scripts/check-nonjsx-copy.mjs` (logic in `scripts/lib/nonjsx-copy.mjs`), part of
`pnpm run i18n:check`. It inspects exactly the positions the rule cannot:
| shape | example |
| --- | --- |
| `config-table` | `const ROWS = [{ label: "Disk usage" }]` |
| `helper-return` | `function badge() { return "Waiting for input" }` |
| `param-default` | `function C({ label = "Running..." })` |
| `display-call` | `toast({ title: "Rename failed" })`, `setPreviewError("…")` |
| `copy-property` | `{ description: "…" }` outside a table |
It is **scoped to `i18nGuardFiles`**, the same allowlist the eslint rule uses and
for the same reason: judging an un-migrated file's existing config tables would
recreate the treadmill described above. The repo-wide half is
`check-new-code-i18n.mjs`, which runs this same detector over added files and
changed lines everywhere — so the new-code ratchet no longer inherits the hole.
Template literals are read too, judged on their **longest static chunk** rather
than on the chunks joined: `` `Every day at ${at} ${zone}` `` is copy, while
`` `${w}px ${h}em` `` is not.
#### Silencing one, and why the reason is mandatory
```ts
// i18n-exempt: persisted preset seed; localizing needs a key/value split.
const DEFAULT_PR_PRESETS = [{ label: "Review", hint: "Read the diff, flag issues" }];
```
The marker attaches to the enclosing **statement** or **function** — one comment
covers a whole config table, or a whole prompt builder with four `return`s. A
marker with no reason after the colon is *reported*, not honoured.
That is the load-bearing part. Every shape this check finds has a legitimate
form, and they are not distinguishable from the string: a value persisted in
whichever locale created it, a prompt sent verbatim to a model, a token compared
with `===`, a brand noun. The ~60 exemptions in the tree today are each one of
those, and every one says which.
#### What it still cannot see
It reads literals, so copy assembled at runtime from fragments is invisible, and
it takes the author's word for an exemption. The pseudo-locale remains the
completeness check. Two limits are worth knowing concretely:
- A **single lowercase word** (`"ended"`, `"watching"`) is below the copy
threshold — too little signal to separate from an enum value. Its neighbours in
the same helper usually are above it, which is how those two were found.
- A string built by concatenation (`prefix + name`) is not a literal.
`externalize-strings.mjs`
covers those shapes — it walks `.ts` as well as `.tsx` and handles template
literals and dialog/toast arguments — but **a zero from `pnpm lint` does not by