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:
+61
-4
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user