156 lines
6.2 KiB
JavaScript
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);
|
|
});
|