scripts/i18n-check.mjs
Rama main · réplica de lectura del repositorio original.
/**
* i18n drift guard.
*
* tsc proves every locale file has every KEY (shape parity). It cannot
* prove the VALUES are still in sync with the English source — someone
* edits en.ts, and fr.ts silently serves stale text.
*
* This script hashes en.ts's string values and compares against the
* `lastSyncedHash:` comment each locale file must carry in its header.
*
* Usage:
* npm run i18n:check → fail (exit 1) if any locale is stale
* npm run i18n:check -- --update → rewrite the hash comments (run
* after syncing translations)
* npm run i18n:check -- --hash → print the current en.ts hash only
*
* The hash is stable across formatting: it parses the AST-ish token stream
* of quoted strings in key order, so reformatting en.ts does NOT bump it,
* but any value/key change does.
*/
import fs from "node:fs";
import { fileURLToPath } from "node:url";
import path from "node:path";
import crypto from "node:crypto";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const LOCALES_DIR = path.join(__dirname, "..", "src", "locales");
const EN_FILE = path.join(LOCALES_DIR, "en.ts");
const HASH_RE = /lastSyncedHash:\s*([0-9a-f]+)/;
/** Extract a canonical string fingerprint from a .ts dictionary file. */
function fingerprint(file) {
const src = fs.readFileSync(file, "utf8");
// Grab every `key: "value"` pair (handles single/double quotes, template
// literals without interpolation). ICU braces are kept verbatim.
const pairs = [];
const re = /([A-Za-z0-9_]+)\s*:\s*(["'`])((?:\\.|(?!\2).)*)\2/g;
let m;
while ((m = re.exec(src)) !== null) {
// Skip the status marker and non-message keys like path/src comments.
if (m[1] === "status") continue;
pairs.push(`${m[1]}\u0000${m[3]}`);
}
return crypto
.createHash("sha256")
.update(pairs.join("\u0001"))
.digest("hex")
.slice(0, 12);
}
const enHash = fingerprint(EN_FILE);
if (process.argv.includes("--hash")) {
console.log(enHash);
process.exit(0);
}
const localeFiles = fs
.readdirSync(LOCALES_DIR)
.filter((f) => /^([a-z]{2}(-[A-Z]{2})?)\.ts$/.test(f) && f !== "en.ts");
const update = process.argv.includes("--update");
let stale = [];
for (const file of localeFiles) {
const full = path.join(LOCALES_DIR, file);
const src = fs.readFileSync(full, "utf8");
const m = src.match(HASH_RE);
if (!m) {
console.error(`✗ ${file}: missing "lastSyncedHash:" header comment`);
stale.push(file);
continue;
}
if (m[1] !== enHash) {
if (update) {
fs.writeFileSync(
full,
src.replace(HASH_RE, `lastSyncedHash: ${enHash}`),
);
console.log(`↻ ${file}: hash updated to ${enHash}`);
} else {
console.error(
`✗ ${file}: stale (has ${m[1]}, en.ts is ${enHash}) — re-sync translations, then run: npm run i18n:check -- --update`,
);
stale.push(file);
}
} else {
console.log(`✓ ${file}: in sync`);
}
}
if (stale.length && !update) {
console.error(
`\n${stale.length} locale file(s) out of sync with src/locales/en.ts.`,
);
process.exit(1);
}
if (update) console.log(`\nAll locale hashes now ${enHash}.`);