The --check guard compared an area's whole hit count against zero, including 'thrown errors' — which the doc's never-translate list forbids converting. An area holding one legitimate new Error() could therefore never be added to ZEROED, so the debt guard could never be switched on for it at all. Categories now carry neverTranslate, are still reported, and are excluded from --check. Also drops sonner's 'group-[.toast]:text-muted-foreground', matched as an object label. The filter tests per token rather than per string: the first attempt tested the whole string and ate a real label, 'Application type:'. --check now prints the offending strings and files instead of only a count.
204 lines
8.3 KiB
TypeScript
Executable File
204 lines
8.3 KiB
TypeScript
Executable File
#!/usr/bin/env bun
|
|
/**
|
|
* Counts candidate hardcoded user-facing strings in the web UI.
|
|
*
|
|
* Two jobs. It sizes the localization work (docs/localization.md quotes its output), and once an area
|
|
* reaches zero it becomes the guard that stops the debt coming back — see ZEROED below.
|
|
*
|
|
* It is a heuristic and it is deliberately a FLOOR. It cannot see multi-line JSX text, ternaries
|
|
* inside JSX, strings passed down as props, or anything a plugin renders. A falling number means
|
|
* progress; a zero means "nothing this scanner can see", not "nothing left".
|
|
*
|
|
* bun scripts/i18n/scan-hardcoded.ts # summary by area
|
|
* bun scripts/i18n/scan-hardcoded.ts --files # worst files first
|
|
* bun scripts/i18n/scan-hardcoded.ts --list ui.button # print the strings themselves
|
|
* bun scripts/i18n/scan-hardcoded.ts --check # non-zero exit if a zeroed area regressed
|
|
*/
|
|
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
|
|
const AREAS = [
|
|
{ label: 'officer-web (shell)', root: 'src/apps/officer-web' },
|
|
{ label: 'officerdev (panels)', root: 'src/workspaces/officerdev/src' },
|
|
{ label: 'shared components', root: 'src/workspaces/components' },
|
|
] as const;
|
|
|
|
/**
|
|
* Areas that have been converted and must stay converted. Add an area here the moment it reaches
|
|
* zero — enabling every area on day one would just be red for weeks and get ignored.
|
|
*/
|
|
const ZEROED: string[] = [];
|
|
|
|
const SKIP = /node_modules|\.gen\.|[/\\]locales[/\\]|[/\\]vendor[/\\]|\.test\.|\.d\.ts$/;
|
|
|
|
// A quoted literal, single or double. Kept as two groups because JS has no branch reset.
|
|
const Q = String.raw`(?:"([^"\n]{2,120})"|'([^'\n]{2,120})')`;
|
|
|
|
/**
|
|
* `neverTranslate` marks a category the doc's "What must never be translated" list forbids converting —
|
|
* so it is reported (an agent should see it and confirm), but excluded from `--check`.
|
|
*
|
|
* Without that split the guard is unusable: an area holding one legitimate `new Error()` can never
|
|
* reach zero, so it could never be added to ZEROED, so the debt guard could never be switched on for
|
|
* it. Counting work that must NOT be done against a target of zero makes the target unreachable.
|
|
*/
|
|
const PATTERNS: { kind: string; rx: RegExp; neverTranslate?: true }[] = [
|
|
// >Some visible words<
|
|
{ kind: 'jsx text', rx: />\s*([A-Z][A-Za-z0-9][^<>{}\n]{2,100}?)\s*</g },
|
|
// placeholder="..." and friends
|
|
{
|
|
kind: 'attributes',
|
|
rx: new RegExp(
|
|
String.raw`\b(?:placeholder|title|aria-label|alt|label|description|tooltip|emptyMessage)\s*=\s*` + Q,
|
|
'g',
|
|
),
|
|
},
|
|
{ kind: 'toast/alert', rx: new RegExp(String.raw`\b(?:toast(?:\.\w+)?|alert|confirm)\s*\(\s*` + Q, 'g') },
|
|
// label: 'Status' — nav items, menus, table columns. The largest hidden category.
|
|
{
|
|
kind: 'object labels',
|
|
rx: new RegExp(
|
|
String.raw`\b(?:label|name|title|heading|placeholder|description|tooltip|emptyText)\s*:\s*` + Q,
|
|
'g',
|
|
),
|
|
},
|
|
// Thrown Errors are for whoever is reading a terminal at 4am. They stay in one language.
|
|
{ kind: 'thrown errors', rx: new RegExp(String.raw`new Error\(\s*` + Q, 'g'), neverTranslate: true },
|
|
// `Failed to create ${kind}` — needs interpolation, so it costs more to convert.
|
|
{ kind: 'template str', rx: /`([A-Z][^`\n]{4,100}\$\{[^`]*)`/g },
|
|
];
|
|
|
|
/** Filters out ids, class names, paths, mime types and SCREAMING_CONSTANTS. */
|
|
function isHuman(value: string | undefined): value is string {
|
|
if (!value) return false;
|
|
const s = value.trim();
|
|
if (s.length < 3) return false;
|
|
if (!/[A-Za-z]{3}/.test(s)) return false;
|
|
if (/^[a-z0-9\-_./:]+$/.test(s)) return false;
|
|
if (/^(https?:|\/|#|data:|application\/|image\/)/.test(s)) return false;
|
|
if (/^[A-Z0-9_]+$/.test(s)) return false;
|
|
// Tailwind, reached via a `description:`/`label:` key in a classNames object — e.g. sonner's
|
|
// `description: 'group-[.toast]:text-muted-foreground'`.
|
|
//
|
|
// The test is per TOKEN, not on the whole string: every space-separated token must look like a
|
|
// utility (all lowercase, no sentence punctuation) and at least one must carry a variant colon or
|
|
// an arbitrary-value bracket. Testing the whole string instead cost a real label — "Application
|
|
// type:" was filtered because it ends in a colon, which is also how prose introduces a value.
|
|
const tokens = s.split(/\s+/);
|
|
const utilityish = (tok: string) => /^[a-z0-9:_\-[\]().#&/%!]+$/.test(tok);
|
|
if (tokens.every(utilityish) && tokens.some((tok) => /[:[]/.test(tok))) return false;
|
|
return true;
|
|
}
|
|
|
|
function* walk(dir: string): Generator<string> {
|
|
let entries: string[];
|
|
try {
|
|
entries = readdirSync(dir);
|
|
} catch {
|
|
return;
|
|
}
|
|
for (const entry of entries) {
|
|
const path = join(dir, entry);
|
|
if (SKIP.test(path)) continue;
|
|
let isDir = false;
|
|
try {
|
|
isDir = statSync(path).isDirectory();
|
|
} catch {
|
|
continue;
|
|
}
|
|
if (isDir) yield* walk(path);
|
|
else if (path.endsWith('.tsx') || path.endsWith('.ts')) yield path;
|
|
}
|
|
}
|
|
|
|
type Hit = { kind: string; value: string; file: string };
|
|
|
|
function scan(root: string): { files: number; hits: Hit[] } {
|
|
const hits: Hit[] = [];
|
|
let files = 0;
|
|
|
|
for (const file of walk(root)) {
|
|
files++;
|
|
let source: string;
|
|
try {
|
|
source = readFileSync(file, 'utf8');
|
|
} catch {
|
|
continue;
|
|
}
|
|
for (const { kind, rx } of PATTERNS) {
|
|
rx.lastIndex = 0;
|
|
for (const match of source.matchAll(rx)) {
|
|
const value = match.slice(1).find((group) => group !== undefined);
|
|
if (isHuman(value)) hits.push({ kind, value: value.trim(), file });
|
|
}
|
|
}
|
|
}
|
|
return { files, hits };
|
|
}
|
|
|
|
const args = new Set(process.argv.slice(2));
|
|
const listPrefix = process.argv.includes('--list') ? process.argv[process.argv.indexOf('--list') + 1] : undefined;
|
|
|
|
const results = AREAS.map((area) => ({ ...area, ...scan(area.root) }));
|
|
const kinds = PATTERNS.map((p) => p.kind);
|
|
const pad = (s: string | number, n: number) => String(s).padStart(n);
|
|
|
|
console.log(
|
|
`${'area'.padEnd(22)} ${pad('files', 5)} ${kinds.map((k) => pad(k.slice(0, 13), 14)).join('')} ${pad('total', 7)}`,
|
|
);
|
|
for (const r of results) {
|
|
const byKind = kinds.map((k) => pad(r.hits.filter((h) => h.kind === k).length, 14)).join('');
|
|
console.log(`${r.label.padEnd(22)} ${pad(r.files, 5)} ${byKind} ${pad(r.hits.length, 7)}`);
|
|
}
|
|
|
|
const all = results.flatMap((r) => r.hits);
|
|
const distinct = new Set(all.map((h) => h.value));
|
|
const interpolated = new Set(all.filter((h) => h.kind === 'template str').map((h) => h.value));
|
|
|
|
const NEVER_KINDS = new Set(PATTERNS.filter((p) => p.neverTranslate).map((p) => p.kind));
|
|
const convertible = (hits: Hit[]) => hits.filter((h) => !NEVER_KINDS.has(h.kind));
|
|
|
|
console.log(
|
|
`${'TOTAL'.padEnd(22)} ${pad(
|
|
results.reduce((n, r) => n + r.files, 0),
|
|
5,
|
|
)} ${kinds.map((k) => pad(all.filter((h) => h.kind === k).length, 14)).join('')} ${pad(all.length, 7)}`,
|
|
);
|
|
console.log();
|
|
console.log(`occurrences ${all.length}`);
|
|
console.log(` convertible ${convertible(all).length}`);
|
|
console.log(` never-translate ${all.length - convertible(all).length} (${[...NEVER_KINDS].join(', ')})`);
|
|
console.log(`distinct strings ${distinct.size}`);
|
|
console.log(` needing params ${interpolated.size}`);
|
|
|
|
if (args.has('--files')) {
|
|
console.log('\nworst files:');
|
|
const perFile = new Map<string, number>();
|
|
for (const h of all) perFile.set(h.file, (perFile.get(h.file) ?? 0) + 1);
|
|
for (const [file, n] of [...perFile].sort((a, b) => b[1] - a[1]).slice(0, 25)) {
|
|
console.log(` ${pad(n, 4)} ${file}`);
|
|
}
|
|
}
|
|
|
|
if (listPrefix) {
|
|
console.log(`\nstrings in files matching "${listPrefix}":`);
|
|
for (const h of all.filter((x) => x.file.includes(listPrefix))) console.log(` [${h.kind}] ${h.value}`);
|
|
}
|
|
|
|
if (args.has('--check')) {
|
|
const regressed = results
|
|
.filter((r) => ZEROED.includes(r.label))
|
|
.map((r) => ({ label: r.label, hits: convertible(r.hits) }))
|
|
.filter((r) => r.hits.length > 0);
|
|
if (regressed.length > 0) {
|
|
console.error('\nThese areas were converted and have regressed:');
|
|
for (const r of regressed) {
|
|
console.error(` ${r.label}: ${r.hits.length} hardcoded strings`);
|
|
for (const h of r.hits) console.error(` [${h.kind}] ${h.value} — ${h.file}`);
|
|
}
|
|
console.error('\nUse a catalog key. See docs/localization.md.');
|
|
process.exit(1);
|
|
}
|
|
console.log(`\ncheck: ok (${ZEROED.length} area(s) held at zero)`);
|
|
}
|