#!/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. * * 'officer-web (shell)' landed 2026-08-23 (phase 2). It holds no never-translate hits of its own; what it * does hold is ~30 `` labels quoted from Google's console, which * `stripUntranslated` skips for the same reason. */ const ZEROED: string[] = ['shared components', 'officer-web (shell)']; 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* /^[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, ''); /** * ``, ``, `
` and `` mark content as code rather than prose — that is what the
 * elements are for. `Authorization: Bearer …` 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/>');

/**
 * `` marks text that must stay in one language — today, a label quoted from
 * another product's UI, which docs/localization.md classifies as an identifier.
 *
 * Skipped for the same reason `neverTranslate` categories are excluded from `--check`: counting work the
 * doc FORBIDS doing against a target of zero makes the target unreachable. The marker is explicit and
 * carries a reason, so this cannot become a quiet catch-all — unlike a heuristic, somebody has to write
 * it and say why.
 */
const stripUntranslated = (source: string): string =>
  source.replace(/]*>[\s\S]*?<\/Untranslated>/g, '');

function* walk(dir: string): Generator {
  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 = stripUntranslated(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();
  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)`);
}