#!/usr/bin/env node // i18n-audit (#72 i18n-foundation). // // Scans key web UI roots for hardcoded English or German JSX text and emits a // baseline Markdown report. This is BASELINE work — the script counts and // lists; it does NOT enforce. Full extraction is a separately-tracked sprint. // // Usage: // node scripts/i18n-audit.mjs # write to default path // node scripts/i18n-audit.mjs --out path/to/report.md # custom out path // node scripts/i18n-audit.mjs --json # stdout JSON /* eslint-disable no-console */ import { readdir, readFile, stat, writeFile, mkdir } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(__dirname, ".."); const SCAN_ROOTS = ["routes", "modules", "routeModules", "components"].map((root) => path.join(repoRoot, "apps/web/app", root)); const IGNORED_DIRS = new Set(["node_modules", "dist", "build", ".cache", "coverage", "__tests__", "test-fixtures"]); const IGNORED_FILE_SUFFIXES = [".test.tsx", ".test.ts", ".spec.tsx", ".spec.ts", ".stories.tsx", ".d.ts"]; // JSX text matcher. We look for runs of letters that are clearly user-facing // — anything ≥ 3 chars, starts with a capital or contains a German umlaut, and // is not wrapped in `{t(…)}`. The point isn't to be 100 % accurate; it's to // produce a meaningful baseline number that the next extraction sprint can // drive to zero. const JSX_TEXT_PATTERN = />\s*([A-ZÄÖÜ][A-Za-zÄÖÜäöüß][A-Za-zÄÖÜäöüß0-9 .,;:!?'\-]{2,})\s* entry.name.endsWith(suffix))) continue; out.push(full); } return out; } function extractHardcodedStrings(source) { const hits = []; const seen = new Set(); let match; JSX_TEXT_PATTERN.lastIndex = 0; while ((match = JSX_TEXT_PATTERN.exec(source)) !== null) { if (isInsideStringLiteral(source, match.index)) continue; const text = match[1].trim(); if (!text) continue; // Strip out obvious non-strings: single-word identifiers in PascalCase, // numbers-only, or icon labels that are actually component names. if (/^[A-Z][A-Za-z]*$/.test(text)) continue; if (text.length < 3) continue; // Use the line number of the match. const lineNumber = source.slice(0, match.index).split("\n").length; const key = `${lineNumber}:${text}`; if (seen.has(key)) continue; seen.add(key); hits.push({ line: lineNumber, text }); } return hits; } function isInsideStringLiteral(source, index) { let quote = null; let escaped = false; let inLineComment = false; let inBlockComment = false; for (let i = 0; i < index; i += 1) { const char = source[i]; const next = source[i + 1]; if (inLineComment) { if (char === "\n") inLineComment = false; continue; } if (inBlockComment) { if (char === "*" && next === "/") { inBlockComment = false; i += 1; } continue; } if (quote) { if (escaped) { escaped = false; continue; } if (char === "\\") { escaped = true; continue; } if (char === quote) quote = null; continue; } if (char === "/" && next === "/") { inLineComment = true; i += 1; continue; } if (char === "/" && next === "*") { inBlockComment = true; i += 1; continue; } if (char === '"' || char === "'" || char === "`") quote = char; } return quote !== null; } function extractIntlFormattingUsages(source) { const hits = []; const seen = new Set(); let match; INTL_FORMATTING_PATTERN.lastIndex = 0; while ((match = INTL_FORMATTING_PATTERN.exec(source)) !== null) { const expression = match[0].replace(/\($/, ""); const lineNumber = source.slice(0, match.index).split("\n").length; const key = `${lineNumber}:${expression}`; if (seen.has(key)) continue; seen.add(key); hits.push({ line: lineNumber, expression }); } return hits; } async function auditFile(filePath) { const source = await readFile(filePath, "utf8"); const hits = extractHardcodedStrings(source); const intlHits = extractIntlFormattingUsages(source); if (hits.length === 0 && intlHits.length === 0) return null; return { filePath: path.relative(repoRoot, filePath), hits, intlHits }; } async function runAudit() { const files = []; for (const root of SCAN_ROOTS) { const stats = await stat(root).catch(() => null); if (!stats?.isDirectory()) continue; files.push(...(await walk(root))); } const results = (await Promise.all(files.map(auditFile))).filter(Boolean); results.sort((a, b) => a.filePath.localeCompare(b.filePath)); const total = results.reduce((acc, file) => acc + file.hits.length, 0); const hardcodedFiles = results.filter((file) => file.hits.length > 0); const intlFiles = results .filter((file) => file.intlHits.length > 0) .map((file) => ({ filePath: file.filePath, hits: file.intlHits })); const totalIntlFormattingUsages = intlFiles.reduce((acc, file) => acc + file.hits.length, 0); return { scannedFileCount: files.length, fileHitCount: hardcodedFiles.length, totalHardcodedStrings: total, intlFileCount: intlFiles.length, totalIntlFormattingUsages, files: hardcodedFiles, intlFiles, }; } function renderMarkdown(report, now) { const intlFiles = report.intlFiles ?? []; const intlFileCount = report.intlFileCount ?? intlFiles.length; const totalIntlFormattingUsages = report.totalIntlFormattingUsages ?? intlFiles.reduce((acc, file) => acc + file.hits.length, 0); const lines = []; lines.push(`# i18n Audit — Baseline ${now.toISOString().slice(0, 10)}`); lines.push(""); lines.push("> Generated by `scripts/i18n-audit.mjs`. Counts hardcoded user-facing"); lines.push("> strings in `apps/web/app/{routes,modules,routeModules,components}/**/*.{ts,tsx}`."); lines.push("> Baseline only — not yet"); lines.push("> enforced. See `docs/contributing/i18n.md` for the migration playbook."); lines.push(""); lines.push("## Summary"); lines.push(""); lines.push(`| Metric | Value |`); lines.push(`| --- | ---: |`); lines.push(`| Files scanned | ${report.scannedFileCount} |`); lines.push(`| Files with hardcoded strings | ${report.fileHitCount} |`); lines.push(`| Total hardcoded JSX text fragments | ${report.totalHardcodedStrings} |`); lines.push(`| Files with locale-sensitive formatting signals | ${intlFileCount} |`); lines.push(`| Total locale-sensitive formatting signals | ${totalIntlFormattingUsages} |`); lines.push(""); if (report.files.length === 0 && intlFiles.length === 0) { lines.push("No hardcoded strings detected. Audit clean."); return lines.join("\n"); } if (report.files.length > 0) { lines.push("## Top offenders"); lines.push(""); const top = [...report.files].sort((a, b) => b.hits.length - a.hits.length).slice(0, 20); lines.push(`| File | Hits |`); lines.push(`| --- | ---: |`); for (const file of top) { lines.push(`| \`${file.filePath}\` | ${file.hits.length} |`); } lines.push(""); lines.push("## Detail (first 100 hits)"); lines.push(""); lines.push(`| File:Line | Hardcoded text |`); lines.push(`| --- | --- |`); let detailCount = 0; for (const file of report.files) { for (const hit of file.hits) { if (detailCount >= 100) break; const text = hit.text.replace(/\|/g, "\\|").replace(/\n/g, " "); lines.push(`| \`${file.filePath}:${hit.line}\` | ${text} |`); detailCount += 1; } if (detailCount >= 100) break; } lines.push(""); } if (intlFiles.length > 0) { lines.push("## Formatting migration signals (first 100 hits)"); lines.push(""); lines.push(`| File:Line | Signal |`); lines.push(`| --- | --- |`); let detailCount = 0; for (const file of intlFiles) { for (const hit of file.hits) { if (detailCount >= 100) break; lines.push(`| \`${file.filePath}:${hit.line}\` | \`${hit.expression}\` |`); detailCount += 1; } if (detailCount >= 100) break; } } return lines.join("\n"); } function parseArgs(argv) { const args = { out: null, json: false }; for (let i = 2; i < argv.length; i += 1) { const arg = argv[i]; if (arg === "--json") args.json = true; else if (arg === "--out") args.out = argv[++i]; else if (arg.startsWith("--out=")) args.out = arg.slice("--out=".length); } return args; } async function main() { const args = parseArgs(process.argv); const report = await runAudit(); if (args.json) { process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); return; } const now = new Date(); const outPath = args.out ? path.resolve(repoRoot, args.out) : path.join(repoRoot, "docs/contributing", `i18n-audit-${now.toISOString().slice(0, 10)}.md`); await mkdir(path.dirname(outPath), { recursive: true }); await writeFile(outPath, `${renderMarkdown(report, now)}\n`, "utf8"); console.log( `i18n-audit: scanned ${report.scannedFileCount} files, found ${report.totalHardcodedStrings} hardcoded strings across ${report.fileHitCount} files and ${report.totalIntlFormattingUsages} formatting signals across ${report.intlFileCount} files`, ); console.log(`i18n-audit: report written to ${path.relative(repoRoot, outPath)}`); } if (import.meta.url === `file://${process.argv[1]}`) { main().catch((err) => { console.error(err); process.exit(1); }); } export { runAudit, renderMarkdown, extractHardcodedStrings, extractIntlFormattingUsages };