Start extraction: an i18n core, and a way to see how far it has got
The groundwork in #145 gave the app a language to serve. This gives it something to serve, and a way to measure the distance to the languages actually planned. The English text is the key. `t("Archive")` looks "Archive" up and returns the English when it is not there, which buys three things worth more than tidy symbolic keys: no English catalogue to keep in step with the code, a missing translation that degrades to readable English rather than to `mail.list.archive`, and an extraction step that is wrapping a string rather than inventing a name for it. Names are where extraction stalls, and 55 components is a lot of small naming arguments. The cost is that editing English copy orphans its translations, which is the right way round: the copy is the product, and a stale German sentence should fall back to the new English. `plural()` takes forms rather than (one, other), because two forms is an English assumption that does not survive phase two of the plan. Russian and Ukrainian need three, and choosing between them is not a question about the number 1. Intl.PluralRules knows the rule for every language the browser knows, so the catalogue supplies the forms and the runtime picks; a category the catalogue does not carry falls back to `other` rather than rendering undefined. Interpolation is named rather than positional for the same reason -- German moves the parts of a sentence around and means the same thing. Catalogues are dynamically imported, so a reader who never leaves English never downloads one, and English needs no fetch at all. `applyLang` sets the lang attribute before kicking the load, deliberately: lang is what stops Chrome offering to translate and should not wait on a network request to say something it already knows. `t()` is a plain function, not a hook, so the tree is keyed on a language version at the root and thrown away when the catalogue changes. Making every call site a subscriber would turn extracting a string from "wrap it" into "wrap it and add a hook", for an event that happens about once per account. NotificationsSettings is extracted end to end as the reference -- it covers all four shapes, being JSX text, translated attributes, a toast, and a sentence with a value interpolated into it. scripts/i18n-coverage.mjs counts what is left, because ~1,000 strings across 56 files is too many to eyeball in review or carry in anyone's head. It reports 20 wrapped and 925 remaining, and it deliberately does not count punctuation and separators as untranslated -- a floor no amount of work could reach would make the number useless. A progress report rather than a gate: --check exits non-zero, for once the number is low enough for that to mean something. ROADMAP.md said translations were "English-only for now" on a page whose stated purpose is things the answer is "no" to. It now says what is actually happening, carries the phase order, and says why Arabic, Hebrew and Persian are on neither list: RTL is a layout and bidi problem rather than a longer catalogue, and shipping it as though it were the same kind of work is how an RTL build ends up unusable with nobody saying so.
This commit is contained in:
Executable
+55
@@ -0,0 +1,55 @@
|
||||
#!/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.
|
||||
*/
|
||||
import ts from "typescript";
|
||||
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;
|
||||
|
||||
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.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);
|
||||
if (lit && lit.text.trim().length > 1) 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);
|
||||
Reference in New Issue
Block a user