Files
ihasmail/scripts/i18n-coverage.mjs
jcoffey-dev d282813bc5 Give the i18n scripts a parser again
TypeScript 7 is the native port: the package ships a `tsc` shim over a Go
binary, and `typescript` now exports `version` and `versionMajorMinor` and no
compiler API. Every `ts.createSourceFile` in scripts/ has been throwing
"Cannot read properties of undefined (reading 'Latest')" since the 5.9.3 → 7.0.2
bump -- four of the five i18n scripts dead, only i18n-extract still running.

Nothing noticed because no workflow runs them. The catalogue gate for nine
languages has been dark, and the only signal was running it by hand.

There is no official TS7 API package (@typescript/ast and @typescript/api are
both 404), and the alternative was rewriting 493 lines and 25 distinct AST
calls, including the JSX guards, against a different tree -- in tooling with no
tests of its own. So `typescript-ast` is an npm alias for the last TypeScript
carrying the JS API. It parses; `typescript` still type-checks and builds. Two
entries, two jobs, said so in each script so the next reader does not delete
one as a leftover.

What the gate says now it can speak: catalogues are green and coverage is 100%.
The "16 falling back to English" it reports in every locale are placeholders,
example domains, a product name, a licence id and the quote glyph -- strings
that should stay English. The 41 stale keys per locale are real dead weight and
are left for their own change.
2026-09-10 14:21:42 -07:00

87 lines
4.5 KiB
JavaScript
Executable File

#!/usr/bin/env node
/*
* How much of the interface is extracted, and what is left.
*
* Extraction is ~1,000 strings across ~56 files, which is far too many to
* carry in anyone's head or to eyeball in review. This counts what is still
* hardcoded so the work can be done a file at a time and the remainder is
* always a number rather than a feeling.
*
* It is a progress report, not a gate: run it, do a file, run it again. It
* exits non-zero only with --check, so CI can be told to fail on regressions
* later, once the number is low enough for that to mean something.
*/
/*
* 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";
/** Attributes a person reads. `className` and `key` are not among them. */
const ATTRS = new Set(["title", "aria-label", "placeholder", "alt", "label", "hint", "confirmLabel", "message", "description"]);
/* Text that is not prose: punctuation, separators, and the single glyphs used
as dividers. Counting these as untranslated would put a floor under the
number that no amount of work could reach. */
const NOT_PROSE = /^[\s·—–\-—:;,.()[\]{}/|+×✓~<>#*@0-9]*$/u;
/*
* Text that is deliberately not translated is not "remaining work". Counting
* it put a floor under the number that no amount of effort could reach -- the
* report sat at 21 with only 6 real items left, which makes the number
* something to argue with rather than act on. Same rule the codemod uses.
*/
const CODE_TAGS = new Set(["code", "kbd", "pre", "samp", "var"]);
const optedOut = (node, src) => {
const opening = ts.isJsxElement(node) ? node.openingElement : ts.isJsxSelfClosingElement(node) ? node : null;
return Boolean(opening?.attributes.properties.some((a) =>
ts.isJsxAttribute(a) && a.name.getText(src) === "translate" &&
a.initializer && ts.isStringLiteral(a.initializer) && a.initializer.text === "no"));
};
const files = globSync("web/src/**/*.tsx").filter((f) => !f.includes("__tests__"));
const rows = [];
let done = 0, todo = 0;
for (const file of files) {
const text = readFileSync(file, "utf8");
const src = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
let left = 0;
const wrapped = (text.match(/\bt\(\s*["'`]/g) || []).length + (text.match(/\bplural\(/g) || []).length;
const visit = (node) => {
if ((ts.isJsxElement(node) && CODE_TAGS.has(node.openingElement.tagName.getText(src).toLowerCase())) || optedOut(node, src)) return;
if (ts.isJsxText(node) && node.text.trim().length > 1 && !NOT_PROSE.test(node.text.trim())) left++;
if (ts.isJsxAttribute(node) && ATTRS.has(node.name.getText(src))) {
const i = node.initializer;
const lit = i && (ts.isStringLiteral(i) ? i : ts.isJsxExpression(i) && i.expression && ts.isStringLiteral(i.expression) ? i.expression : null);
// The same prose test the text nodes get. Without it, placeholders that
// are format examples -- "123456" for a one-time code, "+1 555 0100" for
// a phone -- counted as untranslated work forever.
if (lit && lit.text.trim().length > 1 && !NOT_PROSE.test(lit.text.trim())) left++;
}
ts.forEachChild(node, visit);
};
visit(src);
done += wrapped;
todo += left;
if (left) rows.push([file.replace("web/src/", ""), left, wrapped]);
}
rows.sort((a, b) => b[1] - a[1]);
const pct = done + todo === 0 ? 100 : Math.round((done / (done + todo)) * 100);
console.log(`i18n extraction: ${done} wrapped, ${todo} remaining across ${rows.length} files (${pct}%)\n`);
for (const [f, left, w] of rows.slice(0, Number(process.argv.find((a) => a.startsWith("--top="))?.slice(6) ?? 15))) {
console.log(` ${String(left).padStart(4)} left${w ? `, ${w} done` : " "} ${f}`);
}
if (rows.length > 15 && !process.argv.includes("--all")) console.log(`\n …and ${rows.length - 15} more (--all, or --top=N)`);
if (process.argv.includes("--check") && todo > 0) process.exit(1);