Files
ServerKit/frontend/scripts/extract-i18n.mjs
T
Juan DenisandClaude Opus 5 7de4d10eda feat(i18n): codemod for the long tail; census fixes; Intl component options
Built to convert the WordPress extension (1,099 strings across 44 files) and
kept because the same job remains for 12 more extension repos and the panel's
own 8,034-literal tail.

i18n-codemod.mjs keys the same four shapes the census counts, using the same
isCopy rule, so what it converts is exactly what the ratchet measures. It
REFUSES rather than guesses: a hit outside a component needs `t` threaded
through a helper's arguments, which is a design decision, not a rewrite.
Five bugs it had, all caught by building the result rather than by reading it:

- multi-line JSX text became a string literal containing raw newlines. JSX
  collapses that whitespace at render, so the default must too.
- a literal already containing \n (an SSH key placeholder) was emitted raw and
  produced an unterminated string.
- `confirm({ title: 'x' })` matched BOTH the data-object rule and the copy-call
  rule; applying both corrupted the file. Innermost edit wins -- for a call,
  t() is right because nothing resolves a `titleKey` there.
- copy hides in `err.message || 'Failed…'` and `x ? 'A' : 'B'` as often as it
  sits directly in a call, so argument recursion matches the census.
- that recursion then had to stop at nested JSX, or it "translated" a
  `rel="noopener noreferrer"`.

Two census corrections, both of which were inflating the numbers:

- check-i18n-literals recursed into nested JSX inside a copy prop and counted
  the nested element's own attributes. `rel` is not copy; the ratchet was
  unreachable on correct code.
- check-intl-door walked dist/, so an extension repo's bundle double-counted
  every call in it. WordPress reported 56 where the source had 28.

Both censuses and the extractor now take --root (and --out) so an extension
repo runs the panel's own checks instead of copying them -- copying is how the
vendor shims drifted. --update refuses a foreign root: those ceilings are the
panel's.

The format door takes `parts`, passing Intl.DateTimeFormat component options
through for what a named style cannot express -- chart ticks, month labels.
That was the actual reason callers reached for toLocaleDateString([], {...}),
which silently follows the browser; now they keep the format and the locale.

Ceilings re-seeded to the corrected numbers: literals 8034, intl door 123.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 08:50:13 -04:00

313 lines
12 KiB
JavaScript

