mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-29 16:17:25 +09:00
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>
389 lines
16 KiB
JavaScript
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.`);
|