Files
HiveGrid-Wireframes/hivegrid-cloud/scripts/i18n-audit.mjs
T

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 };