Files
kandev/apps/web/scripts/check-i18n-keys.mjs
T
2026-08-05 11:22:36 +02:00

197 lines
8.0 KiB
JavaScript

#!/usr/bin/env node
/**
* Verify translation keys and catalogs agree.
*
* With key-based i18next the English copy lives ONLY in `src/locales/en/*.json`
* — it cannot be regenerated from source the way Lingui's source-text keys
* could. So the CI gate is a DRIFT check, not a re-extraction:
*
* - every `t("ns:key")` / `<Trans i18nKey="ns:key">` in source has a catalog
* 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]
*/
import fs from "node:fs";
import path from "node:path";
import {
discoverRealLocales,
formatParityIssue,
readLocaleNamespaces,
realLocaleParityIssues,
} from "./lib/i18n-catalogs.mjs";
const ROOT = path.resolve(import.meta.dirname, "..");
const LOCALES = path.join(ROOT, "src", "locales");
const STRICT_ORPHANS = process.argv.includes("--strict-orphans");
function readCatalog(locale) {
const out = new Map(); // "ns:key" -> value
for (const [namespace, messages] of readLocaleNamespaces(LOCALES, locale)) {
for (const [key, value] of messages) out.set(`${namespace}:${key}`, value);
}
return out;
}
function sourceFiles() {
const out = [];
const walk = (dir) => {
if (!fs.existsSync(dir)) return;
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, e.name);
if (e.isDirectory()) {
if (["node_modules", "dist", "e2e", "locales"].includes(e.name)) continue;
walk(full);
} else if (/\.(tsx?|mts)$/.test(e.name) && !/\.test\.tsx?$/.test(e.name)) {
out.push(full);
}
}
};
for (const r of ["components", "app", "lib", "src", "hooks"]) walk(path.join(ROOT, r));
return out;
}
const en = readCatalog("en");
// Namespaces that actually exist, so a bare "foo:bar" string elsewhere in the
// code isn't mistaken for a translation key.
const NAMESPACES = new Set([...en.keys()].map((k) => k.split(":")[0]));
// Direct call sites: t("ns:key"), globalT("ns:key"), i18nKey="ns:key".
const CALL_RE = /(?:\bt\(|\bglobalT\(|i18nKey=)\s*["']([a-zA-Z0-9_]+:[a-zA-Z0-9_.-]+)["']/g;
// Keys held in constants/tables and resolved dynamically later (`t(item.label)`),
// which is how the former Lingui `msg` descriptors were converted.
const LITERAL_RE = /["']([a-zA-Z0-9_]+:[a-zA-Z0-9_.-]+)["']/g;
const used = new Map(); // key -> Set(file); drives the "missing key" check
const referencedLiterals = new Set(); // only clears orphans, never reports missing
const note = (key, file) => {
if (!used.has(key)) used.set(key, new Set());
used.get(key).add(path.relative(ROOT, file));
};
for (const file of sourceFiles()) {
const code = fs.readFileSync(file, "utf8");
for (const m of code.matchAll(CALL_RE)) note(m[1], file);
// A bare "ns:key" literal is ambiguous — testids and storage keys share the
// shape — so it may only mark a catalog entry as used, never demand one.
for (const m of code.matchAll(LITERAL_RE)) {
if (NAMESPACES.has(m[1].split(":")[0])) referencedLiterals.add(m[1]);
}
}
const pseudo = readCatalog("pseudo");
/** Plural keys live as `key_one` / `key_other`; the source references `key`. */
function isSatisfied(catalog, key) {
if (catalog.has(key)) return true;
return catalog.has(`${key}_one`) || catalog.has(`${key}_other`);
}
const missing = [...used.keys()].filter((k) => !isSatisfied(en, k)).sort();
const pluralBases = new Set(
[...en.keys()].filter((k) => /_(one|other)$/.test(k)).map((k) => k.replace(/_(one|other)$/, "")),
);
const orphans = [...en.keys()]
.filter((k) => {
const base = k.replace(/_(one|other)$/, "");
if (used.has(k) || referencedLiterals.has(k)) return false;
return !(pluralBases.has(base) && (used.has(base) || referencedLiterals.has(base)));
})
.sort();
const enKeys = new Set(en.keys());
const pseudoKeys = new Set(pseudo.keys());
const pseudoMissing = [...enKeys].filter((k) => !pseudoKeys.has(k));
const pseudoExtra = [...pseudoKeys].filter((k) => !enKeys.has(k));
const sourceNamespaces = readLocaleNamespaces(LOCALES, "en");
const realLocales = discoverRealLocales(LOCALES);
const realLocaleIssues = realLocales.flatMap((locale) =>
realLocaleParityIssues(sourceNamespaces, readLocaleNamespaces(LOCALES, locale), locale),
);
let failed = false;
if (missing.length) {
failed = true;
console.error(`\n✖ ${missing.length} key(s) used in source but missing from the en catalog:`);
for (const k of missing.slice(0, 40)) {
console.error(` ${k} (${[...used.get(k)].slice(0, 2).join(", ")})`);
}
if (missing.length > 40) console.error(` … and ${missing.length - 40} more`);
}
if (pseudoMissing.length || pseudoExtra.length) {
failed = true;
console.error(
`\n✖ pseudo catalog is out of sync with en ` +
`(${pseudoMissing.length} missing, ${pseudoExtra.length} extra).` +
`\n Run: pnpm run i18n:pseudo`,
);
}
// 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) {
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) {
const label = STRICT_ORPHANS ? "✖" : "⚠";
console[STRICT_ORPHANS ? "error" : "warn"](
`\n${label} ${orphans.length} catalog entr(ies) not referenced in source:`,
);
for (const k of orphans.slice(0, 20)) console.warn(` ${k}`);
if (orphans.length > 20) console.warn(` … and ${orphans.length - 20} more`);
if (STRICT_ORPHANS) failed = true;
}
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 in sync.${advisory}`,
);
}
process.exit(failed ? 1 : 0);