scripts/locale-audit.mjs
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env node
/**
* Locale audit: deeper than the hash gate — key parity vs en, placeholder
* ({x}/ICU) mismatches, untranslated-value suspects, duplicate values.
* Covers EVERY locale in src/locales/ (not just fr/es) so a forgotten key,
* a broken placeholder, or an untranslated string in ANY language is caught.
*
* Run alongside the gates when touching locale files: npm run i18n:audit
* Exit code: 1 when a CI-failing defect is found (key parity / placeholder
* parity), 0 otherwise. Untranslated suspects are reported as warnings only —
* legitimately-identical values (brand names, technical terms) make them too
* noisy to gate on.
*/
import ts from "typescript";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.join(__dirname, "..");
// ── flatten locale files into dotted key paths + values ─────────────────
// also collect nested-object keys (e.g. errors.errorBoundary.title)
function flattenDeep(file) {
const src = fs.readFileSync(file, "utf8");
const sf = ts.createSourceFile(file, src, ts.ScriptTarget.Latest, true);
const out = {};
function visit(node, prefix) {
if (ts.isObjectLiteralExpression(node)) {
node.properties.forEach((p) => {
if (ts.isPropertyAssignment(p)) {
const name = p.name.getText(sf).replace(/^['"]|['"]$/g, "");
const key = prefix ? `${prefix}.${name}` : name;
if (ts.isStringLiteral(p.initializer) || ts.isNoSubstitutionTemplateLiteral(p.initializer)) {
out[key] = p.initializer.text;
} else {
visit(p.initializer, key);
}
}
});
}
}
const decl = sf.statements.find(ts.isVariableStatement)?.declarationList?.declarations?.[0]?.initializer;
visit(decl, "");
return out;
}
const localesDir = path.join(root, "src/locales");
const en = flattenDeep(path.join(localesDir, "en.ts"));
// Discover every non-English locale file dynamically (fr, es, de, zh, ar,
// pt-BR, it, ja, ru, tr, …) so a new locale is audited the day it lands.
const localeDicts = fs
.readdirSync(localesDir)
.filter((f) => f.endsWith(".ts") && f !== "en.ts")
.sort()
.map((f) => ({ name: f.replace(/\.ts$/, ""), dict: flattenDeep(path.join(localesDir, f)) }));
if (localeDicts.length === 0) {
console.error("No locale files found next to en.ts");
process.exit(1);
}
const enKeys = new Set(Object.keys(en));
// CI-failing defects vs warnings.
const errors = [];
const warnings = [];
// ── 1. key parity en↔<each locale> ──────────────────────────────────────
console.log(`== key parity (en vs ${localeDicts.length} locales) ==`);
for (const { name, dict } of localeDicts) {
const keys = new Set(Object.keys(dict));
const missing = [...enKeys].filter((k) => !keys.has(k));
const extra = [...keys].filter((k) => !enKeys.has(k));
if (missing.length || extra.length) {
console.log(` ${name}: missing=${missing.length} extra=${extra.length}`);
if (missing.length) {
console.log(` missing: ${missing.join(", ")}`);
errors.push(`${name}: ${missing.length} missing key(s)`);
}
if (extra.length) {
console.log(` extra: ${extra.join(", ")}`);
warnings.push(`${name}: ${extra.length} extra (not in en)`);
}
} else {
console.log(` ${name}: ok (${keys.size} keys)`);
}
}
// ── 2. placeholder parity ({x} / {count} / ICU) per locale ──────────────
function ph(v) {
const s = new Set();
// Capture placeholder NAMES from both simple {name} and ICU {name, plural,
// ...} forms. The ICU construct's name is what the caller passes, so it must
// be PRESERVED (the old code stripped the whole ICU block to a literal and
// lost the name, making en look placeholder-less vs ja/zh which drop ICU
// plurals and keep a plain {name} — a false mismatch on correct translations).
// Requires { immediately followed by word chars then , or } so internal ICU
// branch labels (`one {`, `=1 {`) and `#` are never captured.
for (const m of v.matchAll(/\{(\w+)(?:,|\})/g)) s.add(m[1]);
return s;
}
console.log("\n== placeholder mismatches en vs each locale ==");
let phTotal = 0;
for (const { name, dict } of localeDicts) {
let phCount = 0;
for (const k of Object.keys(en)) {
if (!(k in dict)) continue;
const a = ph(en[k]);
const b = ph(dict[k]);
if (a.size !== b.size || [...a].some((x) => !b.has(x))) {
phCount++;
console.log(` ${name} ${k}: en=${[...a]} ${name}=${[...b]}`);
}
}
if (phCount) errors.push(`${name}: ${phCount} placeholder mismatch(es)`);
phTotal += phCount;
}
if (!phTotal) console.log(" none");
// ── 3. untranslated / suspicious values per locale (WARNING only) ───────
// Single-word technical terms / brand names that legitimately stay identical
// across languages. Multi-word identical values are reported but not fatal.
const IDENTICAL_OK = /^(URL|SVG|PNG|KB|SMS|Bio|Star|Auto|Center|Tile|Contain|Cover|Gel|Mesh|Pixel|Néon|Radial|Glass|Regular|Semibold|Profil|Terminal|Design|links|Setup)$/i;
console.log("\n== values identical to en (possible untranslated — warning only) ==");
for (const { name, dict } of localeDicts) {
let idCount = 0;
const samples = [];
for (const k of Object.keys(en)) {
if (!(k in dict)) continue;
if (en[k] === dict[k] && /[a-z]{3,}/i.test(en[k])) {
// ignore single-word technical terms that legitimately stay the same
if (en[k].split(/\s+/).length === 1 && IDENTICAL_OK.test(en[k])) continue;
idCount++;
if (samples.length < 5) samples.push(`${k}: "${en[k].slice(0, 48)}"`);
}
}
if (idCount) {
console.log(` ${name}: ${idCount} suspect(s)`);
for (const s of samples) console.log(` ${s}`);
if (idCount > 5) console.log(` … and ${idCount - 5} more`);
warnings.push(`${name}: ${idCount} possibly-untranslated value(s)`);
} else {
console.log(` ${name}: none`);
}
}
// ── 4. duplicate values within en ────────────────────────────────────────
console.log("\n== duplicate values within en.ts (info only) ==");
const byVal = {};
for (const [k, v] of Object.entries(en)) {
if (!/[a-z]{3,}/i.test(v)) continue;
(byVal[v] ??= []).push(k);
}
const dups = Object.entries(byVal).filter(([, ks]) => ks.length > 1);
for (const [v, ks] of dups) {
console.log(` "${v.slice(0, 50)}" → ${ks.join(", ")}`);
}
console.log(` (${dups.length} duplicate groups)`);
// ── 5. unused keys: scan src for t("key") / t.rich usage ─────────────────
console.log("\n== unused-key scan (en.ts keys never referenced in src) ==");
const srcFiles = [];
(function walk(dir) {
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, e.name);
if (e.isDirectory()) {
if (e.name === "node_modules" || e.name === ".next") continue;
walk(p);
} else if (/\.(tsx?|mjs)$/.test(e.name)) {
srcFiles.push(p);
}
}
})(path.join(root, "src"));
const referenced = new Set(); // dotted full paths like "common.save"
const dynamicRefs = new Set(); // refs built from constants (label: "bgSolid")
for (const f of srcFiles) {
if (f.includes("src/locales/")) continue;
const src = fs.readFileSync(f, "utf8");
// namespaces bound in this file (per-file set: over-approximates scope,
// safer for unused-detection than last-wins per-var)
const nsSet = new Set();
for (const m of src.matchAll(/(?:useTranslations|getTranslations)\(\s*"([^"]+)"\s*\)/g)) {
nsSet.add(m[1]);
}
for (const m of src.matchAll(/\b(\w+)\s*(?:\.\s*(rich|raw|markup|has)\s*)?\(\s*"((?:[^"\\]|\\.)*)"/g)) {
const callee = m[1];
if (!/^t[A-Z]?/.test(callee)) continue;
const key = m[3];
for (const ns of nsSet) referenced.add(`${ns}.${key}`);
referenced.add(`(bare).${key}`);
}
// constants arrays (theme-constants label keys, action-error map)
for (const m of src.matchAll(/label:\s*"([A-Za-z0-9_]+)"/g)) dynamicRefs.add(m[1]);
}
// error map: MESSAGE_KEYS values are errors-namespace keys
for (const m of fs.readFileSync(path.join(root, "src/lib/action-error-i18n.ts"), "utf8").matchAll(/:\s*"([A-Za-z0-9_]+)",/g)) {
dynamicRefs.add(m[1]);
}
const leaves = Object.keys(en).filter((k) => typeof en[k] === "string");
const unused = leaves.filter((k) => {
if (referenced.has(k)) return false;
const leaf = k.split(".").pop();
if (dynamicRefs.has(leaf)) return false;
// bare t("key") where the var has no known namespace — can't resolve
const bareHits = [...referenced].filter((r) => r.startsWith("(bare).") && r.endsWith("." + leaf));
if (bareHits.length) return false;
return true;
});
console.log(` ${unused.length} possibly-unused keys:`);
for (const k of unused) console.log(` ${k}`);
if (unused.length) warnings.push(`${unused.length} possibly-unused key(s) in en.ts`);
// ── summary + exit code ─────────────────────────────────────────────────
console.log("\n== summary ==");
if (warnings.length) {
console.log(` warnings (${warnings.length}):`);
for (const w of warnings) console.log(` ! ${w}`);
}
if (errors.length) {
console.log(` ERRORS (${errors.length}):`);
for (const e of errors) console.log(` ✗ ${e}`);
console.log("\nLocale audit FAILED — fix the errors above.");
process.exit(1);
}
console.log(" no CI-failing errors.");
console.log("\nDone.");