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

143 lines
5.7 KiB
JavaScript

#!/usr/bin/env node
// Plan 79 ratchet 5 — one locale-aware formatting door.
//
// Counts direct `toLocaleString` / `toLocaleDateString` / `toLocaleTimeString`
// calls and direct `new Intl.*` construction outside `src/utils/intl.js`.
//
// Every one of these is a locale bug, not merely a style problem: called with
// no argument they follow the BROWSER's locale rather than the panel's, so a
// panel switched to Spanish still renders English dates -- and a formatter
// built at module load can never follow a switch at all. They are also the
// slow path: tables construct one formatter per cell per render, which is the
// expensive half of Intl.
//
// Usage (from frontend/):
// node scripts/check-intl-door.mjs # check against the ceiling
// node scripts/check-intl-door.mjs --report # list the offenders
// node scripts/check-intl-door.mjs --update # write the current count
import { readFileSync, readdirSync, writeFileSync } from 'node:fs';
import { dirname, extname, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
const root = resolve(here, '..');
// `--root <dir>` censuses another tree — an extension repo's own src/. An
// extension's `toLocaleDateString()` is the same bug as core's: with no
// argument it follows the BROWSER, so its dates disagree with every date
// around them on a panel set to another language.
const rootArg = process.argv.includes('--root')
? process.argv[process.argv.indexOf('--root') + 1]
: null;
const srcDir = rootArg ? resolve(process.cwd(), rootArg) : resolve(root, 'src');
const ceilingFile = resolve(here, 'INTL_DOOR_CEILING');
// The door itself, and the tests that prove it behaves per locale.
const ALLOWED = new Set([
'utils/intl.js',
'utils/__tests__/intl.test.mjs',
]);
const PATTERN = /\.toLocale(?:Date|Time)?String\s*\(|new\s+Intl\.\w+/g;
// Build output is not source. An extension repo keeps its bundle in dist/, and
// counting the bundle double-counts every call in it AND reports hits nobody
// can fix without rebuilding.
const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', '__tests__']);
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);
}
return ['.js', '.jsx', '.mjs'].includes(extname(entry.name)) ? [path] : [];
});
}
export function census() {
const byFile = new Map();
for (const path of walk(srcDir)) {
const rel = relative(srcDir, path).replaceAll('\\', '/');
if (ALLOWED.has(rel)) continue;
const hits = [];
const lines = readFileSync(path, 'utf8').split(/\r?\n/);
// Comments are stripped first: a doc comment that NAMES the pattern is
// not a call, and counting it would mean a violation could be "fixed"
// by commenting it out.
let inBlock = false;
lines.forEach((line, index) => {
let code = line;
if (inBlock) {
const end = code.indexOf('*/');
if (end === -1) return;
code = code.slice(end + 2);
inBlock = false;
}
code = code.replace(/\/\*[^*]*\*\//g, '');
const blockStart = code.indexOf('/*');
if (blockStart !== -1) {
inBlock = true;
code = code.slice(0, blockStart);
}
const lineComment = code.indexOf('//');
if (lineComment !== -1) code = code.slice(0, lineComment);
for (const match of code.matchAll(PATTERN)) {
hits.push({ line: index + 1, text: match[0].trim() });
}
});
if (hits.length) byFile.set(rel, hits);
}
return byFile;
}
const byFile = census();
const count = [...byFile.values()].reduce((total, hits) => total + hits.length, 0);
if (process.argv.includes('--update')) {
if (rootArg) {
console.error('--update writes the HOST ceiling; refusing to do that from --root.');
process.exit(2);
}
writeFileSync(ceilingFile, `${count}\n`);
console.log(`intl door ceiling updated to ${count}`);
process.exit(0);
}
if (process.argv.includes('--report')) {
const rows = [...byFile].sort((a, b) => b[1].length - a[1].length);
for (const [file, hits] of rows) {
console.log(` ${String(hits.length).padStart(3)} ${file}`);
for (const hit of hits.slice(0, 3)) {
console.log(` :${hit.line} ${hit.text}`);
}
}
console.log(`\ntotal: ${count} direct calls in ${byFile.size} files`);
process.exit(0);
}
const ceiling = Number(readFileSync(ceilingFile, 'utf8').trim());
if (count > ceiling) {
const worst = [...byFile].sort((a, b) => b[1].length - a[1].length).slice(0, 5);
console.error('\nintl door check failed:\n');
console.error(` ${count} direct Intl / toLocale* calls; the ceiling is ${ceiling}.`);
console.error(' Format through the door so the value follows the PANEL locale:\n');
console.error(" import useFormat from '@/hooks/useFormat'; // components");
console.error(" import { formatDate } from '@/utils/intl'; // everything else\n");
for (const [file, hits] of worst) {
console.error(` ${String(hits.length).padStart(3)} ${file}`);
}
console.error('');
process.exit(1);
}
if (ceiling - count > 5) {
console.error(`\nintl door ceiling is ${ceiling} but only ${count} remain; run`);
console.error(' node scripts/check-intl-door.mjs --update\n');
process.exit(1);
}
console.log(`✓ intl door: ${count} direct Intl/toLocale calls remain ratcheted (ceiling ${ceiling}).`);