Files
ihasmail-inbuxa/scripts/i18n-catalog-check.mjs
T

156 lines
7.7 KiB
JavaScript

#!/usr/bin/env node
/*
* Check a catalog against the strings the code actually asks for.
*
* Two failures, and only one of them is visible without this.
*
* A *missing* key renders English. That is the designed fallback and shows up
* as an untranslated word on screen, which somebody will eventually notice.
*
* A *stale* key -- one whose English no longer exists, usually because it was
* mistyped when the catalog was written -- is silent. The translation sits
* in the file looking correct, is never looked up, and the app renders English
* for ever. Nothing warns, because a catalog is only ever read by key.
*/
/*
* The parser, not the compiler.
*
* TypeScript 7 is the native port: its package ships a `tsc` shim over a Go
* binary and nothing else, so `typescript` now exports `version` and
* `versionMajorMinor` and no compiler API at all. Every `ts.createSourceFile`
* in this directory started throwing "Cannot read properties of undefined
* (reading 'Latest')" the day the bump landed, and nothing noticed, because no
* workflow runs these.
*
* `typescript-ast` is an npm alias for the last TypeScript that carries the JS
* API (see package.json). It parses; `typescript` still type-checks and builds.
* Two entries, two jobs -- not a version someone forgot to remove.
*/
import ts from "typescript-ast";
import { readFileSync, globSync } from "node:fs";
/*
* Two sets, because there are two questions and they need different nets.
*
* `wanted` is what a catalog *owes*: the strings that actually reach t(),
* tc() or plural(). Coverage is measured against it, so it has to stay strict
* -- widening it would count every CSS class and JMAP method name as an
* untranslated string.
*
* `seen` is every string literal in the source, and answers only "is this
* catalog key still written down anywhere". Stale detection needs the wide
* net: a key reaches t() as a variable often enough that a strict set reports
* mostly false alarms.
*/
const wanted = new Set();
const seen = new Set();
for (const file of globSync("web/src/**/*.{ts,tsx}").filter((f) => !f.includes("__tests__") && !f.includes("/locales/"))) {
const src = ts.createSourceFile(file, readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
const visit = (n) => {
/*
* Anything held in a constant and translated where it renders -- t(s.label),
* t(b.description), t(group) -- reaches t() as a variable, so there is no
* literal at the call site and every one of them looked "stale".
*
* This used to chase the shapes one at a time: a `label:` property, then an
* object named *_LABELS. It still cried wolf, because the shapes kept
* coming -- `description:` and `group:` on keyboard bindings, the calendar's
* view names, the read-receipt refusals, the palette names. 41 reported,
* 10 of them real. A report that is three-quarters false is one nobody acts
* on, which is how these sat unread long enough to be worth a commit of
* their own.
*
* So: any string literal anywhere in the source counts as a use. That
* under-reports -- a literal that exists but is never passed to t() will not
* be flagged -- and that is the right way round. A missed stale key costs a
* line of dead translation; a false one costs the credibility of the whole
* check, and then every real finding with it.
*/
if (ts.isStringLiteral(n) || ts.isNoSubstitutionTemplateLiteral(n)) seen.add(n.text);
if (ts.isJsxText(n)) { const text = n.text.trim(); if (text) seen.add(text); }
/*
* A `label:` in a constant is still a string somebody has to translate --
* it reaches t() one render later -- so it stays part of what a catalog
* owes, and out of coverage it would flatter the number.
*/
if (ts.isPropertyAssignment(n) && n.name.getText(src) === "label" && ts.isStringLiteral(n.initializer)) wanted.add(n.initializer.text);
if (ts.isVariableDeclaration(n) && ts.isIdentifier(n.name) && /_LABELS?$/.test(n.name.text)) {
const walk = (x) => { if (ts.isStringLiteral(x)) wanted.add(x.text); ts.forEachChild(x, walk); };
if (n.initializer) walk(n.initializer);
}
if (ts.isCallExpression(n) && ts.isIdentifier(n.expression)) {
const fn = n.expression.text, a0 = n.arguments[0];
if ((fn === "t" || fn === "translate" || fn === "tNode") && a0 && ts.isStringLiteral(a0)) wanted.add(a0.text);
// tc(context, source) keys the catalog on both, joined by the same
// control character tc() uses. Without this the contextual entries all
// looked stale, which is the checker's own false alarm rather than a
// catalog problem.
if (fn === "tc" && a0 && ts.isStringLiteral(a0) && n.arguments[1] && ts.isStringLiteral(n.arguments[1])) {
// Only the contextual key is required. The plain one is tc()'s
// fallback, not a second obligation -- asking for both would report
// work that does not exist.
wanted.add(`${a0.text}\u0004${n.arguments[1].text}`);
seen.add(`${a0.text}\u0004${n.arguments[1].text}`);
}
if (fn === "plural" && n.arguments[1] && ts.isObjectLiteralExpression(n.arguments[1])) {
for (const p of n.arguments[1].properties) {
if (ts.isPropertyAssignment(p) && p.name.getText(src) === "other" && ts.isStringLiteral(p.initializer)) wanted.add(p.initializer.text);
}
}
}
ts.forEachChild(n, visit);
};
visit(src);
}
/*
* A catalog and a picker entry are two halves of one thing, and either half
* alone is dead weight. A catalog with no entry in UI_LANGUAGES never
* reaches a reader -- it builds, it passes every test, and the language simply
* is not offered. That happened to Dutch: the entry was added by a text
* replacement anchored on a line that did not exist on that branch, so it was
* a silent no-op and nothing anywhere complained.
*/
const languagesSrc = readFileSync("web/src/lib/languages.ts", "utf8");
const registered = new Set([...languagesSrc.matchAll(/tag:\s*"([\w-]+)"/g)].map((m) => m[1]));
const catalogs = new Set(globSync("web/src/locales/*.ts").map((f) => f.split("/").pop().replace(".ts", "")));
let failed = false;
for (const tag of catalogs) {
if (!registered.has(tag)) {
failed = true;
console.log(`!! ${tag}.ts exists but is not in UI_LANGUAGES — the language is never offered\n`);
}
}
for (const tag of registered) {
if (tag !== "en" && !catalogs.has(tag)) {
failed = true;
console.log(`!! UI_LANGUAGES offers ${tag} but there is no ${tag}.ts — it would fall back to English\n`);
}
}
for (const file of globSync("web/src/locales/*.ts")) {
const tag = file.split("/").pop().replace(".ts", "");
const src = ts.createSourceFile(file, readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
const have = new Set();
const visit = (n) => {
if (ts.isPropertyAssignment(n) && ts.isStringLiteral(n.name)) have.add(n.name.text);
ts.forEachChild(n, visit);
};
visit(src);
const stale = [...have].filter((k) => !seen.has(k) && !["one", "other", "few", "many", "zero", "two"].includes(k));
const missing = [...wanted].filter((k) => !have.has(k));
const pct = Math.round(((wanted.size - missing.length) / wanted.size) * 100);
console.log(`${tag}: ${wanted.size - missing.length}/${wanted.size} translated (${pct}%), ${missing.length} falling back to English`);
if (stale.length) {
failed = true;
console.log(`\n ${stale.length} STALE key(s) — translated but never looked up, so they do nothing:`);
for (const k of stale) console.log(` ${JSON.stringify(k)}`);
}
if (process.argv.includes("--missing")) {
console.log(`\n missing:`);
for (const k of missing) console.log(` ${JSON.stringify(k)}`);
}
}
if (failed && process.argv.includes("--check")) process.exit(1);