171 lines
8.1 KiB
JavaScript
171 lines
8.1 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 catalog 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 catalog 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",
|
|
]);
|
|
/*
|
|
* A JSX attribute is shown whether it lands on an element or on a component:
|
|
* `<MenuItem label="Collapse all">` renders its label as given, exactly as
|
|
* `<button title="…">` does. Checking only the DOM spellings let every
|
|
* component prop through, so the props are checked here too.
|
|
*/
|
|
const UI_ATTRS = new Set(["title", "aria-label", "placeholder", "alt", ...UI_PROPS]);
|
|
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 catalog -- 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);
|
|
/*
|
|
* `strict` withdraws the catalog-key exemption. It exists for English held
|
|
* in a constant and translated where it renders; a literal written straight
|
|
* into a JSX attribute has no later render site to be translated at -- no
|
|
* component here passes its props through t() -- so being a key only means
|
|
* a translation exists that this string never reaches.
|
|
*/
|
|
const report = (node, text, strict = false) => {
|
|
if (!looksLikeUi(text) || (!strict && 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;
|
|
|
|
/*
|
|
* English assembled around values: `aria-label={`Remove ${email}`}`.
|
|
*
|
|
* The literal cannot be a catalog key as written, so whether it is a key is
|
|
* not asked. Neither is looksLikeUi, which reads the opening of a sentence:
|
|
* `${name} — shared by ${owner}` opens with a value and its words come after.
|
|
* Any run of letters between the values counts. The only template literals
|
|
* that reach a UI attribute and are not prose are pure punctuation around
|
|
* values, like `${name} (${size})`, and those have none.
|
|
*/
|
|
const reportTemplate = (x) => {
|
|
const parts = ts.isNoSubstitutionTemplateLiteral(x) ? [x.text] : [x.head.text, ...x.templateSpans.map((s) => s.literal.text)];
|
|
if (!/[A-Za-z]{2,}/.test(parts.join(""))) return;
|
|
const { line } = src.getLineAndCharacterOfPosition(x.getStart(src));
|
|
found.push({ file, line: line + 1, text: parts.join("{}") });
|
|
};
|
|
const isTemplate = (x) => ts.isTemplateExpression(x) || ts.isNoSubstitutionTemplateLiteral(x);
|
|
|
|
const visit = (n) => {
|
|
if (ts.isPropertyAssignment(n) && UI_PROPS.has(n.name.getText(src).replace(/['"]/g, ""))) {
|
|
if (ts.isStringLiteral(n.initializer) && !wrapped.has(n.initializer)) report(n.initializer, n.initializer.text);
|
|
if (isTemplate(n.initializer)) reportTemplate(n.initializer);
|
|
}
|
|
if (ts.isJsxAttribute(n) && n.initializer && UI_ATTRS.has(n.name.getText(src))) {
|
|
const walk = (x) => {
|
|
if (isTemplate(x)) reportTemplate(x);
|
|
if (ts.isStringLiteral(x) && !wrapped.has(x)) report(x, x.text, true);
|
|
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 catalog 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 catalog key");
|
|
process.exit(0);
|
|
}
|
|
console.log(`${found.length} user-visible string(s) the extractor cannot see and no catalog 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 catalog key.");
|
|
process.exit(process.argv.includes("--check") ? 1 : 0);
|