#!/usr/bin/env node
// Plan 79 D1 — generate src/i18n/locales/en.json from the source tree.
//
// The inline English default IS the source of truth:
//
// t('apps.empty.title', 'No applications yet')
//
// en.json is generated from those defaults and must never be hand-edited. Two
// properties follow from that, and they are what make a migration of this size
// survivable:
//
// * A key that has not been extracted yet still renders correct English, so
// a half-migrated page is never broken and never shows a raw key path to a
// user.
// * Renaming the English copy is a one-place edit in the component; the
// translations for other locales stay keyed and are visibly stale rather
// than silently wrong.
//
// Usage (from frontend/):
// node scripts/extract-i18n.mjs # write en.json
// node scripts/extract-i18n.mjs --check # fail if en.json is out of date
import { readFileSync, readdirSync, writeFileSync } from 'node:fs';
import { dirname, extname, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parse } from 'espree';
const here = dirname(fileURLToPath(import.meta.url));
const root = resolve(here, '..');
// `--root <dir> --out <file>` extracts another tree — an extension repo's own
// source into its own bundle. Extensions carry inline English defaults exactly
// like core, so they get en.json generated the same way rather than a second
// extractor that would drift from this one.
const argOf = (name) => (process.argv.includes(name)
? process.argv[process.argv.indexOf(name) + 1]
: null);
const rootArg = argOf('--root');
const outArg = argOf('--out');
const srcDir = rootArg ? resolve(process.cwd(), rootArg) : resolve(root, 'src');
const outFile = outArg
? resolve(process.cwd(), outArg)
: resolve(srcDir, 'i18n', 'locales', 'en.json');
const SKIP_DIRS = new Set(['node_modules', '__mocks__']);
const SKIP_FILE = /\.(test|spec|stories)\.[jt]sx?$/;
function walk(dir) {
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const path = join(dir, entry.name);
if (entry.isDirectory()) return SKIP_DIRS.has(entry.name) ? [] : walk(path);
if (!['.js', '.jsx'].includes(extname(entry.name))) return [];
if (SKIP_FILE.test(entry.name)) return [];
return [path];
});
}
// Bare `t(...)` only -- the `const { t } = useTranslation()` idiom.
//
// Deliberately NOT `<anything>.t(...)`: `i18next.t(key, ...)` inside the
// format door re-dispatches a key it was handed, so its argument is a variable
// by construction. Flagging that would make the door unextractable while the
// literal keys at its own call sites are right there to collect.
function isTranslationCall(node) {
return node.type === 'CallExpression'
&& node.callee.type === 'Identifier'
&& node.callee.name === 't';
}
/**
* Is SOME default declared? For a computed key the default is computed too
* (`t(item.labelKey, { defaultValue: item.label })`), so it cannot be
* extracted — but its presence is still what stops a missing bundle rendering
* the raw key path to a user, and that is what this gate is for.
*/
function hasDefault(node) {
const [, second, third] = node.arguments;
if (second?.type === 'Literal' && typeof second.value === 'string') return true;
for (const candidate of [second, third]) {
if (candidate?.type !== 'ObjectExpression') continue;
for (const property of candidate.properties) {
if (property.type !== 'Property' || property.computed) continue;
if ((property.key.name || property.key.value) === 'defaultValue') return true;
}
}
return false;
}
/** The English default declared at a call site, or null. */
function defaultValueOf(node) {
const [, second, third] = node.arguments;
if (second?.type === 'Literal' && typeof second.value === 'string') return second.value;
for (const candidate of [second, third]) {
if (candidate?.type !== 'ObjectExpression') continue;
for (const property of candidate.properties) {
if (property.type !== 'Property' || property.computed) continue;
const key = property.key.name || property.key.value;
if (key === 'defaultValue' && property.value.type === 'Literal') {
return String(property.value.value);
}
}
}
return null;
}
// Declarative keys in data files.
//
// A label sitting in a data table (sidebarItems.js, tab tables, column defs)
// is still copy, but it cannot be a `t()` call: resolving at module load would
// translate it once, at import, and never again on a locale switch. So the
// data declares the pair
//
// { labelKey: 'nav.dashboard', label: 'Dashboard' }
//
// and the renderer resolves `t(item.labelKey, item.label)` at render time.
// This collects the pair at its declaration site, the only place both halves
// are literal.
// A dotted, lowercase-namespaced path: `nav.dashboard`, `common.actions.save`.
const TRANSLATION_KEY = /^[a-z][a-zA-Z0-9]*(\.[a-zA-Z0-9_]+)+$/;
function declarativePairs(node) {
if (node.type !== 'ObjectExpression') return null;
const literals = new Map();
for (const property of node.properties) {
if (property.type !== 'Property' || property.computed) continue;
const name = property.key.name || property.key.value;
if (property.value?.type === 'Literal' && typeof property.value.value === 'string') {
literals.set(name, property.value.value);
}
}
const pairs = [];
for (const [name, value] of literals) {
if (!name.endsWith('Key')) continue;
const sibling = name.slice(0, -3);
// The value must LOOK like a translation key. `Key` already means
// "identifier" in several places -- `archKey: 'amd64'` next to
// `arch: 'x64 (amd64)'` is a CPU architecture, not copy, and a looser
// rule silently pulled it into en.json.
if (!TRANSLATION_KEY.test(value)) continue;
if (literals.has(sibling)) pairs.push({ key: value, value: literals.get(sibling) });
}
return pairs.length ? pairs : null;
}
// <Trans i18nKey="..." defaults="Type <0>{{value}}</0> to confirm:" />
//
// A sentence with an element inside it cannot be a plain `t()` call, and it
// must not be split into prefix/suffix keys either: word order differs by
// language, and a split sentence cannot be reordered by a translator.
function transPair(node) {
if (node.type !== 'JSXElement') return null;
const name = node.openingElement?.name;
if (name?.type !== 'JSXIdentifier' || name.name !== 'Trans') return null;
const literals = new Map();
for (const attribute of node.openingElement.attributes) {
if (attribute.type !== 'JSXAttribute') continue;
if (attribute.value?.type === 'Literal' && typeof attribute.value.value === 'string') {
literals.set(attribute.name.name, attribute.value.value);
}
}
const key = literals.get('i18nKey');
const value = literals.get('defaults');
return key && value ? { key, value } : null;
}
function visit(node, onCall, onPair) {
if (!node || typeof node !== 'object') return;
if (Array.isArray(node)) {
for (const child of node) visit(child, onCall, onPair);
return;
}
if (!node.type) return;
if (isTranslationCall(node)) onCall(node);
const pairs = declarativePairs(node);
if (pairs) for (const pair of pairs) onPair(pair, node);
const trans = transPair(node);
if (trans) onPair(trans, node);
for (const key of Object.keys(node)) {
if (key === 'type' || key === 'loc' || key === 'range') continue;
visit(node[key], onCall, onPair);
}
}
export function collect() {
const entries = new Map(); // key -> { value, sites: [] }
const problems = [];
for (const path of walk(srcDir)) {
const rel = relative(root, path).replaceAll('\\', '/');
let ast;
try {
ast = parse(readFileSync(path, 'utf8'), {
ecmaVersion: 'latest',
sourceType: 'module',
ecmaFeatures: { jsx: true },
loc: true,
});
} catch (error) {
problems.push(`${rel}: parse failed — ${error.message}`);
continue;
}
const record = (key, value, site) => {
const existing = entries.get(key);
if (existing && existing.value !== value) {
problems.push(
`${site}: key '${key}' redefines the English default\n`
+ ` here: ${JSON.stringify(value)}\n`
+ ` there: ${JSON.stringify(existing.value)} (${existing.sites[0]})`,
);
return;
}
if (existing) {
existing.sites.push(site);
return;
}
entries.set(key, { value, sites: [site] });
};
visit(
ast,
(node) => {
const first = node.arguments[0];
const site = `${rel}:${node.loc.start.line}`;
const value = defaultValueOf(node);
if (first?.type !== 'Literal' || typeof first.value !== 'string') {
// A computed key is legitimate when the pair was declared
// in data (see declarativePair) and this site only resolves
// it -- but only if a default rides along, or a missing
// bundle renders the raw key path to a user.
if (!hasDefault(node)) {
problems.push(`${site}: t() has a computed key and no defaultValue`);
}
return;
}
if (value === null) {
problems.push(`${site}: t('${first.value}') has no inline English default`);
return;
}
record(first.value, value, site);
},
(pair, node) => record(pair.key, pair.value, `${rel}:${node.loc.start.line}`),
);
}
return { entries, problems };
}
/** Dotted keys into the nested shape the locale files use. */
export function nest(entries) {
const tree = {};
const problems = [];
for (const key of [...entries.keys()].sort()) {
const parts = key.split('.');
let node = tree;
let ok = true;
for (const part of parts.slice(0, -1)) {
if (typeof node[part] === 'string') {
problems.push(`key '${key}' nests under '${part}', which is already a string`);
ok = false;
break;
}
node[part] = node[part] || {};
node = node[part];
}
if (!ok) continue;
const leaf = parts[parts.length - 1];
if (node[leaf] && typeof node[leaf] === 'object') {
problems.push(`key '${key}' is both a string and a namespace`);
continue;
}
node[leaf] = entries.get(key).value;
}
return { tree, problems };
}
const { entries, problems: collectProblems } = collect();
const { tree, problems: nestProblems } = nest(entries);
const problems = [...collectProblems, ...nestProblems];
if (problems.length) {
console.error('\ni18n extraction failed:\n');
for (const problem of problems) console.error(` ${problem}`);
console.error('\n Every t() needs a literal key and an inline English default:');
console.error(" t('apps.empty.title', 'No applications yet')\n");
process.exit(1);
}
const serialized = `${JSON.stringify(tree, null, 4)}\n`;
if (process.argv.includes('--check')) {
let current = '';
try {
current = readFileSync(outFile, 'utf8');
} catch {
current = '';
}
if (current !== serialized) {
console.error('\nen.json is out of date — it is generated, not written.\n');
console.error(' node scripts/extract-i18n.mjs\n');
process.exit(1);
}
console.log(`✓ en.json is current (${entries.size} keys).`);
process.exit(0);
}
writeFileSync(outFile, serialized);
console.log(`✓ wrote ${relative(root, outFile).replaceAll('\\', '/')} — ${entries.size} keys.`);