mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
9a7c17e7cc
The count-string gate only knew {count} and {n}, the params t() picks a
plural form by. A quantity named anything else kept one form in every
language and passed: Russian read "Все {days} дней" for 2 days and
"{objects} объектов" for 3 objects, Greek "{minutes} λεπτά" for 1 minute.
Twelve such en strings become plural groups with their forms in all 27
locales, the quantity renamed to {count}: the trip length limit, the
chat image limit, the API key limit, the storage move prompt and its
reclaimable line, the Dawarich wish rule and suggestion duration, the
road trip lateness warnings, and the road trip echo and window texts.
Their callers pass the count as a number; the storage panels used to
pass it as a string, which no form is picked by. The reclaimable line
loses its dash for a comma in every locale while it is rewritten.
i18n-parity now treats the quantity names in QUANTITY_PARAMS ({days},
{minutes}, {objects}, {max} and the like) like {count}. The four strings
where the number is followed by nothing but a unit symbol (the Dawarich
durations, the upload size limit) go on NOT_PLURAL with that reason.
312 lines
13 KiB
JavaScript
312 lines
13 KiB
JavaScript
#!/usr/bin/env node
|
||
// i18n parity check — keeps Julien's "every locale = same files + keys as en"
|
||
// DoD honest over time.
|
||
//
|
||
// What it checks per non-en locale:
|
||
// 1. File set parity: every domain file that exists in en/ must exist in this
|
||
// locale's dir; no extra domain files allowed.
|
||
// 2. Key set parity: for each shared domain file, the top-level translation
|
||
// keys must match exactly (no missing, no extra).
|
||
//
|
||
// Output: structured report grouped by locale, plus a `--strict` flag that
|
||
// returns exit-code 1 when any drift is present (intended for CI). Without
|
||
// `--strict` the script exits 0 and prints, so it can also run as a non-blocking
|
||
// audit during translation work.
|
||
// 3. Plural forms: a count-bearing string (`key` or `key.other`, plus at
|
||
// least one of `key.zero|one|two|few|many` in en) is a plural group. Its
|
||
// category keys are not compared with en's, since Russian needs `.few`
|
||
// and `.many` and Japanese needs none. Instead every locale must spell
|
||
// out each category whole counts reach in its language, and may not
|
||
// carry one its language never selects. The rules come from
|
||
// `Intl.PluralRules`, through the same helpers the client resolves with.
|
||
// 4. Count strings: an en string carrying {count} or {n} (the two params
|
||
// `t()` picks a plural form by) must be a plural group, so every
|
||
// language gets the forms it needs. So must one carrying a quantity
|
||
// under another name ({days}, {minutes}, {objects}, QUANTITY_PARAMS),
|
||
// which no form is picked by: it is renamed to {count}. The exceptions
|
||
// are NOT_PLURAL: a number that is never a quantity ("Day {n}", a step,
|
||
// a multiplier), or one followed only by a unit symbol.
|
||
// 5. Untranslated strings: the marked `// en-fallback` and unmarked English
|
||
// copies per locale and file may only shrink (i18n-untranslated.mjs,
|
||
// which also lowers the baseline with --update).
|
||
//
|
||
// Limitations: we only parse *top-level* string keys (those declared as the
|
||
// first column of the file, matching the regex below). Nested objects, function
|
||
// bodies, and inline comments are ignored. This matches how `t(key)` calls
|
||
// resolve at runtime in TranslationContext.
|
||
|
||
import { readFileSync } from 'node:fs';
|
||
import { join } from 'node:path';
|
||
import { getIntlLanguage } from '../src/i18n/languages.ts';
|
||
import { allPluralCategories, integerPluralCategories, pluralFormOf, pluralGroups } from '../src/i18n/plural.ts';
|
||
import { I18N_ROOT, listDomainFiles, listLocales, readCatalog } from './i18n-catalog.mjs';
|
||
import { compare, countUntranslated, formatGrown, readBaseline } from './i18n-untranslated.mjs';
|
||
|
||
// Match a top-level translation key declaration: leading whitespace, then a
|
||
// quoted key (must start with a lowercase letter), then a colon. This is the
|
||
// exact pattern every domain file uses. Keys may hold a colon themselves
|
||
// (admin.plugins.perm.db:read:trips); without it in the class those keys were
|
||
// never compared, and two locales lacked dozens of them unnoticed.
|
||
const TOP_LEVEL_KEY_RE = /^\s*'([a-z][a-zA-Z0-9.\-_:]*)'\s*:/gm;
|
||
|
||
function extractKeys(locale, file) {
|
||
const content = readFileSync(join(I18N_ROOT, locale, file), 'utf8');
|
||
const keys = new Set();
|
||
for (const match of content.matchAll(TOP_LEVEL_KEY_RE)) {
|
||
keys.add(match[1]);
|
||
}
|
||
return keys;
|
||
}
|
||
|
||
function withoutVariants(keys, groups) {
|
||
return new Set([...keys].filter((k) => !pluralFormOf(k, groups)));
|
||
}
|
||
|
||
/** What one locale owes the plural groups of one file. */
|
||
function checkPluralForms(locale, keys, groups) {
|
||
const intl = getIntlLanguage(locale);
|
||
const required = integerPluralCategories(intl).filter((c) => c !== 'other');
|
||
const allowed = new Set(allPluralCategories(intl));
|
||
const missing = [];
|
||
const invalid = [];
|
||
for (const base of groups) {
|
||
for (const c of required) if (!keys.has(`${base}.${c}`)) missing.push(`${base}.${c}`);
|
||
}
|
||
for (const key of keys) {
|
||
const form = pluralFormOf(key, groups);
|
||
if (form && !allowed.has(form.category)) invalid.push(key);
|
||
}
|
||
return { missing, invalid };
|
||
}
|
||
|
||
/**
|
||
* en strings that carry {count} or {n} without being a quantity of
|
||
* something, so no word in any language agrees with the number. Everything
|
||
* else with a count is a plural group. An entry that stops matching an
|
||
* ungrouped count string fails the check, so the list cannot go stale.
|
||
*/
|
||
export const NOT_PLURAL = [
|
||
{ key: 'collab.polls.optionPlaceholder', because: 'the position of a poll option ("Option 3")' },
|
||
{ key: 'collections.importOnDay', because: 'the number of the day a place is planned on' },
|
||
{ key: 'dashboard.atlas.aroundEquator', because: 'a decimal multiplier ("≈ 1.37×"), not a count of things' },
|
||
{ key: 'dayplan.dayN', because: 'the number of a day in the trip' },
|
||
{ key: 'planner.dayN', because: 'the number of a day in the trip' },
|
||
{ key: 'help.center.step', because: 'the position of a step in a guide' },
|
||
{ key: 'help.center.stepOf', because: 'the position of a step in a guide' },
|
||
{ key: 'help.center.imageAlt', because: 'the position of a step in a guide' },
|
||
{ key: 'system_notice.pager.goto', because: 'the position of a notice in the pager' },
|
||
{ key: 'tours.addedToDay', because: 'the number of the day a tour was added to' },
|
||
{ key: 'tours.planner.waypointLabel', because: 'the position of a waypoint on the tour' },
|
||
{ key: 'dawarich.duration.minutes', because: 'a duration in a unit symbol (min), the same for any number' },
|
||
{ key: 'dawarich.duration.hours', because: 'a duration in a unit symbol (h), the same for any number' },
|
||
{ key: 'dawarich.duration.hoursMinutes', because: 'a duration in unit symbols (h, min), the same for any number' },
|
||
{ key: 'files.uploadErrorSize', because: 'a size limit in a unit symbol (MB), the same for any number' },
|
||
];
|
||
|
||
/**
|
||
* Param names that hold a quantity of something but are not the ones `t()`
|
||
* picks a plural form by. A string using one keeps a single form in every
|
||
* language ("Все {days} дней" for 2 days), so it is held like {count}: rename
|
||
* the param to {count} and make the string a plural group.
|
||
*/
|
||
export const QUANTITY_PARAMS = [
|
||
'days',
|
||
'nights',
|
||
'weeks',
|
||
'months',
|
||
'years',
|
||
'hours',
|
||
'minutes',
|
||
'seconds',
|
||
'items',
|
||
'objects',
|
||
'max',
|
||
];
|
||
|
||
const COUNT_PARAM_RE = new RegExp(`\\{(?:count|n|${QUANTITY_PARAMS.join('|')})\\}`);
|
||
|
||
/**
|
||
* en strings carrying a count or quantity param outside any plural group,
|
||
* and NOT_PLURAL entries that no longer name such a string.
|
||
*/
|
||
function checkCountStrings(enFiles, notPlural = NOT_PLURAL, root = I18N_ROOT) {
|
||
const allowed = new Set(notPlural.map((e) => e.key));
|
||
const ungrouped = [];
|
||
const seen = new Set();
|
||
for (const file of enFiles) {
|
||
const entries = readCatalog('en', file, root);
|
||
const groups = pluralGroups(entries.map((e) => e.key));
|
||
for (const { key, value } of entries) {
|
||
if (!COUNT_PARAM_RE.test(value) || groups.has(key) || pluralFormOf(key, groups)) continue;
|
||
if (key.endsWith('.other') && groups.has(key.slice(0, -'.other'.length))) continue;
|
||
if (allowed.has(key)) seen.add(key);
|
||
else ungrouped.push({ file, key });
|
||
}
|
||
}
|
||
return { ungrouped, stale: [...allowed].filter((key) => !seen.has(key)) };
|
||
}
|
||
|
||
/** The untranslated ratchet's verdict, with a reading error reported rather than thrown. */
|
||
function checkUntranslated() {
|
||
try {
|
||
const { grown, lowerable } = compare(countUntranslated(), readBaseline());
|
||
return { error: null, grown, lowerable };
|
||
} catch (err) {
|
||
return { error: err.message, grown: [], lowerable: 0 };
|
||
}
|
||
}
|
||
|
||
function diffSets(reference, candidate) {
|
||
const missing = [];
|
||
const extra = [];
|
||
for (const k of reference) if (!candidate.has(k)) missing.push(k);
|
||
for (const k of candidate) if (!reference.has(k)) extra.push(k);
|
||
return { missing, extra };
|
||
}
|
||
|
||
function checkParity() {
|
||
const locales = listLocales();
|
||
if (!locales.includes('en')) {
|
||
throw new Error('shared/src/i18n/en is required as the reference locale');
|
||
}
|
||
const enFiles = listDomainFiles('en');
|
||
const enKeysByDomain = new Map();
|
||
for (const f of enFiles) enKeysByDomain.set(f, extractKeys('en', f));
|
||
|
||
const report = { fileDrift: [], keyDrift: [], pluralDrift: [] };
|
||
const groupsByDomain = new Map();
|
||
for (const f of enFiles) {
|
||
const groups = pluralGroups(enKeysByDomain.get(f));
|
||
groupsByDomain.set(f, groups);
|
||
const { missing, invalid } = checkPluralForms('en', enKeysByDomain.get(f), groups);
|
||
if (missing.length || invalid.length) report.pluralDrift.push({ locale: 'en', file: f, missing, invalid });
|
||
}
|
||
|
||
for (const locale of locales) {
|
||
if (locale === 'en') continue;
|
||
|
||
const localeFiles = listDomainFiles(locale);
|
||
const { missing: missingFiles, extra: extraFiles } = diffSets(
|
||
new Set(enFiles),
|
||
new Set(localeFiles),
|
||
);
|
||
|
||
if (missingFiles.length || extraFiles.length) {
|
||
report.fileDrift.push({ locale, missing: missingFiles, extra: extraFiles });
|
||
}
|
||
|
||
for (const file of enFiles) {
|
||
if (!localeFiles.includes(file)) continue;
|
||
const localeKeys = extractKeys(locale, file);
|
||
const groups = groupsByDomain.get(file);
|
||
const { missing, extra } = diffSets(
|
||
withoutVariants(enKeysByDomain.get(file), groups),
|
||
withoutVariants(localeKeys, groups),
|
||
);
|
||
if (missing.length || extra.length) {
|
||
report.keyDrift.push({ locale, file, missing, extra });
|
||
}
|
||
const plural = checkPluralForms(locale, localeKeys, groups);
|
||
if (plural.missing.length || plural.invalid.length) {
|
||
report.pluralDrift.push({ locale, file, ...plural });
|
||
}
|
||
}
|
||
}
|
||
|
||
report.countDrift = checkCountStrings(enFiles);
|
||
report.untranslated = checkUntranslated();
|
||
return report;
|
||
}
|
||
|
||
function formatReport(report) {
|
||
const lines = [];
|
||
|
||
if (report.fileDrift.length === 0) {
|
||
lines.push('File parity: OK');
|
||
} else {
|
||
lines.push(`File parity: ${report.fileDrift.length} locale(s) with file drift`);
|
||
for (const { locale, missing, extra } of report.fileDrift) {
|
||
if (missing.length) lines.push(` ${locale}: missing ${missing.join(', ')}`);
|
||
if (extra.length) lines.push(` ${locale}: extra ${extra.join(', ')}`);
|
||
}
|
||
}
|
||
|
||
if (report.keyDrift.length === 0) {
|
||
lines.push('Key parity: OK');
|
||
} else {
|
||
lines.push(`Key parity: ${report.keyDrift.length} domain file(s) with key drift`);
|
||
for (const { locale, file, missing, extra } of report.keyDrift) {
|
||
const parts = [];
|
||
if (missing.length) parts.push(`missing ${missing.length} (e.g. ${missing.slice(0, 3).join(', ')})`);
|
||
if (extra.length) parts.push(`extra ${extra.length} (e.g. ${extra.slice(0, 3).join(', ')})`);
|
||
lines.push(` ${locale}/${file}: ${parts.join('; ')}`);
|
||
}
|
||
}
|
||
|
||
if (report.pluralDrift.length === 0) {
|
||
lines.push('Plural forms: OK');
|
||
} else {
|
||
lines.push(`Plural forms: ${report.pluralDrift.length} domain file(s) with missing or impossible forms`);
|
||
for (const { locale, file, missing, invalid } of report.pluralDrift) {
|
||
const parts = [];
|
||
if (missing.length) parts.push(`missing ${missing.join(', ')}`);
|
||
if (invalid.length) parts.push(`never selected ${invalid.join(', ')}`);
|
||
lines.push(` ${locale}/${file}: ${parts.join('; ')}`);
|
||
}
|
||
}
|
||
|
||
if (report.countDrift) {
|
||
const { ungrouped, stale } = report.countDrift;
|
||
if (ungrouped.length === 0 && stale.length === 0) {
|
||
lines.push('Count strings: OK');
|
||
} else {
|
||
lines.push('Count strings: en strings with {count}, {n} or a quantity param that are no plural group');
|
||
for (const { file, key } of ungrouped) {
|
||
lines.push(
|
||
` en/${file}: ${key} (name the quantity {count} and add ${key}.one and the other forms, see shared/CLAUDE.md)`,
|
||
);
|
||
}
|
||
for (const key of stale) lines.push(` NOT_PLURAL lists ${key}, which is no ungrouped count string: remove it`);
|
||
}
|
||
}
|
||
|
||
if (report.untranslated) {
|
||
const { error, grown, lowerable } = report.untranslated;
|
||
if (error) {
|
||
lines.push(`Untranslated strings: cannot check, ${error}`);
|
||
} else if (grown.length === 0) {
|
||
lines.push(
|
||
'Untranslated strings: OK' +
|
||
(lowerable ? ` (${lowerable} baseline entries can come down: node scripts/i18n-untranslated.mjs --update)` : ''),
|
||
);
|
||
} else {
|
||
lines.push('Untranslated strings: grew past scripts/i18n-untranslated-baseline.json');
|
||
lines.push(...formatGrown(grown));
|
||
}
|
||
}
|
||
|
||
return lines.join('\n');
|
||
}
|
||
|
||
// Export a structured API for vitest. The CLI entry point only runs when
|
||
// executed directly (`node scripts/i18n-parity.mjs`), so importing this file
|
||
// from a spec does not produce side effects.
|
||
export { checkCountStrings, checkParity, formatReport };
|
||
|
||
const isCli = process.argv[1] && process.argv[1].endsWith('i18n-parity.mjs');
|
||
if (isCli) {
|
||
const strict = process.argv.includes('--strict');
|
||
const filesOnly = process.argv.includes('--files-only');
|
||
const report = checkParity();
|
||
const shown = filesOnly ? { ...report, keyDrift: [], pluralDrift: [], countDrift: null, untranslated: null } : report;
|
||
process.stdout.write(formatReport(shown) + '\n');
|
||
|
||
if (strict) {
|
||
const hasFileDrift = report.fileDrift.length > 0;
|
||
const hasKeyDrift = filesOnly ? false : report.keyDrift.length > 0 || report.pluralDrift.length > 0;
|
||
const hasCountDrift = !filesOnly && (report.countDrift.ungrouped.length > 0 || report.countDrift.stale.length > 0);
|
||
const hasUntranslated = !filesOnly && (report.untranslated.error !== null || report.untranslated.grown.length > 0);
|
||
if (hasFileDrift || hasKeyDrift || hasCountDrift || hasUntranslated) process.exit(1);
|
||
}
|
||
}
|