mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
* ci(i18n): fail on new English-identical translations The drift check only warned about locale values identical to English, so untranslated strings kept landing. It now fails on any such value that tools/i18n/identical-en-baseline.json does not record for that locale and key. The baseline captures today's 2,015 entries: legitimately identical values (brand and technical names, language autonyms, PIN) and the existing debt. An entry only covers the English text it recorded, so copying reworded English into a locale fails too. Baseline entries that are no longer identical are reported, not fatal. `pnpm run i18n:baseline:update` rewrites the baseline deliberately; CI runs `pnpm run i18n:validate` (node tests, then the check) and never rewrites it. `--fail-on-identical` remains as a strict audit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(i18n): keep the baseline intact on failed updates and strict audits `--update-baseline` now writes nothing while any locale is unreadable or has missing or extra keys, so an incomplete translation cannot reshape the baseline. `--fail-on-identical` no longer reads the baseline it ignores, so a damaged file cannot block a strict audit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
341 lines
11 KiB
JavaScript
341 lines
11 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Compares every locale in apps/web/src/assets/i18n/ with en.json.
|
|
*
|
|
* Missing or extra keys fail. A value identical to English fails too, unless
|
|
* tools/i18n/identical-en-baseline.json records that exact English text for
|
|
* that locale and key. The baseline holds values that are legitimately the
|
|
* same in a language (brands, technical terms, loanwords such as "PIN") and
|
|
* the untranslated debt that existed when the guard was introduced, so new
|
|
* keys must ship translated while old debt can be paid down.
|
|
*
|
|
* Baseline entries that no longer match an English-identical value are only
|
|
* reported. `--update-baseline` rewrites the baseline from the current files;
|
|
* run it deliberately and review the diff. CI never runs it.
|
|
* `--fail-on-identical` ignores the baseline for a strict audit.
|
|
*/
|
|
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
import { dirname, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
export const I18N_DIR = resolve(__dirname, '../../apps/web/src/assets/i18n');
|
|
export const BASELINE_PATH = resolve(__dirname, 'identical-en-baseline.json');
|
|
const EN_LOCALE = 'en';
|
|
const MAX_EXAMPLES = 5;
|
|
const UPDATE_COMMAND = 'pnpm run i18n:baseline:update';
|
|
const FLAGS = new Set(['--fail-on-identical', '--update-baseline', '--']);
|
|
|
|
function readJson(filePath) {
|
|
return JSON.parse(readFileSync(filePath, 'utf8'));
|
|
}
|
|
|
|
function isPlainObject(value) {
|
|
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
}
|
|
|
|
export function collectLeaves(value, prefix = '', leaves = new Map()) {
|
|
if (!isPlainObject(value)) {
|
|
throw new Error(`Expected object at ${prefix || '<root>'}`);
|
|
}
|
|
|
|
for (const [key, entry] of Object.entries(value)) {
|
|
const path = prefix ? `${prefix}.${key}` : key;
|
|
if (isPlainObject(entry)) {
|
|
collectLeaves(entry, path, leaves);
|
|
} else {
|
|
leaves.set(path, entry);
|
|
}
|
|
}
|
|
|
|
return leaves;
|
|
}
|
|
|
|
/** Keys whose non-empty locale string equals the English string. */
|
|
export function findIdenticalToEnglish(enLeaves, localeLeaves) {
|
|
const identical = new Map();
|
|
for (const [key, enValue] of enLeaves) {
|
|
const localeValue = localeLeaves.get(key);
|
|
if (
|
|
typeof enValue === 'string' &&
|
|
localeValue === enValue &&
|
|
enValue.trim().length > 0
|
|
) {
|
|
identical.set(key, enValue);
|
|
}
|
|
}
|
|
return identical;
|
|
}
|
|
|
|
/**
|
|
* Splits a locale's English-identical keys into baselined and new ones. An
|
|
* entry covers a key only while its recorded text is still the English text,
|
|
* so rewording English and copying the new text into a locale is new debt.
|
|
* Stale entries no longer describe an English-identical value: the key was
|
|
* translated or removed, or its English text changed.
|
|
*/
|
|
export function compareWithBaseline(identical, localeBaseline = new Map()) {
|
|
const baselined = [];
|
|
const unbaselined = [];
|
|
for (const [key, value] of identical) {
|
|
(localeBaseline.get(key) === value ? baselined : unbaselined).push(key);
|
|
}
|
|
const stale = [...localeBaseline]
|
|
.filter(([key, value]) => identical.get(key) !== value)
|
|
.map(([key]) => key);
|
|
return { baselined, unbaselined, stale };
|
|
}
|
|
|
|
/** Validates the baseline JSON into Map<locale, Map<key, englishText>>. */
|
|
export function parseBaseline(raw) {
|
|
if (!isPlainObject(raw)) {
|
|
throw new Error('expected an object of locales');
|
|
}
|
|
const baseline = new Map();
|
|
for (const [locale, entries] of Object.entries(raw)) {
|
|
if (!isPlainObject(entries)) {
|
|
throw new Error(`expected an object of keys for ${locale}`);
|
|
}
|
|
for (const [key, value] of Object.entries(entries)) {
|
|
if (typeof value !== 'string') {
|
|
throw new Error(`expected English text at ${locale} ${key}`);
|
|
}
|
|
}
|
|
baseline.set(locale, new Map(Object.entries(entries)));
|
|
}
|
|
return baseline;
|
|
}
|
|
|
|
/** Serialises Map<locale, Map<key, text>> with locales and keys sorted. */
|
|
export function serializeBaseline(identicalByLocale) {
|
|
const byCodeUnit = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
const output = {};
|
|
for (const locale of [...identicalByLocale.keys()].sort(byCodeUnit)) {
|
|
const entries = identicalByLocale.get(locale);
|
|
if (entries.size > 0) {
|
|
output[locale] = Object.fromEntries(
|
|
[...entries].sort(([a], [b]) => byCodeUnit(a, b))
|
|
);
|
|
}
|
|
}
|
|
return `${JSON.stringify(output, null, 4)}\n`;
|
|
}
|
|
|
|
function countChanges(from, to, onChange = () => undefined) {
|
|
let changes = 0;
|
|
for (const [locale, entries] of from) {
|
|
for (const [key, value] of entries) {
|
|
if (to.get(locale)?.get(key) !== value) {
|
|
changes += 1;
|
|
onChange(locale, key, value);
|
|
}
|
|
}
|
|
}
|
|
return changes;
|
|
}
|
|
|
|
function formatExamples(values) {
|
|
if (values.length === 0) {
|
|
return '';
|
|
}
|
|
|
|
const suffix = values.length > MAX_EXAMPLES ? ', ...' : '';
|
|
return ` (${values.slice(0, MAX_EXAMPLES).join(', ')}${suffix})`;
|
|
}
|
|
|
|
function formatError(error) {
|
|
return error instanceof Error ? error.message : String(error);
|
|
}
|
|
|
|
function readBaseline(baselinePath) {
|
|
let raw;
|
|
try {
|
|
raw = readJson(baselinePath);
|
|
} catch (error) {
|
|
if (error?.code === 'ENOENT') {
|
|
return new Map();
|
|
}
|
|
throw new Error(`${baselinePath}: ${formatError(error)}`);
|
|
}
|
|
try {
|
|
return parseBaseline(raw);
|
|
} catch (error) {
|
|
throw new Error(`${baselinePath}: ${formatError(error)}`);
|
|
}
|
|
}
|
|
|
|
function listLocales(i18nDir) {
|
|
return readdirSync(i18nDir)
|
|
.filter((file) => file.endsWith('.json'))
|
|
.map((file) => file.slice(0, -'.json'.length))
|
|
.filter((locale) => locale !== EN_LOCALE)
|
|
.sort();
|
|
}
|
|
|
|
function checkLocale({ i18nDir, locale, enLeaves, baseline, mode }) {
|
|
const localeLeaves = collectLeaves(
|
|
readJson(resolve(i18nDir, `${locale}.json`))
|
|
);
|
|
const missing = [...enLeaves.keys()].filter(
|
|
(key) => !localeLeaves.has(key)
|
|
);
|
|
const extra = [...localeLeaves.keys()].filter((key) => !enLeaves.has(key));
|
|
const identical = findIdenticalToEnglish(enLeaves, localeLeaves);
|
|
const { baselined, unbaselined, stale } = compareWithBaseline(
|
|
identical,
|
|
baseline.get(locale)
|
|
);
|
|
const rejected =
|
|
mode === 'strict'
|
|
? [...identical.keys()]
|
|
: mode === 'update'
|
|
? []
|
|
: unbaselined;
|
|
return { missing, extra, identical, baselined, rejected, stale };
|
|
}
|
|
|
|
function reportLocale(log, file, result, mode) {
|
|
const { missing, extra, identical, baselined, rejected, stale } = result;
|
|
const failed = missing.length + extra.length + rejected.length > 0;
|
|
const baselineNote =
|
|
mode === 'baseline' ? ` baselined=${baselined.length}` : '';
|
|
log(
|
|
`${failed ? 'FAIL' : 'ok'} ${file}: missing=${
|
|
missing.length
|
|
}${formatExamples(missing)} extra=${extra.length}${formatExamples(
|
|
extra
|
|
)} identical_en=${identical.size}${baselineNote}`
|
|
);
|
|
if (mode === 'baseline' && stale.length > 0) {
|
|
log(
|
|
`note ${file}: ${stale.length} baseline entries are no longer English-identical${formatExamples(
|
|
stale
|
|
)}`
|
|
);
|
|
}
|
|
return failed;
|
|
}
|
|
|
|
function reportRejected(log, rejected, enLeaves, mode) {
|
|
if (rejected.length === 0) {
|
|
return;
|
|
}
|
|
const scope = mode === 'strict' ? '' : ' and are not in the baseline';
|
|
log(`${rejected.length} value(s) equal English${scope}:`);
|
|
for (const [file, key] of rejected) {
|
|
log(` ${file} ${key}: ${JSON.stringify(enLeaves.get(key))}`);
|
|
}
|
|
if (mode === 'baseline') {
|
|
log(
|
|
`Translate them (see the i18n-fill skill). Only if a value is legitimately the same in that language, run \`${UPDATE_COMMAND}\` and commit the reviewed baseline.`
|
|
);
|
|
}
|
|
}
|
|
|
|
function writeBaseline(log, baselinePath, previous, identicalByLocale) {
|
|
const added = countChanges(
|
|
identicalByLocale,
|
|
previous,
|
|
(locale, key, value) =>
|
|
log(` + ${locale} ${key}: ${JSON.stringify(value)}`)
|
|
);
|
|
const removed = countChanges(previous, identicalByLocale);
|
|
writeFileSync(baselinePath, serializeBaseline(identicalByLocale));
|
|
log(
|
|
`Baseline written: ${added} added, ${removed} removed. Review every added entry before committing.`
|
|
);
|
|
}
|
|
|
|
/** Runs the check and returns the process exit code. */
|
|
export function run({
|
|
i18nDir = I18N_DIR,
|
|
baselinePath = BASELINE_PATH,
|
|
argv = [],
|
|
log = console.log,
|
|
} = {}) {
|
|
const strict = argv.includes('--fail-on-identical');
|
|
const update = argv.includes('--update-baseline');
|
|
if (argv.some((arg) => !FLAGS.has(arg)) || (strict && update)) {
|
|
log(
|
|
`FAIL unsupported arguments: ${argv.join(' ')}; pass at most one of --fail-on-identical and --update-baseline.`
|
|
);
|
|
return 1;
|
|
}
|
|
const mode = strict ? 'strict' : update ? 'update' : 'baseline';
|
|
|
|
let enLeaves;
|
|
let baseline;
|
|
try {
|
|
enLeaves = collectLeaves(
|
|
readJson(resolve(i18nDir, `${EN_LOCALE}.json`))
|
|
);
|
|
// A strict audit ignores the baseline, so a damaged file cannot block it.
|
|
baseline = strict ? new Map() : readBaseline(baselinePath);
|
|
} catch (error) {
|
|
if (!enLeaves || !update) {
|
|
log(`FAIL ${formatError(error)}`);
|
|
return 1;
|
|
}
|
|
log(`note replacing unreadable baseline: ${formatError(error)}`);
|
|
baseline = new Map();
|
|
}
|
|
|
|
const locales = listLocales(i18nDir);
|
|
const identicalByLocale = new Map();
|
|
const rejected = [];
|
|
let failed = false;
|
|
let staleTotal = 0;
|
|
|
|
for (const locale of locales) {
|
|
const file = `${locale}.json`;
|
|
let result;
|
|
try {
|
|
result = checkLocale({ i18nDir, locale, enLeaves, baseline, mode });
|
|
} catch (error) {
|
|
failed = true;
|
|
log(`FAIL ${file}: ${formatError(error)}`);
|
|
continue;
|
|
}
|
|
identicalByLocale.set(locale, result.identical);
|
|
rejected.push(...result.rejected.map((key) => [file, key]));
|
|
staleTotal += result.stale.length;
|
|
failed = reportLocale(log, file, result, mode) || failed;
|
|
}
|
|
|
|
if (mode === 'baseline') {
|
|
for (const [locale, entries] of baseline) {
|
|
if (!locales.includes(locale)) {
|
|
staleTotal += entries.size;
|
|
log(`note ${locale}: baseline lists a locale with no file`);
|
|
}
|
|
}
|
|
if (staleTotal > 0) {
|
|
log(
|
|
`note ${staleTotal} stale baseline entries; run \`${UPDATE_COMMAND}\` to shrink the baseline.`
|
|
);
|
|
}
|
|
}
|
|
reportRejected(log, rejected, enLeaves, mode);
|
|
|
|
if (update) {
|
|
// Only a complete, readable set of locales is a state worth recording.
|
|
if (failed) {
|
|
log(
|
|
'FAIL baseline not written: fix the unreadable, missing or extra keys above first.'
|
|
);
|
|
return 1;
|
|
}
|
|
writeBaseline(log, baselinePath, baseline, identicalByLocale);
|
|
}
|
|
|
|
return failed ? 1 : 0;
|
|
}
|
|
|
|
const isMain =
|
|
process.argv[1] &&
|
|
resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
|
|
|
|
if (isMain) {
|
|
process.exitCode = run({ argv: process.argv.slice(2) });
|
|
}
|