Files
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

389 lines
16 KiB
JavaScript

#!/usr/bin/env node
// Mechanical i18n conversion, for the long tail.
//
// Converting a 44-file extension by hand is how typos and half-converted files
// get in. This does the four shapes the census counts — and ONLY those, using
// the same `isCopy` rule, so what it converts is exactly what the ratchet
// measures:
//
// 1. JSX text Save -> {t('k', 'Save')}
// 2. JSX copy props label="Save" -> label={t('k', 'Save')}
// 3. Toast args toast.error('x')-> toast.error(t('k', 'x'))
// 4. Data copy keys { label: 'x' } -> { labelKey: 'k', label: 'x' }
//
// It deliberately REFUSES rather than guesses:
// * template literals (they need interpolation restructuring by hand);
// * any hit whose enclosing function is not a component, because the fix
// there is to thread `t` through a helper's arguments — a design decision,
// not a rewrite.
// Refusals are printed and left untouched, so the ratchet still sees them.
//
// Usage:
// node i18n-codemod.mjs --root <dir> --prefix wp [--sdk serverkit-sdk] [--dry]
import { readFileSync, readdirSync, writeFileSync } from 'node:fs';
import { extname, join, relative, resolve } from 'node:path';
import { parse } from 'espree';
const arg = (name, fallback = null) => (process.argv.includes(name)
? process.argv[process.argv.indexOf(name) + 1]
: fallback);
const rootDir = resolve(process.cwd(), arg('--root', 'src'));
const prefix = arg('--prefix', 'app');
const sdkModule = arg('--sdk', 'react-i18next');
const dry = process.argv.includes('--dry');
const onlyFile = arg('--file');
const COPY_PROPS = new Set([
'label', 'placeholder', 'title', 'description', 'helpText', 'help',
'emptyText', 'emptyMessage', 'confirmText', 'cancelText', 'submitText',
'heading', 'subtitle', 'subheading', 'caption', 'hint', 'tooltip',
'alt', 'aria-label', 'aria-description', 'aria-placeholder',
'message', 'text', 'buttonText', 'actionLabel', 'header',
]);
const COPY_KEYS = new Set([
'label', 'title', 'description', 'placeholder', 'helpText', 'help',
'emptyText', 'emptyMessage', 'confirmText', 'cancelText', 'submitText',
'heading', 'subtitle', 'subheading', 'caption', 'hint', 'tooltip',
'message', 'buttonText', 'actionLabel', 'header', 'summary',
]);
const COPY_CALLS = new Set([
'success', 'error', 'info', 'warning', 'warn', 'message', 'loading',
'showToast', 'toast', 'notify', 'confirm', 'alert',
]);
const TOAST_OBJECTS = new Set(['toast', 'toasts', 'sonner', 'notifications']);
const SKIP_DIRS = new Set(['__tests__', '__mocks__', 'node_modules', 'dist']);
const SKIP_FILE = /\.(test|spec|stories)\.[jt]sx?$/;
function isCopy(raw) {
const value = String(raw).trim();
if (value.length < 2) return false;
if (!/[A-Za-z]/.test(value)) return false;
if (/^https?:\/\//.test(value)) return false;
if (/^[/#.]/.test(value)) return false;
if (/^[a-z0-9]+([-_.:][a-z0-9]+)+$/.test(value)) return false;
if (/^[A-Z0-9]+(_[A-Z0-9]+)*$/.test(value)) return false;
if (/^[a-z]+$/.test(value)) return false;
if (/^\{\{.*\}\}$/.test(value)) return false;
if (/^[a-z][a-zA-Z0-9]*$/.test(value)) return false;
return /\s/.test(value) || /^[A-Z].{2,}/.test(value);
}
function walk(dir) {
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
if (entry.isDirectory()) {
return SKIP_DIRS.has(entry.name) ? [] : walk(join(dir, entry.name));
}
const ext = extname(entry.name);
if (!['.js', '.jsx'].includes(ext) || SKIP_FILE.test(entry.name)) return [];
return [join(dir, entry.name)];
});
}
const camel = (text, words = 6) => text
.replace(/[^A-Za-z0-9 ]/g, ' ')
.trim().split(/\s+/).slice(0, words)
.map((word, i) => (i === 0
? word.toLowerCase()
: word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()))
.join('') || 'text';
// Newlines need escaping, not just quotes: a placeholder like
// '-----BEGIN OPENSSH PRIVATE KEY-----\n…' carries a real line break, and
// emitting it raw produces an unterminated string literal.
const jsString = (value) => `'${value
.replace(/\\/g, '\\\\')
.replace(/'/g, "\\'")
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r')
.replace(/\t/g, '\\t')}'`;
function isTranslationCall(node) {
if (node.type !== 'CallExpression') return false;
const callee = node.callee;
if (callee.type === 'Identifier') return callee.name === 't';
return callee.type === 'MemberExpression' && !callee.computed && callee.property?.name === 't';
}
/** Is this function a React component (uppercase name)? */
function componentName(node, parent) {
if (node.type === 'FunctionDeclaration' && node.id) return node.id.name;
if (parent?.type === 'VariableDeclarator' && parent.id?.type === 'Identifier') {
return parent.id.name;
}
if (parent?.type === 'ExportDefaultDeclaration') return 'default';
return null;
}
/**
* A template literal becomes ONE key with named placeholders:
*
* `Deployment failed: ${err.message}`
* -> t('k', 'Deployment failed: {{message}}', { message: err.message })
*
* One key, not a prefix/suffix pair, because the value lands in a different
* place in other languages. Placeholder names come from the expression itself
* (`err.message` -> message) so the translator sees something meaningful.
*/
function templateToKeyed(node, source, keyFor, camelFn, jsStringFn) {
const names = new Map();
const nameFor = (expr, index) => {
let base = 'value';
if (expr.type === 'Identifier') base = expr.name;
else if (expr.type === 'MemberExpression' && !expr.computed && expr.property?.name) {
base = expr.property.name;
}
base = base.replace(/[^A-Za-z0-9]/g, '') || 'value';
let name = base;
let n = 2;
while (names.has(name)) { name = `${base}${n}`; n += 1; }
names.set(name, source.slice(expr.range[0], expr.range[1]));
return name;
};
let pattern = '';
node.quasis.forEach((quasi, i) => {
pattern += quasi.value.cooked ?? '';
if (i < node.expressions.length) pattern += `{{${nameFor(node.expressions[i], i)}}}`;
});
pattern = pattern.replace(/\s+/g, ' ').trim();
// Nothing readable outside the placeholders — not copy, leave it alone.
const literalPart = node.quasis.map((q) => q.value.cooked ?? '').join(' ').trim();
if (!literalPart || literalPart.length < 3 || !/[A-Za-z]{3}/.test(literalPart)) return null;
const values = [...names].map(([name, expr]) => `${name}: ${expr}`).join(', ');
return `t(${jsStringFn(keyFor(literalPart))}, ${jsStringFn(pattern)}, { ${values} })`;
}
let converted = 0;
let refused = 0;
const refusals = [];
const filesTouched = [];
for (const path of walk(rootDir)) {
const rel = relative(rootDir, path).replaceAll('\\', '/');
if (onlyFile && !rel.endsWith(onlyFile)) continue;
const source = readFileSync(path, 'utf8');
let ast;
try {
ast = parse(source, {
ecmaVersion: 'latest', sourceType: 'module',
ecmaFeatures: { jsx: true }, loc: true, range: true,
});
} catch (error) {
refusals.push(`${rel}: parse failed — ${error.message}`);
continue;
}
// Keep the file's own camelCase — `camel()` would flatten WordPressSiteCard
// to "wordpresssitecard", and a key nobody can read is a key nobody reuses.
const base = rel.split('/').pop().replace(/\.[jt]sx?$/, '');
const fileSlug = base.charAt(0).toLowerCase() + base.slice(1).replace(/[^A-Za-z0-9]/g, '');
const used = new Map();
const keyFor = (text) => {
const base = `${prefix}.${fileSlug}.${camel(text)}`;
const seen = used.get(base) || 0;
used.set(base, seen + 1);
return seen ? `${base}${seen + 1}` : base;
};
const edits = [];
const hookInto = new Set(); // function nodes needing `const { t } = …`
const stack = []; // [{node, name}] innermost last
const enclosingComponent = () => {
for (let i = stack.length - 1; i >= 0; i -= 1) {
const frame = stack[i];
if (frame.name && /^[A-Z]/.test(frame.name)) return frame;
if (frame.name === 'default') return frame;
}
return null;
};
const record = (node, text, replacement, needsHook = true) => {
if (!isCopy(text)) return;
if (needsHook) {
const frame = enclosingComponent();
if (!frame) {
refused += 1;
refusals.push(`${rel}:${node.loc.start.line}: "${text.trim().slice(0, 40)}" is outside a component — thread \`t\` through by hand`);
return;
}
hookInto.add(frame.node);
}
edits.push({ start: node.range[0], end: node.range[1], text: replacement });
converted += 1;
};
/**
* Key every copy string inside an expression, in place.
*
* Copy hides in `err.message || 'Failed…'` and `x ? 'A' : 'B'` as often as
* it sits directly in a call or prop. Replacing each literal in place keeps
* the surrounding expression intact.
*
* It stops at nested JSX: a `title={cond ? 'A' : <b rel="noopener">}` must
* not have the nested element's OWN attributes rewritten — `rel` is not
* copy, and the outer walk reaches that element on its own terms anyway.
*/
const keyStringsIn = (expr) => {
if (!expr || typeof expr !== 'object') return;
if (Array.isArray(expr)) { expr.forEach(keyStringsIn); return; }
if (!expr.type || isTranslationCall(expr)) return;
if (expr.type.startsWith('JSXElement') || expr.type === 'JSXFragment') return;
if (expr.type === 'Literal' && typeof expr.value === 'string' && isCopy(expr.value)) {
record(expr, expr.value,
`t(${jsString(keyFor(expr.value))}, ${jsString(expr.value)})`);
return;
}
if (expr.type === 'TemplateLiteral') {
const keyed = templateToKeyed(expr, source, keyFor, camel, jsString);
if (keyed) {
const literal = expr.quasis.map((q) => q.value.cooked ?? '').join(' ').trim();
record(expr, literal, keyed);
}
return;
}
for (const key of Object.keys(expr)) {
if (['type', 'loc', 'range', 'parent'].includes(key)) continue;
keyStringsIn(expr[key]);
}
};
const visit = (node, parent) => {
if (!node || typeof node !== 'object') return;
if (Array.isArray(node)) { for (const child of node) visit(child, parent); return; }
if (!node.type) return;
if (isTranslationCall(node)) return;
const isFn = ['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression']
.includes(node.type);
if (isFn) stack.push({ node, name: componentName(node, parent) });
switch (node.type) {
case 'JSXText': {
const text = node.value;
const trimmed = text.trim();
if (isCopy(trimmed)) {
const lead = text.slice(0, text.indexOf(trimmed.charAt(0)));
const tail = text.slice(lead.length + trimmed.length);
// JSX collapses internal whitespace at render, so a default
// spanning source lines must be collapsed too — emitting the
// raw text would put literal newlines inside a quoted string
// and fail to parse.
const flat = trimmed.replace(/\s+/g, ' ');
record(node, flat,
`${lead}{t(${jsString(keyFor(flat))}, ${jsString(flat)})}${tail}`);
}
break;
}
case 'JSXAttribute': {
const name = node.name?.type === 'JSXNamespacedName'
? `${node.name.namespace.name}:${node.name.name.name}`
: node.name?.name;
if (name && COPY_PROPS.has(name)
&& node.value?.type === 'Literal' && typeof node.value.value === 'string'
&& isCopy(node.value.value)) {
const text = node.value.value;
record(node.value, text,
`{t(${jsString(keyFor(text))}, ${jsString(text)})}`);
} else if (name && COPY_PROPS.has(name)
&& node.value?.type === 'JSXExpressionContainer') {
keyStringsIn(node.value.expression);
}
break;
}
case 'CallExpression': {
const callee = node.callee;
const name = callee.type === 'Identifier' ? callee.name
: (callee.type === 'MemberExpression' && !callee.computed
? callee.property?.name : null);
const object = callee.type === 'MemberExpression'
&& callee.object?.type === 'Identifier' ? callee.object.name : null;
if (name && COPY_CALLS.has(name)
&& (object === null || TOAST_OBJECTS.has(object) || name === 'confirm')) {
// See keyStringsIn above.
node.arguments.forEach(keyStringsIn);
}
break;
}
case 'Property': {
if (node.computed) break;
const key = node.key?.name
|| (typeof node.key?.value === 'string' ? node.key.value : null);
if (key && COPY_KEYS.has(key)
&& node.value?.type === 'Literal' && typeof node.value.value === 'string'
&& isCopy(node.value.value)) {
// Declarative pair — no hook needed, it resolves at render.
const text = node.value.value;
record(node, text,
`${key}Key: ${jsString(keyFor(text))}, ${key}: ${jsString(text)}`,
false);
if (isFn) stack.pop();
return; // do not descend; the whole property is replaced
}
break;
}
default: break;
}
for (const key of Object.keys(node)) {
if (['type', 'loc', 'range', 'parent'].includes(key)) continue;
visit(node[key], node);
}
if (isFn) stack.pop();
};
visit(ast, null);
if (!edits.length) continue;
// Hook lines, innermost-first so ranges stay valid.
for (const fn of hookInto) {
const body = fn.body;
if (body.type !== 'BlockStatement') {
refusals.push(`${rel}: component with an expression body needs \`t\` by hand`);
continue;
}
edits.push({
start: body.range[0] + 1, end: body.range[0] + 1,
text: '\n const { t } = useTranslation();',
});
}
// Two rules can claim the same source range — `confirm({ title: 'x' })`
// matches BOTH the data-object rule (declarative pair) and the copy-call
// rule (inner t()). Applying both corrupts the file, so keep the INNERMOST:
// for a call, `t()` is the right answer because nothing resolves a
// `titleKey` there. A genuine data object never overlaps, since the
// recursive rule only runs inside a copy call.
const kept = [];
for (const edit of [...edits].sort((a, b) => (a.end - a.start) - (b.end - b.start))) {
const overlaps = kept.some((k) => edit.start < k.end && k.start < edit.end);
if (!overlaps) kept.push(edit);
}
let output = source;
for (const edit of kept.sort((a, b) => b.start - a.start)) {
output = output.slice(0, edit.start) + edit.text + output.slice(edit.end);
}
if (!/useTranslation/.test(source)) {
const lastImport = [...output.matchAll(/^import .*?;$/gm)].pop();
const line = `import { useTranslation } from '${sdkModule}';`;
output = lastImport
? output.slice(0, lastImport.index + lastImport[0].length) + `\n${line}`
+ output.slice(lastImport.index + lastImport[0].length)
: `${line}\n${output}`;
}
filesTouched.push(rel);
if (!dry) writeFileSync(path, output);
}
for (const refusal of refusals) console.log(` REFUSED ${refusal}`);
console.log(`\n${dry ? '[dry] ' : ''}${converted} literals keyed across ${filesTouched.length} files; ${refused} refused.`);