289 lines
10 KiB
JavaScript
289 lines
10 KiB
JavaScript
#!/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*</g;
|
|
const INTL_FORMATTING_PATTERN = /\b(?:toLocale(?:String|DateString|TimeString|LowerCase|UpperCase)?|DateUtils\.[A-Za-z0-9_]+|NumberUtils\.[A-Za-z0-9_]+)\s*\(/g;
|
|
|
|
async function walk(dir) {
|
|
let entries;
|
|
try {
|
|
entries = await readdir(dir, { withFileTypes: true });
|
|
} catch {
|
|
return [];
|
|
}
|
|
const out = [];
|
|
for (const entry of entries) {
|
|
if (entry.name.startsWith(".")) continue;
|
|
const full = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
if (IGNORED_DIRS.has(entry.name)) continue;
|
|
out.push(...(await walk(full)));
|
|
continue;
|
|
}
|
|
if (!entry.name.endsWith(".tsx") && !entry.name.endsWith(".ts")) continue;
|
|
if (IGNORED_FILE_SUFFIXES.some((suffix) => 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 };
|