fix(i18n): make real-locale catalog parity advisory, not a gate (#2272)
Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
@@ -10,6 +10,35 @@
|
||||
* entry -> missing keys are an ERROR (they would render as the raw key)
|
||||
* - every catalog entry is referenced somewhere -> orphans are a WARNING
|
||||
* - `en` and `pseudo` have the same key sets -> drift is an ERROR
|
||||
* - real locales (anything that is not `en`/`pseudo`, discovered from the
|
||||
* directory listing rather than named here) -> parity issues are a WARNING
|
||||
*
|
||||
* ## Why real locales only warn
|
||||
*
|
||||
* `en` and `pseudo` are ours: English is authored in the same PR as the code,
|
||||
* and `pseudo` is GENERATED from it by `pnpm run i18n:pseudo`. A mismatch there
|
||||
* is always the author's to fix in the change that caused it, so it gates.
|
||||
*
|
||||
* Real-locale catalogs are translated OUT OF BAND, by a third party, on their
|
||||
* own cadence. Gating on them means an ordinary English-only PR fails CI for
|
||||
* work nobody in the merge path can do — and that is not hypothetical: #2261
|
||||
* added 13 `en` chat keys and left `main` red, because #2243 had introduced
|
||||
* `zh-cn` and the gate 20 minutes earlier. So these are reported loudly, for
|
||||
* whoever owns the translation, and do not block a merge.
|
||||
*
|
||||
* ## What this check is NOT
|
||||
*
|
||||
* The real-locale pass is STRUCTURAL, not translational. It compares shapes, so
|
||||
* a green run does not mean "translated" — measured against `main`:
|
||||
*
|
||||
* caught: missing key, extra key, missing/extra namespace, empty or
|
||||
* non-string value, dropped `{{placeholder}}`, dropped `<n>` tag
|
||||
* NOT caught: a value identical to English, a whole catalog copy-pasted from
|
||||
* `en`, and swapped `_one`/`_other` forms
|
||||
*
|
||||
* The identical-to-English tolerance is deliberate — brand nouns and technical
|
||||
* literals (`Kandev`, `GitHub`, `Webhook URL`) must stay verbatim, so there is
|
||||
* no sound automatic rule here. Reviewing a translation is a human job.
|
||||
*
|
||||
* Usage: node scripts/check-i18n-keys.mjs [--strict-orphans]
|
||||
*/
|
||||
@@ -130,10 +159,15 @@ if (pseudoMissing.length || pseudoExtra.length) {
|
||||
);
|
||||
}
|
||||
|
||||
// Advisory, never fatal — see the "Why real locales only warn" note at the top.
|
||||
// Deliberately NOT gated behind --strict-orphans or any flag: the point is that
|
||||
// no invocation of this script can be made to fail on a translation catalog.
|
||||
if (realLocaleIssues.length) {
|
||||
failed = true;
|
||||
console.error(`\n✖ ${realLocaleIssues.length} real-locale catalog parity issue(s):`);
|
||||
for (const issue of realLocaleIssues) console.error(` ${formatParityIssue(issue)}`);
|
||||
console.warn(
|
||||
`\n⚠ ${realLocaleIssues.length} real-locale catalog parity issue(s) ` +
|
||||
`— advisory, does not fail the build:`,
|
||||
);
|
||||
for (const issue of realLocaleIssues) console.warn(` ${formatParityIssue(issue)}`);
|
||||
}
|
||||
|
||||
if (orphans.length) {
|
||||
@@ -147,9 +181,16 @@ if (orphans.length) {
|
||||
}
|
||||
|
||||
if (!failed) {
|
||||
// Say only what was actually gated. The previous wording claimed "pseudo and
|
||||
// zh-cn in sync", which was misleading twice over: it implied a real locale
|
||||
// gates the build, and it read as "translated" for a check that cannot see
|
||||
// English left inside a translated value.
|
||||
const advisory = realLocales.length
|
||||
? ` ${realLocaleIssues.length} advisory ${realLocales.join(", ")} issue(s).`
|
||||
: "";
|
||||
console.log(
|
||||
`✓ i18n keys OK — ${used.size} key(s) referenced, ${en.size} en entr(ies), ` +
|
||||
`${orphans.length} orphan(s), pseudo and ${realLocales.join(", ") || "no real locales"} in sync.`,
|
||||
`${orphans.length} orphan(s), pseudo in sync.${advisory}`,
|
||||
);
|
||||
}
|
||||
process.exit(failed ? 1 : 0);
|
||||
|
||||
@@ -60,81 +60,88 @@ describe("real locale catalog parity", () => {
|
||||
expect(result.status).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects a missing namespace", () => {
|
||||
it("warns about a missing namespace without failing", () => {
|
||||
const fixture = completeFixture();
|
||||
delete fixture[ZH_CN].settings;
|
||||
const result = runFixture(fixture);
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toContain("advisory");
|
||||
expect(result.stderr).toContain(ZH_CN);
|
||||
expect(result.stderr).toContain("settings");
|
||||
expect(result.stderr).toContain("missing namespace");
|
||||
});
|
||||
|
||||
it("rejects an extra namespace", () => {
|
||||
it("warns about an extra namespace without failing", () => {
|
||||
const fixture = completeFixture();
|
||||
fixture[ZH_CN].extra = { surprise: "意外" };
|
||||
const result = runFixture(fixture);
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toContain("advisory");
|
||||
expect(result.stderr).toContain(ZH_CN);
|
||||
expect(result.stderr).toContain("extra");
|
||||
expect(result.stderr).toContain("extra namespace");
|
||||
});
|
||||
|
||||
it("rejects a missing key", () => {
|
||||
it("warns about a missing key without failing", () => {
|
||||
const fixture = completeFixture();
|
||||
delete fixture[ZH_CN].common.second;
|
||||
const result = runFixture(fixture);
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toContain("advisory");
|
||||
expect(result.stderr).toContain(ZH_CN);
|
||||
expect(result.stderr).toContain("common");
|
||||
expect(result.stderr).toContain("missing key: second");
|
||||
});
|
||||
|
||||
it("rejects an extra key", () => {
|
||||
it("warns about an extra key without failing", () => {
|
||||
const fixture = completeFixture();
|
||||
fixture[ZH_CN].common.surprise = "意外";
|
||||
const result = runFixture(fixture);
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toContain("advisory");
|
||||
expect(result.stderr).toContain(ZH_CN);
|
||||
expect(result.stderr).toContain("common");
|
||||
expect(result.stderr).toContain("extra key: surprise");
|
||||
});
|
||||
|
||||
it("rejects a translation that drops an interpolation placeholder", () => {
|
||||
it("warns about a translation that drops an interpolation placeholder", () => {
|
||||
const fixture = completeFixture();
|
||||
fixture.en.common.first = "Open {{url}} as {{user}}";
|
||||
fixture[ZH_CN].common.first = "以 {{user}} 身份打开";
|
||||
const result = runFixture(fixture);
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toContain("advisory");
|
||||
expect(result.stderr).toContain(ZH_CN);
|
||||
expect(result.stderr).toContain("common");
|
||||
expect(result.stderr).toContain("interpolation placeholder mismatch: first");
|
||||
});
|
||||
|
||||
it("rejects a translation that drops numeric Trans tags", () => {
|
||||
it("warns about a translation that drops numeric Trans tags", () => {
|
||||
const fixture = completeFixture();
|
||||
fixture.en.common.first = "Open <1>{{url}}</1>";
|
||||
fixture[ZH_CN].common.first = "打开 {{url}}";
|
||||
const result = runFixture(fixture);
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toContain("advisory");
|
||||
expect(result.stderr).toContain(ZH_CN);
|
||||
expect(result.stderr).toContain("common");
|
||||
expect(result.stderr).toContain("Trans tag structure mismatch: first");
|
||||
});
|
||||
|
||||
it("rejects a translation that changes a numeric Trans tag index", () => {
|
||||
it("warns about a translation that changes a numeric Trans tag index", () => {
|
||||
const fixture = completeFixture();
|
||||
fixture.en.common.first = "Open <1>{{url}}</1>";
|
||||
fixture[ZH_CN].common.first = "打开 <2>{{url}}</2>";
|
||||
const result = runFixture(fixture);
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toContain("advisory");
|
||||
expect(result.stderr).toContain(ZH_CN);
|
||||
expect(result.stderr).toContain("common");
|
||||
expect(result.stderr).toContain("Trans tag structure mismatch: first");
|
||||
@@ -142,37 +149,109 @@ describe("real locale catalog parity", () => {
|
||||
});
|
||||
|
||||
describe("translated value validation", () => {
|
||||
it("rejects an empty translated value", () => {
|
||||
it("warns about an empty translated value without failing", () => {
|
||||
const fixture = completeFixture();
|
||||
fixture[ZH_CN].common.first = "";
|
||||
const result = runFixture(fixture);
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toContain("advisory");
|
||||
expect(result.stderr).toContain(ZH_CN);
|
||||
expect(result.stderr).toContain("common");
|
||||
expect(result.stderr).toContain("empty translated value: first");
|
||||
});
|
||||
|
||||
it("rejects a whitespace-only translated value", () => {
|
||||
it("warns about a whitespace-only translated value without failing", () => {
|
||||
const fixture = completeFixture();
|
||||
fixture[ZH_CN].common.first = " \t\n ";
|
||||
const result = runFixture(fixture);
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toContain("advisory");
|
||||
expect(result.stderr).toContain(ZH_CN);
|
||||
expect(result.stderr).toContain("common");
|
||||
expect(result.stderr).toContain("empty translated value: first");
|
||||
});
|
||||
|
||||
it("rejects a non-string translated value", () => {
|
||||
it("warns about a non-string translated value without failing", () => {
|
||||
const fixture = completeFixture();
|
||||
fixture[ZH_CN].common.first = 42;
|
||||
const result = runFixture(fixture);
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toContain("advisory");
|
||||
expect(result.stderr).toContain(ZH_CN);
|
||||
expect(result.stderr).toContain("common");
|
||||
expect(result.stderr).toContain("non-string translated value: first");
|
||||
expect(result.stderr).not.toContain("matchAll is not a function");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* `en` <-> `pseudo` is the half that still gates, and after making real locales
|
||||
* advisory it carries all of the weight — so it needs its own coverage rather
|
||||
* than being assumed. Both catalogs are ours: English is authored beside the
|
||||
* code and `pseudo` is generated from it, so a mismatch is always the author's
|
||||
* to fix in the same change.
|
||||
*/
|
||||
describe("pseudo catalog drift still fails the build", () => {
|
||||
it("rejects a key missing from pseudo", () => {
|
||||
const fixture = completeFixture();
|
||||
delete fixture.pseudo.common.second;
|
||||
const result = runFixture(fixture);
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain("pseudo catalog is out of sync");
|
||||
});
|
||||
|
||||
it("rejects a key present only in pseudo", () => {
|
||||
const fixture = completeFixture();
|
||||
fixture.pseudo.common.surprise = "Śũřƥřîśē";
|
||||
const result = runFixture(fixture);
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain("pseudo catalog is out of sync");
|
||||
});
|
||||
|
||||
it("still fails on pseudo drift even when a real locale is also broken", () => {
|
||||
const fixture = completeFixture();
|
||||
delete fixture.pseudo.common.second;
|
||||
delete fixture[ZH_CN].common.second;
|
||||
const result = runFixture(fixture);
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain("pseudo catalog is out of sync");
|
||||
expect(result.stderr).toContain("advisory");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The real-locale pass compares SHAPES, not meaning. These pin the limits so a
|
||||
* green run is never mistaken for "translated" — the assumption that this check
|
||||
* validates translation quality has already caused two wrong conclusions.
|
||||
*/
|
||||
describe("the real-locale pass is structural, not translational", () => {
|
||||
it("accepts a catalog copy-pasted wholesale from en", () => {
|
||||
const fixture = completeFixture();
|
||||
fixture[ZH_CN] = structuredClone(fixture.en);
|
||||
const result = runFixture(fixture);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).not.toContain("advisory");
|
||||
});
|
||||
|
||||
it("accepts swapped plural forms", () => {
|
||||
const fixture = completeFixture();
|
||||
fixture.en.common = { items_one: "{{count}} item", items_other: "{{count}} items" };
|
||||
fixture.pseudo.common = { items_one: "{{count}} îţēḿ", items_other: "{{count}} îţēḿś" };
|
||||
fixture[ZH_CN].common = { items_one: "{{count}} 个项目", items_other: "{{count}} 个项目" };
|
||||
const swapped = structuredClone(fixture);
|
||||
swapped[ZH_CN].common = {
|
||||
items_one: fixture[ZH_CN].common.items_other,
|
||||
items_other: fixture[ZH_CN].common.items_one,
|
||||
};
|
||||
const result = runFixture(swapped);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
+53
-5
@@ -44,8 +44,12 @@ Two enforcement layers, and it matters which is which:
|
||||
`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,
|
||||
but every real locale must add the same namespace in the same change.
|
||||
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
|
||||
|
||||
@@ -841,9 +845,10 @@ case variants such as `zh-CN`, but cookies and `<html lang>` use the canonical i
|
||||
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`, then fails on any missing or extra
|
||||
namespace or key. Regenerate `pseudo` only from English with
|
||||
`pnpm run i18n:pseudo`; never copy a human translation into it.
|
||||
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
|
||||
@@ -853,3 +858,46 @@ 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.
|
||||
|
||||
+43
-17
@@ -50,10 +50,17 @@ language and the pseudo-locale as the migration QA oracle.
|
||||
migration PR extends the allowlist by the path it converts, so coverage only
|
||||
ever grows and un-migrated code is never a blocker.
|
||||
- CI SHALL verify keys and catalogs do not drift: every `ns:key` referenced in
|
||||
source must exist in the `en` catalog; every committed real locale must have
|
||||
exactly the same namespaces and key sets as `en`; and `pseudo` must remain in
|
||||
strict generated sync with `en`. (Catalogs are NOT regenerated from source —
|
||||
with key-based ids the English copy exists only in the catalog.)
|
||||
source must exist in the `en` catalog, and `pseudo` must remain in strict
|
||||
generated sync with `en`. Both are authored or generated in the same change as
|
||||
the code, so both SHALL fail the build. (Catalogs are NOT regenerated from
|
||||
source — with key-based ids the English copy exists only in the catalog.)
|
||||
- Real-locale parity SHALL be reported as a **warning and SHALL NOT fail the
|
||||
build**. Translation is performed out of band by a third party, so gating on it
|
||||
blocks an English-only change for work nobody in the merge path can do. This is
|
||||
a deliberate reversal of the original requirement: #2243 introduced `zh-cn`
|
||||
together with a hard parity gate, #2261 added English keys twenty minutes
|
||||
later, and `main` went red although neither change was wrong and no ordering of
|
||||
the two could have avoided it.
|
||||
- The full sweep SHALL externalize **every** user-facing string across
|
||||
`apps/web/components/` (1,121 files), `apps/web/app/` (285 files), and the
|
||||
`@kandev/ui` primitives, leaving no user-facing English literal outside the
|
||||
@@ -70,8 +77,8 @@ merge. Sequence:
|
||||
language switcher, CI gates, codemods, the scoped lint guard, and one page
|
||||
(Settings → General → Appearance) migrated end-to-end as the worked example.
|
||||
2. **First real language**: ship `zh-cn` catalogs for every existing namespace,
|
||||
register the locale in the frontend and backend, and strengthen catalog
|
||||
parity checks for every committed real locale.
|
||||
register the locale in the frontend and backend, and add advisory catalog
|
||||
parity reporting for every committed real locale.
|
||||
3. **Per-directory migrations**: each PR externalizes one directory, appends it
|
||||
to `i18nGuardFiles`, and adds its screen to the pseudo-coverage spec.
|
||||
4. **Close-out**: `@kandev/ui` primitives, the shared-task artifacts, and
|
||||
@@ -83,8 +90,10 @@ i18n adds no server-side persistent entities. It introduces:
|
||||
- **Message catalogs** — checked-in JSON under
|
||||
`apps/web/src/locales/<locale>/<namespace>.json` for `en`, `zh-cn`, and
|
||||
`pseudo`. `en` is hand-authored (keys are stable ids; the English copy lives
|
||||
only here); `zh-cn` is a human-maintained translation whose namespace and key
|
||||
sets exactly mirror `en`; `pseudo` is generated from `en` by
|
||||
only here); `zh-cn` is a human-maintained translation, maintained out of band,
|
||||
whose namespace and key sets aim to mirror `en` but may lag it — a gap warns
|
||||
rather than failing, and i18next falls back to `en` at runtime; `pseudo` is
|
||||
generated from `en` by
|
||||
`scripts/generate-pseudo-locale.mjs` and is never hand-edited. Catalogs load at
|
||||
runtime via Vite glob import — no build plugin.
|
||||
- **Locale preference** — the user's chosen locale, one value from the supported
|
||||
@@ -120,9 +129,12 @@ Frontend module contract (new):
|
||||
**THEN** `<html lang>` is `en`, all text renders from the `en` catalog, and the
|
||||
language switcher shows English selected.
|
||||
- **GIVEN** the Settings language switcher, **WHEN** the user selects
|
||||
**简体中文**, **THEN** every currently migrated message resolves from the
|
||||
`zh-cn` catalog, `<html lang>` becomes `zh-cn`, and the selection survives a
|
||||
reload through the `kandev_locale=zh-cn` cookie.
|
||||
**简体中文**, **THEN** every migrated message present in the `zh-cn` catalog
|
||||
resolves from it, any message not yet translated falls back to its `en` value
|
||||
rather than rendering the raw key, `<html lang>` becomes `zh-cn`, and the
|
||||
selection survives a reload through the `kandev_locale=zh-cn` cookie. (The
|
||||
catalog may lag `en`, because real-locale parity is advisory — the fallback is
|
||||
what makes that safe to ship.)
|
||||
- **GIVEN** no locale cookie and `Accept-Language: zh-CN`, **WHEN** the Go shell
|
||||
or a server-rendered browser error is returned, **THEN** locale negotiation
|
||||
selects `zh-cn`, the shell emits `<html lang="zh-cn">`, and the backend message
|
||||
@@ -156,8 +168,12 @@ Frontend module contract (new):
|
||||
- **GIVEN** a production build, **WHEN** the language switcher renders, **THEN**
|
||||
English and 简体中文 are offered and the pseudo-locale is not.
|
||||
- **GIVEN** an English namespace or key changes, **WHEN** `i18n:check` runs,
|
||||
**THEN** every real locale missing or adding a namespace or key fails with an
|
||||
error naming the locale, namespace, and missing or extra entries.
|
||||
**THEN** every real locale missing or adding a namespace or key is reported as
|
||||
a warning naming the locale, namespace, and missing or extra entries, **AND**
|
||||
the command still exits 0.
|
||||
- **GIVEN** the `pseudo` catalog has drifted from `en`, **WHEN** `i18n:check`
|
||||
runs, **THEN** it fails, **even when** a real locale is also out of parity —
|
||||
the advisory path must not mask the gating one.
|
||||
|
||||
## Failure modes
|
||||
- **Missing translation for the active locale** → i18next falls back to `en`,
|
||||
@@ -213,10 +229,20 @@ Frontend module contract (new):
|
||||
persisted and document locale. Locale negotiation is case-insensitive, so
|
||||
BCP-47 input such as `zh-CN` resolves to `zh-cn`. The switcher label is the
|
||||
fixed endonym `简体中文`, not a translated message.
|
||||
- **Real-locale parity**: every committed real locale must contain every English
|
||||
namespace and exactly the same keys, including plural suffixes and `<Trans>`
|
||||
structures. Runtime English fallback remains a safety net, not a substitute
|
||||
for a complete checked-in catalog.
|
||||
- **Real-locale parity**: every committed real locale should contain every
|
||||
English namespace and exactly the same keys, including plural suffixes and
|
||||
`<Trans>` structures — but this is **advisory, not enforced**, because
|
||||
translation happens out of band (see the CI requirement above). Runtime English
|
||||
fallback is what makes an incomplete catalog safe to merge; it remains a safety
|
||||
net rather than a substitute for a complete checked-in catalog.
|
||||
- **The parity check is structural, not translational**: it compares shapes, so a
|
||||
green run does not mean "translated". It catches a missing/extra key or
|
||||
namespace, an empty or non-string value, a dropped `{{placeholder}}`, and a
|
||||
dropped or renumbered `<Trans>` tag. It does **not** catch a value identical to
|
||||
English, a catalog copy-pasted wholesale from `en`, or swapped `_one`/`_other`
|
||||
forms. The identical-to-English tolerance is required — brand nouns and
|
||||
technical literals must stay verbatim — so tightening it needs a per-key
|
||||
exemption list or a whole-file proportion threshold, not a `value === en` rule.
|
||||
- **Library**: react-i18next (superseding the initial Lingui implementation) —
|
||||
chosen so the English copy lives in one place and components stay free of
|
||||
inline source text.
|
||||
|
||||
Reference in New Issue
Block a user