674 lines
25 KiB
JavaScript
674 lines
25 KiB
JavaScript
#!/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);
|