mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
bee500880e
Finishes the plural work the previous wip commit started. t() picks a count string's form by the language's own Intl.PluralRules, and every locale now spells out the forms its language reaches with whole numbers, about 160 count strings in all: one, few and many in Russian, Ukrainian and Polish, one and few in Czech and Slovak, five forms in Arabic, one in the others, none in Japanese, Korean, Chinese, Thai, Vietnamese and Indonesian. Each language was translated and then reviewed on its own. English no longer reads "1 files", "1 updates available" or "photo(s)". Count labels shown next to a number now take that number too: the atlas and admin statistics, the trip cards, journey and shared trip pills, packing templates, receipts, storage usage, the Studio export note. A heading that shared its key with a counted label keeps a key of its own (the boarding pass heading, the Bookings tab), so languages that put a noun after a number in the singular keep a plural heading. The plugin hosts chip passed its count through .replace() after t() and never got a form; three toasts sent the count as text. Mail and push texts for shared photos and suppressed storage failures pick their form in code through pluralForm. A whole number sent as text counts too, which is how in-app notification params arrive, and the plural rule follows the language of the strings on screen while another one still loads. The parity check knows which forms each language needs and refuses missing and impossible ones. It also reads keys that contain a colon now: it skipped them, which hid 58 missing plugin permission texts in Catalan and 35 in Vietnamese, both translated. The client parity test covers every language of the registry instead of a hand-kept list of 19, and the placeholder spec lets a form spell its number out only where its category is that one number. Brazilian Portuguese error screens were in Breton, and an Arabic dashboard label held Hebrew text.
205 lines
8.0 KiB
JavaScript
205 lines
8.0 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.
|
|
//
|
|
// 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 { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
import { dirname, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { getIntlLanguage } from '../src/i18n/languages.ts';
|
|
import { allPluralCategories, integerPluralCategories, pluralFormOf, pluralGroups } from '../src/i18n/plural.ts';
|
|
|
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
const i18nRoot = join(here, '..', 'src', 'i18n');
|
|
|
|
// 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 listLocales() {
|
|
return readdirSync(i18nRoot)
|
|
.filter((name) => statSync(join(i18nRoot, name)).isDirectory())
|
|
// externalNotifications is a barrel module, not a locale.
|
|
.filter((name) => name !== 'externalNotifications');
|
|
}
|
|
|
|
function listDomainFiles(locale) {
|
|
return readdirSync(join(i18nRoot, locale))
|
|
.filter((f) => f.endsWith('.ts') && f !== 'index.ts')
|
|
.sort();
|
|
}
|
|
|
|
function extractKeys(locale, file) {
|
|
const content = readFileSync(join(i18nRoot, 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 };
|
|
}
|
|
|
|
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 });
|
|
}
|
|
}
|
|
}
|
|
|
|
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('; ')}`);
|
|
}
|
|
}
|
|
|
|
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 { 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();
|
|
process.stdout.write(formatReport(filesOnly ? { ...report, keyDrift: [], pluralDrift: [] } : report) + '\n');
|
|
|
|
if (strict) {
|
|
const hasFileDrift = report.fileDrift.length > 0;
|
|
const hasKeyDrift = filesOnly ? false : report.keyDrift.length > 0 || report.pluralDrift.length > 0;
|
|
if (hasFileDrift || hasKeyDrift) process.exit(1);
|
|
}
|
|
}
|