SkateSpots/scripts/check-i18n.mjs

156 lines
6.2 KiB
JavaScript

#!/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 = /(?<![\w.])t\(\s*['"]([^'"]+)['"]/g;
// Also pick up translation keys declared in data tables, e.g.
// `{ value: 'group', key: 'labUi.coachForm.formatGroup' }` or
// `titleKey:` / `labelKey:` / `badgeKey:`. These are looked up dynamically via
// `t(option.key)`, so the literal-`t()` scan alone would miss them.
const dataRe = /\b(?:key|titleKey|labelKey|badgeKey):\s*['"]([a-z][A-Za-z0-9]*(?:\.[A-Za-z0-9]+)+)['"]/g;
for (const file of files) {
const text = readFileSync(file, 'utf8');
const rel = file.replace(`${ROOT}/`, '');
for (const re of [tRe, dataRe]) {
re.lastIndex = 0;
let match;
while ((match = re.exec(text)) !== null) {
const key = match[1];
if (!used.has(key)) used.set(key, []);
used.get(key).push(rel);
}
}
}
return used;
}
async function main() {
const en = flatten(await loadTranslations(join(SRC, 'i18n/en.ts')));
const es = flatten(await loadTranslations(join(SRC, 'i18n/es.ts')));
const th = flatten(await loadTranslations(join(SRC, 'i18n/th/index.ts')));
const ja = flatten(await loadTranslations(join(SRC, 'i18n/ja/index.ts')));
const problems = [];
// 1. en/es parity.
for (const key of en) if (!es.has(key)) problems.push(`missing in es: ${key}`);
for (const key of es) if (!en.has(key)) problems.push(`missing in en: ${key}`);
// 2. every used key exists in both languages.
const used = collectUsedKeys(walk(SRC));
for (const [key, files] of used) {
if (!en.has(key)) problems.push(`used but missing in en: ${key} (${files[0]})`);
if (!es.has(key)) problems.push(`used but missing in es: ${key} (${files[0]})`);
}
// 3. Thai may be PARTIAL while it's being translated (missing keys fall back
// to English), so a gap is reported as coverage, not a failure. But a Thai
// key that doesn't exist in `en` IS drift → fail.
const missingTh = [...en].filter((k) => !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', 'zh-CN', 'zh-TW', 'uk', 'tr', 'vi', 'id', 'sv', 'cs', 'ro', 'el'];
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);
});