Files
TREK/shared/scripts/i18n-parity.mjs
T
Maurice f3e05f48e1 feat(assistant): found places open the place form, notes kept on its own, city walk stops checked off
- Found places: the strip shows only when the answer names one of them, the
  named ones first. A click on a card, or "Add to trip", opens the planner's
  place form filled in with the place on the day in focus, its Google details
  (photos, hours, description) loading beside it; "+ Day" and the day grid
  are gone.
- Trip notes: remember_for_trip runs at once without a card. The assistant
  keeps lasting facts the user mentions (who travels, needs, likes, budget,
  pace) on its own and offers the unsure ones as a follow-up; the chat shows
  "Noted for this trip: ..." with the notes a tap away, and reads them again
  so the dialog never saves over the new note.
- City walks: set_city_walk_visit checks a station off on its day (visited,
  skipped, or the mark taken back), with day_edit as in the day plan, a
  change card naming the station and its walk, and an undo that restores the
  status before while the station is as the change left it.
- Card heads are a step smaller (32px tile, smaller title and count), and the
  dot beside "Working on it" in the header is gone.
2026-10-23 17:03:01 +02:00

313 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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: 'assistant.card.tour.days', because: 'the numbers of the days a tour lies on ("Days 2, 4")' },
{ 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);
}
}