#!/usr/bin/env node /** * i18n guard — fails the build on translation drift. * * Because `t('some.key')` takes a plain string, TypeScript can't catch a typo or * a key that only exists in one language. This script is the safety net: * * 1. Every `t('…')` key used anywhere in `src/` MUST exist in BOTH `en` and * `es` (a missing key otherwise degrades to the raw key + a console warning). * 2. `en` and `es` must be key-for-key identical, so neither language can drift. * 3. Thai (`th`) may be PARTIAL while it's being translated — a gap falls back * to English and is reported as coverage %, not a failure. A `th` key that * doesn't exist in `en` IS drift and fails. * * Translations are loaded with esbuild (already a dependency) so the TypeScript * source files can be imported directly without a build step. */ import { readFileSync, readdirSync, statSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs'; import { join, extname } from 'node:path'; import { tmpdir } from 'node:os'; import { pathToFileURL } from 'node:url'; import { build } from 'esbuild'; const ROOT = new URL('..', import.meta.url).pathname; const SRC = join(ROOT, 'src'); /** Flatten a nested translations object into a Set of dotted leaf keys. */ function flatten(obj, prefix = '', out = new Set()) { for (const [key, value] of Object.entries(obj)) { const path = prefix ? `${prefix}.${key}` : key; if (value && typeof value === 'object') { flatten(value, path, out); } else if (typeof value === 'string') { out.add(path); } } return out; } /** Load a TS translation module by bundling it to a temp ESM file. */ async function loadTranslations(file) { const result = await build({ entryPoints: [file], bundle: true, format: 'esm', platform: 'node', write: false, logLevel: 'silent', }); const dir = mkdtempSync(join(tmpdir(), 'i18n-check-')); const tmp = join(dir, 'mod.mjs'); writeFileSync(tmp, result.outputFiles[0].text); try { const mod = await import(pathToFileURL(tmp).href); return mod.default; } finally { rmSync(dir, { recursive: true, force: true }); } } /** Recursively list source files under a directory. */ function walk(dir) { const files = []; for (const entry of readdirSync(dir)) { const full = join(dir, entry); if (statSync(full).isDirectory()) { if (entry === 'i18n' || entry === 'test') continue; files.push(...walk(full)); } else if (['.ts', '.tsx'].includes(extname(entry))) { files.push(full); } } return files; } /** Collect every `t('some.key')` usage across the source. */ function collectUsedKeys(files) { const used = new Map(); // key -> [files] const tRe = /(? !th.has(k)).length; for (const key of th) if (!en.has(key)) problems.push(`th has a key not in en: ${key}`); const thCoverage = en.size ? Math.round(((en.size - missingTh) / en.size) * 100) : 100; // 4. Japanese may also be PARTIAL (same rules as Thai). const missingJa = [...en].filter((k) => !ja.has(k)).length; for (const key of ja) if (!en.has(key)) problems.push(`ja has a key not in en: ${key}`); const jaCoverage = en.size ? Math.round(((en.size - missingJa) / en.size) * 100) : 100; // 5. Additional partial locales (same rules as th/ja). const extraLocales = ['pt', 'fr', 'de', 'it', 'nl', 'pl', 'ru', 'ko']; const extraStats = []; for (const lang of extraLocales) { const dict = flatten(await loadTranslations(join(SRC, `i18n/${lang}/index.ts`))); for (const key of dict) if (!en.has(key)) problems.push(`${lang} has a key not in en: ${key}`); const missing = [...en].filter((k) => !dict.has(k)).length; const cov = en.size ? Math.round(((en.size - missing) / en.size) * 100) : 100; extraStats.push(`${lang} ${cov}%`); } if (problems.length) { console.error(`\n✖ i18n check failed (${problems.length}):`); for (const p of problems.slice(0, 200)) console.error(` • ${p}`); if (problems.length > 200) console.error(` …and ${problems.length - 200} more`); process.exit(1); } console.log(`✓ i18n check passed — ${en.size} keys in en/es, ${used.size} used · th ${thCoverage}% · ja ${jaCoverage}% · ${extraStats.join(' · ')}`); } main().catch((err) => { console.error('i18n check crashed:', err); process.exit(1); });