feat(i18n): localize Settings → Workspace (#2212)

Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
João Salavisa
2026-08-03 20:52:09 +02:00
committed by GitHub
parent 8ae850956f
commit 957cbb910e
25 changed files with 1656 additions and 180 deletions
+55
View File
@@ -159,6 +159,7 @@ 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
@@ -643,6 +644,60 @@ 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.
**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 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.