ApiKeys.tsx documents the header to send as <code>Authorization: Bearer …</code>. That is an identifier crossing a boundary on the doc's never-translate list, and translating it would break the thing it documents. Those four elements exist to mark content as code, so the scanner now honours them. Verified the delta across both remaining areas is exactly that one string.
227 lines
9.6 KiB
TypeScript
Executable File
227 lines
9.6 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.
|
|
*
|
|
* 'shared components' landed 2026-08-23 (localization phase 1). It holds three `new Error()` messages
|
|
* that must NEVER be translated; they are reported but excluded from the check, which is why the
|
|
* neverTranslate split below has to exist for this entry to be possible at all.
|
|
*/
|
|
const ZEROED: string[] = ['shared components'];
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Commented-out code is not user-facing text. `Header.tsx` carries a disabled dev-console button inside
|
|
* a `{/* … *\/}` block, and counting it means the area can never reach zero without editing a comment.
|
|
*
|
|
* Deliberately only two shapes, both unambiguous: a block comment, and a line whose first non-space
|
|
* characters are `//`. Stripping every `//` would eat the tail of any string holding a URL.
|
|
*/
|
|
const stripComments = (source: string): string =>
|
|
source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^[ \t]*\/\/.*$/gm, '');
|
|
|
|
/**
|
|
* `<code>`, `<kbd>`, `<pre>` and `<samp>` mark content as code rather than prose — that is what the
|
|
* elements are for. `<code>Authorization: Bearer …</code>` is an HTTP header, which the doc's
|
|
* never-translate list covers as an identifier crossing a boundary, and translating it would break the
|
|
* thing it documents.
|
|
*/
|
|
const stripCodeElements = (source: string): string =>
|
|
source.replace(/<(code|kbd|pre|samp)\b[^>]*>[\s\S]*?<\/\1>/g, '<$1/>');
|
|
|
|
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 = stripCodeElements(stripComments(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)`);
|
|
}
|