#!/usr/bin/env node /** * i18n-audit.mjs — Find missing translations and improperly used translation keys. * * Usage: * node scripts/i18n-audit.mjs # full audit (default, excludes --hardcoded, --prune, --sort) * node scripts/i18n-audit.mjs --ci # same as default but does not fail on missing non-en keys * node scripts/i18n-audit.mjs --missing # missing keys (in EN but not in locale) * node scripts/i18n-audit.mjs --orphan # orphan keys (in locale but not in EN) * node scripts/i18n-audit.mjs --duplicates # duplicate keys in any locale * node scripts/i18n-audit.mjs --unused # unused keys (in EN but not referenced in repo) * node scripts/i18n-audit.mjs --dangling # t() calls referencing keys not in en.ts * node scripts/i18n-audit.mjs --aliases # aliased t() calls (translate/tr instead of t) * node scripts/i18n-audit.mjs --placeholders # placeholder integrity check * node scripts/i18n-audit.mjs --plurals # each locale has bare key OR all CLDR plural forms it needs * node scripts/i18n-audit.mjs --hardcoded # hardcoded English strings in source files * node scripts/i18n-audit.mjs --prune # (destructive) remove unused keys from all locales * node scripts/i18n-audit.mjs --sort # (destructive) alphabetically sort keys in all locales */ import { readFileSync, readdirSync, existsSync, writeFileSync } from "node:fs"; import { join, basename, dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = resolve(__dirname, ".."); const LOCALES_DIR = join(REPO_ROOT, "apps/app/src/i18n/locales"); const APP_SRC = join(REPO_ROOT, "apps/app/src"); const LOCALES = ["ja", "zh", "vi", "pt-BR", "th", "fr", "ca", "es", "ru"]; const EN_FILE = join(LOCALES_DIR, "en.ts"); const PLURAL_SUFFIXES = ["zero", "one", "two", "few", "many", "other"]; const PLURAL_SUFFIX_RE = /_(zero|one|two|few|many|other)$/; const stripPluralSuffix = (key) => key.replace(PLURAL_SUFFIX_RE, ""); // Directory names skipped during the source scan. The "locales" dir is intentionally // skipped; otherwise translation keys will trivially match themselves. const SKIP_DIRS = ["locales", "node_modules", "target", "dist", "build", "out", "coverage"]; const mode = process.argv[2] ?? "--all"; const isCi = mode === "--ci"; const isAll = mode === "--all" || isCi; const EXCLUDED_FROM_ALL = new Set(["--hardcoded"]); const shouldRun = (...modes) => (isAll && !modes.some((m) => EXCLUDED_FROM_ALL.has(m))) || modes.includes(mode); // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function importedConstBindings(content, filePath) { const bindings = new Map(); for (const match of content.matchAll(/import\s+\{([^}]+)\}\s+from\s+["'](\.[^"']+)["'];?/g)) { const source = match[2].endsWith(".ts") ? match[2] : `${match[2]}.ts`; const sourcePath = resolve(dirname(filePath), source); for (const specifier of match[1].split(",")) { const parts = specifier.trim().split(/\s+as\s+/); const imported = parts[0]?.trim(); const local = parts[1]?.trim() ?? imported; if (!imported || !local || imported.startsWith("type ")) continue; bindings.set(local, { imported, sourcePath }); } } return bindings; } function parseExportedConst(filePath, exportName) { const content = readFileSync(filePath, "utf-8"); const escapedName = exportName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const match = content.match( new RegExp(`export\\s+const\\s+${escapedName}\\s*=\\s*\\{([\\s\\S]*?)\\}\\s+as\\s+const;`), ); if (!match) throw new Error(`Could not parse imported const ${exportName} from ${filePath}`); return new Function(`return {${match[1]}}`)(); } /** Parse a locale .ts file into a JS object via eval. */ function parseLocale(filePath) { const content = readFileSync(filePath, "utf-8"); const match = content.match(/export default \{([\s\S]*?)\} as const;/); if (!match) throw new Error(`Could not parse ${filePath}`); const imported = importedConstBindings(content, filePath); const spreadNames = [...match[1].matchAll(/\.\.\.([A-Za-z_$][\w$]*)/g)] .map((spread) => spread[1]); const bindings = new Map(); for (const name of spreadNames) { const source = imported.get(name); if (!source) continue; bindings.set(name, parseExportedConst(source.sourcePath, source.imported)); } return new Function(...bindings.keys(), `return {${match[1]}}`)(...bindings.values()); } /** Extract translation keys from a locale .ts file (as a Set). */ function extractKeys(filePath) { return new Set(Object.keys(parseLocale(filePath))); } /** Extract key→value map from a locale .ts file. */ function extractKeyValues(filePath) { return new Map(Object.entries(parseLocale(filePath))); } /** Find all {placeholders} in a string. */ function findPlaceholders(str) { return [...str.matchAll(/\{([a-z_]+)\}/g)].map((m) => m[0]).sort(); } /** Recursively collect all .ts/.tsx files under a directory. */ function collectSourceFiles(dir, exclude) { const results = []; for (const entry of readdirSync(dir, { withFileTypes: true })) { const full = join(dir, entry.name); if (entry.isDirectory()) { if (exclude && exclude(full)) continue; results.push(...collectSourceFiles(full, exclude)); } else if (/\.(ts|tsx)$/.test(entry.name)) { results.push(full); } } return results; } /** Group an array of strings by prefix (before first dot). */ function groupByPrefix(keys) { const groups = new Map(); for (const key of keys) { const prefix = key.split(".")[0]; groups.set(prefix, (groups.get(prefix) ?? 0) + 1); } return [...groups.entries()].sort((a, b) => b[1] - a[1]); } /** Find duplicate keys in a file (must use regex — JSON.parse dedupes silently). */ function findDuplicates(filePath) { const content = readFileSync(filePath, "utf-8"); const seen = new Map(); const dupes = []; for (const match of content.matchAll(/^\s*"([^"]+)"\s*:/gm)) { const key = match[1]; if (seen.has(key)) dupes.push(key); else seen.set(key, true); } return dupes; } // --------------------------------------------------------------------------- // Audit // --------------------------------------------------------------------------- const enKeys = extractKeys(EN_FILE); const enKeyValues = extractKeyValues(EN_FILE); let exitCode = 0; console.log("╔══════════════════════════════════════════════════╗"); console.log("║ i18n Audit Report ║"); console.log("╚══════════════════════════════════════════════════╝"); console.log(); // --- 1. Key counts --- console.log("=== Key counts ==="); console.log(` en ${enKeys.size} keys (source of truth)`); for (const locale of LOCALES) { const file = join(LOCALES_DIR, `${locale}.ts`); if (!existsSync(file)) { console.log(` ${locale.padEnd(8)} MISSING FILE`); continue; } const keys = extractKeys(file); const pct = Math.round((keys.size / enKeys.size) * 100); console.log(` ${locale.padEnd(8)} ${keys.size} keys (${pct}%)`); } console.log(); // --- 2. Missing keys --- if (shouldRun("--missing")) { console.log("=== Missing keys (in en.ts but not in locale) ==="); for (const locale of LOCALES) { const file = join(LOCALES_DIR, `${locale}.ts`); if (!existsSync(file)) continue; const localeKeys = extractKeys(file); const missing = [...enKeys].filter((k) => !localeKeys.has(k)); if (missing.length === 0) { console.log(` ${locale}: ✓ no missing`); } else { console.log(` ${locale}: ✗ ${missing.length} missing`); if (!isCi) exitCode = 1; if (mode !== "--summary") { for (const [prefix, count] of groupByPrefix(missing).slice(0, 15)) { console.log(` ${String(count).padStart(4)} ${prefix}.*`); } const totalGroups = new Set(missing.map((k) => k.split(".")[0])).size; if (totalGroups > 15) console.log(` ... and ${totalGroups - 15} more groups`); } } } console.log(); } // Locales without plurals (e.g. Chinese, Japanese) use the bare key while en // defines suffixed variants — that's not orphan. The reverse (locale has a // suffix en doesn't) is also fine: the runtime falls back to en's bare or // other-suffix key. const enHasAnyPluralVariant = (key) => PLURAL_SUFFIXES.some((suffix) => enKeys.has(`${key}_${suffix}`)); const isOrphan = (key) => { if (enKeys.has(key)) return false; if (enHasAnyPluralVariant(key)) return false; const base = stripPluralSuffix(key); if (base !== key) { if (enKeys.has(base)) return false; if (enHasAnyPluralVariant(base)) return false; } return true; }; // --- 3. Orphan keys --- const orphansByLocale = {}; if (shouldRun("--orphan", "--prune")) { console.log("=== Orphan keys (in locale but not in en.ts) ==="); for (const locale of LOCALES) { const file = join(LOCALES_DIR, `${locale}.ts`); if (!existsSync(file)) continue; const localeKeys = extractKeys(file); const orphans = [...localeKeys].filter(isOrphan); orphansByLocale[locale] = orphans; if (orphans.length === 0) { console.log(` ${locale}: ✓ no orphans`); } else { console.log(` ${locale}: ⚠ ${orphans.length} orphan keys`); if (mode !== "--summary") { for (const key of orphans.slice(0, 10)) console.log(` ${key}`); if (orphans.length > 10) console.log(` ... and ${orphans.length - 10} more`); } } } console.log(); } // --- 4. Duplicate keys --- if (shouldRun("--duplicates")) { console.log("=== Duplicate keys ==="); for (const locale of ["en", ...LOCALES]) { const file = join(LOCALES_DIR, `${locale}.ts`); if (!existsSync(file)) continue; const dupes = findDuplicates(file); if (dupes.length === 0) { console.log(` ${locale}: ✓ no duplicates`); } else { console.log(` ${locale}: ✗ ${dupes.length} duplicate keys`); exitCode = 1; if (mode !== "--summary") { for (const key of dupes.slice(0, 5)) console.log(` ${key}`); } } } console.log(); } // --- 5. Unused keys --- let unusedKeys = []; if (shouldRun("--unused", "--prune")) { console.log("=== Unused keys (in en.ts but never referenced in repo) ==="); // Search the entire repo (not just apps/app/src) for key references. const repoSourceFiles = collectSourceFiles(REPO_ROOT, (dir) => { const name = basename(dir); return name.startsWith(".") || SKIP_DIRS.includes(name); }); const allSource = repoSourceFiles.map((f) => readFileSync(f, "utf-8")).join("\n"); // A plural-suffixed key (foo_one / foo_other) counts as "used" when the // base key (foo) is referenced — `t(key, { count })` resolves the suffix at // runtime so the source never names the suffixed variant directly. unusedKeys = [...enKeys].filter((key) => { if (allSource.includes(key)) return false; const base = stripPluralSuffix(key); if (base !== key && allSource.includes(base)) return false; return true; }); if (unusedKeys.length === 0) { console.log(" ✓ all keys referenced in source"); } else { console.log(` ⚠ ${unusedKeys.length} potentially unused keys`); if (mode !== "--summary") { for (const [prefix, count] of groupByPrefix(unusedKeys).slice(0, 15)) { console.log(` ${String(count).padStart(4)} ${prefix}.*`); } console.log(); for (const key of unusedKeys) console.log(` ${key}`); } if (mode !== "--prune") { console.log(); console.log(" (auto-fix with --prune option)"); } } console.log(); } // --- 6. Dangling t() calls (referencing keys not in en.ts) --- if (shouldRun("--dangling")) { console.log("=== Dangling t() calls (keys not in en.ts) ==="); const sourceFiles = collectSourceFiles(APP_SRC, (dir) => dir.includes("locales")); // Match t("key.name"), t("key.name", ...), translate("key.name"), tr("key.name") const keyRefPattern = /\b(?:t|translate|tr)\(\s*"([a-z][a-z0-9_]*\.[a-z][a-z0-9_.]*?)"/g; // A `t("foo")` call resolves if `foo` exists OR any plural variant // (`foo_one`, `foo_other`, etc.) exists — the runtime picks a variant // based on params.count. const keyResolves = (key) => { if (enKeys.has(key)) return true; return PLURAL_SUFFIXES.some((suffix) => enKeys.has(`${key}_${suffix}`)); }; const dangling = []; for (const file of sourceFiles) { const content = readFileSync(file, "utf-8"); const lines = content.split("\n"); for (let i = 0; i < lines.length; i++) { for (const match of lines[i].matchAll(keyRefPattern)) { const key = match[1]; if (!keyResolves(key)) { dangling.push({ key, file: file.replace(REPO_ROOT + "/", ""), line: i + 1 }); } } } } if (dangling.length === 0) { console.log(" ✓ all t() keys exist in en.ts"); } else { console.log(` ✗ ${dangling.length} dangling references`); exitCode = 1; if (mode !== "--summary") { for (const { key, file, line } of dangling) { console.log(` ${file}:${line} → "${key}"`); } } } console.log(); // --- 7. Dynamic t() calls (keys built at runtime) --- console.log("=== Dynamic t() calls (keys built at runtime) ==="); const dynamicPattern = /\b(?:t|translate|tr)\(\s*(`[^`]*\$\{|[^"'][^,)]*\+)/g; const dynamicHits = []; for (const file of sourceFiles) { const content = readFileSync(file, "utf-8"); const lines = content.split("\n"); for (let i = 0; i < lines.length; i++) { if (dynamicPattern.test(lines[i])) { dynamicHits.push({ file: file.replace(REPO_ROOT + "/", ""), line: i + 1, text: lines[i].trim() }); } dynamicPattern.lastIndex = 0; } } if (dynamicHits.length === 0) { console.log(" ✓ no dynamic key construction"); } else { console.log(` ✗ ${dynamicHits.length} dynamic key constructions (should be static strings)`); exitCode = 1; for (const { file, line, text } of dynamicHits) { console.log(` ${file}:${line}`); console.log(` ${text.slice(0, 120)}`); } } console.log(); } // --- 8. Aliased t() calls (should use t() directly, not translate/tr wrappers) --- if (shouldRun("--aliases")) { console.log("=== Aliased t() calls (should use t() directly) ==="); const aliasSourceFiles = collectSourceFiles(APP_SRC, (dir) => dir.includes("locales")); const aliasPattern = /\b(?:translate|tr)\s*\(/g; const aliasDefPattern = /(?:const|function)\s+(?:translate|tr)\s*[=(]/; const aliasSkipPattern = /translate\s*\(\s*[-\d]|translate\s*\(\s*0|props\.translate|:\s*\(key:\s*string\)|`translate\(/; const hits = []; for (const file of aliasSourceFiles) { const content = readFileSync(file, "utf-8"); const lines = content.split("\n"); for (let i = 0; i < lines.length; i++) { if (aliasDefPattern.test(lines[i])) continue; if (aliasSkipPattern.test(lines[i])) continue; if (aliasPattern.test(lines[i])) { hits.push({ file: file.replace(REPO_ROOT + "/", ""), line: i + 1, text: lines[i].trim() }); } aliasPattern.lastIndex = 0; } } if (hits.length === 0) { console.log(" ✓ all calls use t() directly"); } else { console.log(` ⚠ ${hits.length} aliased calls (translate/tr instead of t)`); for (const { file, line, text } of hits) { console.log(` ${file}:${line}`); console.log(` ${text.slice(0, 120)}`); } } console.log(); } // --- 9. Placeholder integrity --- if (shouldRun("--placeholders")) { console.log("=== Placeholder integrity ==="); let problems = 0; for (const [key, enValue] of enKeyValues) { const enPh = findPlaceholders(enValue); if (enPh.length === 0) continue; for (const locale of LOCALES) { const file = join(LOCALES_DIR, `${locale}.ts`); if (!existsSync(file)) continue; const localeKV = extractKeyValues(file); const localeValue = localeKV.get(key); if (!localeValue) continue; const localePh = findPlaceholders(localeValue); // {count} is optional in `_zero` / `_one` variants — the translator // can write "No messages" or "1 message" instead of "{count} message". const countOptional = /_(zero|one)$/.test(key); for (const ph of enPh) { if (countOptional && ph === "{count}") continue; if (!localePh.includes(ph)) { console.log(` ✗ ${locale}/${key}: missing placeholder ${ph}`); problems++; exitCode = 1; } } } } if (problems === 0) console.log(" ✓ all placeholders preserved"); else console.log(` ✗ ${problems} placeholder issues`); console.log(); } // --- 10. Plural completeness --- // For every key whose en value contains `{count}`, each locale must define // either the bare key (catch-all) or every plural form its language needs. // Most languages use `_one`+`_other`; `PLURAL_FORMS` lists the languages // that need more, verified against `Intl.PluralRules` (what `t()` uses at // runtime based on CLDR). const DEFAULT_PLURAL_FORM = ["one", "other"]; const PLURAL_FORMS = { ar: ["zero", "one", "two", "few", "many", "other"], // Arabic be: ["one", "few", "many", "other"], // Belarusian bs: ["one", "few", "other"], // Bosnian cs: ["one", "few", "many", "other"], // Czech cy: ["zero", "one", "two", "few", "many", "other"], // Welsh ga: ["one", "two", "few", "many", "other"], // Irish gd: ["one", "two", "few", "other"], // Scottish Gaelic gv: ["one", "two", "few", "many", "other"], // Manx he: ["one", "two", "other"], // Hebrew hr: ["one", "few", "other"], // Croatian iu: ["one", "two", "other"], // Inuktitut kw: ["zero", "one", "two", "few", "many", "other"], // Cornish lt: ["one", "few", "many", "other"], // Lithuanian lv: ["zero", "one", "other"], // Latvian mt: ["one", "two", "few", "many", "other"], // Maltese pl: ["one", "few", "many", "other"], // Polish ro: ["one", "few", "other"], // Romanian ru: ["one", "few", "many", "other"], // Russian sk: ["one", "few", "many", "other"], // Slovak sl: ["one", "two", "few", "other"], // Slovenian sr: ["one", "few", "other"], // Serbian uk: ["one", "few", "many", "other"], // Ukrainian }; if (shouldRun("--plurals")) { console.log("=== Plural completeness ==="); const pluralBases = new Set(); for (const [key, value] of enKeyValues) { if (typeof value === "string" && value.includes("{count}")) { pluralBases.add(stripPluralSuffix(key)); } } for (const locale of ["en", ...LOCALES]) { const file = join(LOCALES_DIR, `${locale}.ts`); if (!existsSync(file)) continue; const required = PLURAL_FORMS[locale] ?? DEFAULT_PLURAL_FORM; const localeKeys = extractKeys(file); const incomplete = []; for (const base of pluralBases) { if (localeKeys.has(base)) continue; const missing = required.filter((cat) => !localeKeys.has(`${base}_${cat}`)); if (missing.length === required.length) continue; // locale has none of these — handled by --missing if (missing.length > 0) incomplete.push({ base, missing }); } if (incomplete.length === 0) { console.log(` ${locale}: ✓ all plural keys complete`); } else { console.log(` ${locale}: ✗ ${incomplete.length} incomplete plural keys`); exitCode = 1; if (mode !== "--summary") { for (const { base, missing } of incomplete) { console.log(` ${base}: missing ${missing.map((m) => `_${m}`).join(", ")}`); } } } } if (pluralBases.size === 0) { console.log(" (no plural-base keys found in en.ts)"); } console.log(); } // --- 11. Hardcoded English scan --- if (shouldRun("--hardcoded")) { console.log("=== Hardcoded English scan ==="); const hardcodedFiles = collectSourceFiles(APP_SRC, (dir) => dir.includes("locales")); const excludePatterns = [ /import\b/, /from\s+"/, /class=/, /\btype\s/, /\bconst\s/, /variant=/, /\bt\(/, /translate\(/, /"connected"/, /"allow"/, /"local"/, /"remote"/, /"object"/, /"string"/, /"user"/, /"assistant"/, /"Escape"/, /"Arrow/, /"Enter"/, /"prompt"/, /"session"/, /"automation"/, /"minimal"/, /"starter"/, /"docker"/, /"opencode"/, /"simple"/, /"Started"/, /"Progress"/, /^\s*\/\//, /^\s*\/\*/, ]; const englishPattern = />[A-Z][a-z]{2,}[^<]*<|"[A-Z][a-z]{3,}[a-z ]+[.!?]?"/; for (const full of hardcodedFiles) { const name = full.replace(APP_SRC + "/", ""); const lines = readFileSync(full, "utf-8").split("\n"); const hits = []; for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (!englishPattern.test(line)) continue; if (excludePatterns.some((p) => p.test(line))) continue; hits.push(` ${i + 1}: ${line.trim()}`); if (hits.length >= 5) break; } if (hits.length === 0) { console.log(` ${name}: ✓ clean`); } else { console.log(` ${name}: ⚠ possible hardcoded strings:`); for (const hit of hits) console.log(hit); } } console.log(); } // --- 11. Sort --- if (mode === "--sort") { console.log("=== Sorting all locale files alphabetically ==="); const allLocaleFiles = ["en", ...LOCALES].map((l) => join(LOCALES_DIR, `${l}.ts`)); const PLURAL_ORDER = { _zero: 0, _one: 1, _two: 2, _few: 3, _many: 4, _other: 5 }; function sortKey(key) { let normalized = key.replace(/\./g, "\x00"); for (const [suffix, order] of Object.entries(PLURAL_ORDER)) { if (normalized.endsWith(suffix)) { normalized = normalized.slice(0, -suffix.length) + `\x01${order}`; break; } } return normalized; } for (const file of allLocaleFiles) { if (!existsSync(file)) continue; const content = readFileSync(file, "utf-8"); // Extract preamble (header comment) and body const exportMatch = content.match(/^([\s\S]*?)(export default \{)([\s\S]*?)(\} as const;\s*)$/); if (!exportMatch) { console.log(` ${basename(file, ".ts")}: ⚠ could not parse, skipped`); continue; } const [, preamble, , body] = exportMatch; // Eval the body as a JS object to get all key-value pairs let obj; try { obj = new Function(`return {${body}}`)(); } catch (e) { console.log(` ${basename(file, ".ts")}: ⚠ eval failed, skipped (${e.message})`); continue; } // Sort keys const sortedKeys = Object.keys(obj).sort((a, b) => { const ak = sortKey(a); const bk = sortKey(b); return ak < bk ? -1 : ak > bk ? 1 : 0; }); // Rebuild — JSON.stringify handles all escaping (\n, quotes, etc.) const lines = sortedKeys.map((key) => ` ${JSON.stringify(key)}: ${JSON.stringify(obj[key])},` ); writeFileSync(file, `${preamble}export default {\n${lines.join("\n")}\n} as const;\n`); const locale = basename(file, ".ts"); console.log(` ${locale}: ${sortedKeys.length} keys sorted`); } console.log(); } // --- 12. Prune (destructive) --- // Runs last so the user sees the unused/orphan reports above before // keys are actually removed. Removes both unused-from-source keys (en // only) and per-locale orphans (keys missing from en). if (mode === "--prune") { console.log("=== Pruning ==="); const unusedSet = new Set(unusedKeys); const allLocaleFiles = ["en", ...LOCALES].map((l) => join(LOCALES_DIR, `${l}.ts`)); let totalRemoved = 0; for (const file of allLocaleFiles) { if (!existsSync(file)) continue; const localeName = basename(file, ".ts"); const removeSet = new Set(unusedSet); const orphans = orphansByLocale[localeName] ?? []; for (const key of orphans) removeSet.add(key); const orphanCount = orphans.length; if (removeSet.size === 0) continue; const content = readFileSync(file, "utf-8"); const lines = content.split("\n"); const filtered = []; let skipNextLine = false; for (let i = 0; i < lines.length; i++) { if (skipNextLine) { skipNextLine = false; continue; } const keyMatch = lines[i].match(/^\s*"([^"]+)"\s*:/); if (keyMatch && removeSet.has(keyMatch[1])) { // Multi-line entry: value on next line if (!lines[i].includes('",') && !lines[i].includes('": "') && i + 1 < lines.length) { skipNextLine = true; } continue; } filtered.push(lines[i]); } writeFileSync(file, filtered.join("\n")); const removed = lines.length - filtered.length; totalRemoved += removed; const breakdown = orphanCount > 0 ? ` (${removed - orphanCount} unused, ${orphanCount} orphan)` : ""; console.log(` ${localeName}: removed ${removed} lines${breakdown}`); } if (totalRemoved === 0) console.log(" ✓ nothing to prune"); console.log(); } // --- Done --- console.log("=== Done ==="); process.exit(exitCode);