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>
143 lines
5.7 KiB
JavaScript
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}).`);
|