Files
ihasmail-inbuxa/scripts/i18n-literals.mjs
T
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

139 lines
6.3 KiB
JavaScript

#!/usr/bin/env node
/*
* User-visible English the extraction pass cannot see.
*
* `i18n:coverage` reads JSX text, and reported 100% while the calendar's
* Day/Week/Month/Agenda buttons rendered English in all nine languages. It was
* not wrong about what it measured -- those labels were never JSX text. They
* were built from an expression, and so was every toast argument, every
* `confirmDialog({ title })`, and every `Could not save: ${err}`.
*
* A string reaches a reader translated if either is true:
*
* 1. it is wrapped where it is written -- t(), tc(), tNode(), plural()
* 2. it is a catalogue key, translated somewhere else
*
* The second case is a real convention here, not a loophole: constant tables
* hold English and the render site calls `t(s.label)`. What this refuses is a
* string that is neither -- one no catalogue has a key for, which therefore
* cannot be translated at all, however many languages ship.
*/
/*
* 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";
/* Where a string literal in this position is shown to somebody. */
const UI_PROPS = new Set([
"title", "message", "label", "confirmLabel", "cancelLabel", "ariaLabel",
"placeholder", "hint", "occurrenceLabel", "occurrenceHint", "seriesLabel", "seriesHint",
]);
const UI_ATTRS = new Set(["title", "aria-label", "placeholder", "alt"]);
const TOASTS = new Set(["error", "success", "info", "show"]);
const WRAPPERS = ["t", "tc", "tNode", "translate", "plural"];
const EQUALITY = new Set([
ts.SyntaxKind.EqualsEqualsEqualsToken, ts.SyntaxKind.ExclamationEqualsEqualsToken,
ts.SyntaxKind.EqualsEqualsToken, ts.SyntaxKind.ExclamationEqualsToken,
]);
/*
* Product names, example addresses and URL scaffolding. These reach t() and
* are deliberately absent from every catalogue -- translating "ihasmail" or
* "[email protected]" would be a bug, not a feature -- so they would otherwise
* be reported for ever.
*/
const NEVER_TRANSLATED = new Set([
"ihasmail", "ihasmail.org", "ihasmail test", "Stalwart", "Stalwart Mail Server",
"AGPL-3.0-or-later · {source}", "•••", "https://", "https://…",
"https://meet.example.com/…", "[email protected]", "[email protected]",
"[email protected]", "List-Id", "X-Spam-Status",
]);
/* Prose, not an identifier: opens like a sentence, and has lower-case letters. */
const looksLikeUi = (s) =>
/[a-z]/.test(s) && /^[A-Z(“]/.test(s) && (/\s/.test(s) || /[.?!…]$/.test(s));
const keys = new Set();
{
const src = readFileSync("web/src/locales/de.ts", "utf8");
for (const m of src.matchAll(/^\s{4}"((?:[^"\\]|\\.)*)":/gm)) keys.add(m[1].replace("\\u0004", ""));
}
const found = [];
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 report = (node, text) => {
if (!looksLikeUi(text) || keys.has(text) || NEVER_TRANSLATED.has(text)) return;
const { line } = src.getLineAndCharacterOfPosition(node.getStart(src));
found.push({ file, line: line + 1, text });
};
/*
* Literals that are not text on their way to a reader.
*
* Two kinds. One is already inside t("...") -- walking into the call would
* report the very string that proves it is handled. The other is an operand
* of an equality test: `rule.name === "New filter"` compares against a
* sentinel stored in the Sieve script, and translating it would not change
* what a reader sees, it would break the comparison.
*/
const exempt = new Set();
const mark = (n) => {
if (ts.isCallExpression(n) && ts.isIdentifier(n.expression) && WRAPPERS.includes(n.expression.text)) {
const walk = (x) => { if (ts.isStringLiteral(x)) exempt.add(x); ts.forEachChild(x, walk); };
for (const a of n.arguments) walk(a);
}
if (ts.isBinaryExpression(n) && EQUALITY.has(n.operatorToken.kind)) {
for (const side of [n.left, n.right]) if (ts.isStringLiteral(side)) exempt.add(side);
}
ts.forEachChild(n, mark);
};
mark(src);
const wrapped = exempt;
const visit = (n) => {
if (ts.isPropertyAssignment(n) && ts.isStringLiteral(n.initializer) && !wrapped.has(n.initializer)
&& UI_PROPS.has(n.name.getText(src).replace(/['"]/g, ""))) {
report(n.initializer, n.initializer.text);
}
if (ts.isJsxAttribute(n) && n.initializer && UI_ATTRS.has(n.name.getText(src))) {
const walk = (x) => {
if (ts.isStringLiteral(x) && !wrapped.has(x)) report(x, x.text);
if (!ts.isCallExpression(x)) ts.forEachChild(x, walk);
};
walk(n.initializer);
}
if (ts.isCallExpression(n) && ts.isPropertyAccessExpression(n.expression)
&& n.expression.expression.getText(src) === "toast" && TOASTS.has(n.expression.name.text)) {
const a0 = n.arguments[0];
if (a0 && ts.isStringLiteral(a0) && !wrapped.has(a0)) report(a0, a0.text);
/* A template literal cannot be a catalogue key at all, so it is always a find. */
if (a0 && ts.isTemplateExpression(a0)) report(a0, a0.head.text + "{}");
}
ts.forEachChild(n, visit);
};
visit(src);
}
if (!found.length) {
console.log("i18n literals: none -- every user-visible string is wrapped or has a catalogue key");
process.exit(0);
}
console.log(`${found.length} user-visible string(s) the extractor cannot see and no catalogue can translate:\n`);
for (const f of found) console.log(` ${f.file}:${f.line}\n ${JSON.stringify(f.text)}`);
console.log("\nWrap them in t() / plural(), or -- for a label held in a constant and");
console.log("translated where it renders -- make sure the English is a catalogue key.");
process.exit(process.argv.includes("--check") ? 1 : 0);