Files
TREK/shared/scripts/i18n-untranslated.mjs
Maurice 5ca6afa789 fix(i18n): the untranslated ratchet sees plural copies and copied phrases
Two holes let English through the ratchet unseen.

A plural form en has no key for (Russian .few and .many, Arabic .two)
was compared with en's value for that same key, which never exists, so a
locale could copy en's text into exactly the forms a new plural group
needs and the count stayed put. Such a form is now compared with every
form en spells out for the group. A form en does declare keeps the exact
comparison: Italian "{count} file" is the Italian plural, not a copy of
en's "{count} file".

The // same-as-en marker excused any value in a Latin-script locale, so
one comment waved a whole copied sentence through. It is honoured only
on a value holding a single plain word, the case shared/CLAUDE.md
already describes. No locale carries the marker today, so nothing that
passed before fails now; the counts are unchanged.
2026-10-08 13:04:42 +02:00

360 lines
14 KiB
JavaScript
Raw Permalink 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:untranslated: English left in the other locales may only shrink.
*
* Parity proves every locale has every key, not that the key holds the
* locale's own language: a copied en string passes it. Two kinds of copy are
* counted per locale and domain file:
*
* marked a declaration carrying `// en-fallback`, the marker a
* translator leaves on a string they could not translate yet
* identical an unmarked value equal to en's, unless the rule in
* `isInvariant` says the text reads the same in every language,
* or a translator confirmed a single word with `// same-as-en`
* in a Latin-script locale (`isExcused`). A plural form en has
* no key for (a Russian `.few` or `.many`) is compared with
* every form en spells out for its group (`enReference`).
*
* Both counts are held at scripts/i18n-untranslated-baseline.json. The check
* fails when either grows past its entry (a file without one may hold none),
* so a feature can no longer ship 26 English copies of a new string. Lowering
* is a separate, explicit step:
*
* node scripts/i18n-untranslated.mjs check against the baseline (CI, via i18n-parity --strict)
* node scripts/i18n-untranslated.mjs --update lower the baseline to today's counts; it never raises an
* entry and drops the ones that reach zero
*
* The two counts are separate on purpose: deleting a marker without
* translating the string moves it from `marked` to `identical`, and the
* identical count refuses it.
*
* Fails closed: a missing or malformed baseline, a locale lacking one of en's
* files, or a value the catalogue reader cannot parse is an error, never a
* pass.
*/
import { pluralFormOf, pluralGroups } from '../src/i18n/plural.ts';
import { asPath, I18N_ROOT, listDomainFiles, listLocales, readCatalog } from './i18n-catalog.mjs';
import { existsSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
export const BASELINE = join(dirname(fileURLToPath(import.meta.url)), 'i18n-untranslated-baseline.json');
/**
* Product and service names, which read the same in every language. A value
* is excused for them, never for an ordinary word: German "Status" or French
* "Transport" are the locale's own words only by coincidence, and a one-word
* label copied from en ("Appearance", "Settings") is the commonest untranslated
* string there is. Names with an inner capital (MapLibre, OpenStreetMap) and
* acronyms need no entry, the word rule below already skips them.
*/
export const BRAND_NAMES = [
'Amap',
'Anthropic',
'Apple Maps',
'Atlas',
'Dawarich',
'Discord',
'Docker',
'Google',
'Google Maps',
'Google Places',
'Home Assistant',
'Immich',
'Mapbox',
'Ntfy',
'Organic Maps',
'Polaroid',
'Synology',
'Synology Photos',
'Vacay',
'Webhook',
'wanderer',
];
/** Unit symbols, short codes rather than words: "{count} km", "{minutes} min". */
export const UNITS = ['km', 'mi', 'ft', 'min'];
/**
* Locales written in a script other than Latin. A Latin word left in one of
* them is English whatever the word, so `// same-as-en` is not honoured
* there. Every locale folder must be listed in exactly one of the two lists,
* so a new locale cannot slip past this rule unclassified.
*/
export const NON_LATIN_LOCALES = ['ar', 'gr', 'ja', 'ko', 'ru', 'th', 'uk', 'zh', 'zh-TW'];
export const LATIN_LOCALES = [
'az',
'br',
'ca',
'cs',
'de',
'en',
'es',
'et',
'fr',
'hu',
'id',
'it',
'nl',
'pl',
'sk',
'sv',
'tr',
'vi',
];
const PLACEHOLDER_RE = /\{[a-zA-Z0-9_]+\}/g;
const TAG_RE = /<\/?[a-zA-Z][^>]*>/g;
// A URL, an e-mail address or a path: anything with a slash or an at sign in it.
const ADDRESS_RE = /\S*[/@]\S*/g;
// Hyphens join a word: "Check-in", "Wi-Fi" and "Auto-Backup" are one each.
const WORD_RE = /\p{L}[\p{L}\p{M}'’-]*/gu;
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Longest first, so "Google Maps" goes as one name before "Google" could split it.
const NAME_RE = new RegExp(
`(?<![\\p{L}\\p{N}])(?:${[...BRAND_NAMES, ...UNITS]
.sort((a, b) => b.length - a.length)
.map(escapeRe)
.join('|')})(?![\\p{L}\\p{N}])`,
'gu',
);
/**
* A word in the sense of the rule: two letters or more and no capital after
* the first. Single letters (the A and Z of a sort label, the v of
* "v{version}"), acronyms (GPX, URLs, 2FA) and names with an inner capital
* (OAuth, AirTrail) are not words here.
*/
export const isPlainWord = (word) => word.length >= 2 && !/\p{Lu}/u.test(word.slice(1));
/**
* Whether a value equal to en's is legitimately the same text in every
* language. True when, after dropping placeholders, markup, addresses, the
* names in BRAND_NAMES and the UNITS, no plain word is left: "{count} km",
* "OAuth", "GPX", "Google Maps", "Atlas", "PDF · {size}". A single plain word
* is counted like a phrase: "Budget" and "Status" may be the locale's word too,
* and that is for a translator to confirm, not for the rule to assume.
*/
export function isInvariant(value) {
return plainWords(value).length === 0;
}
/** The plain words `value` holds once placeholders, markup, addresses, names and units are dropped. */
export function plainWords(value) {
const text = value.replace(PLACEHOLDER_RE, ' ').replace(TAG_RE, ' ').replace(ADDRESS_RE, ' ').replace(NAME_RE, ' ');
return (text.match(WORD_RE) ?? []).filter(isPlainWord);
}
/** Whether `locale` is written in Latin script. Throws for a locale on neither list. */
export function isLatinLocale(locale) {
if (LATIN_LOCALES.includes(locale)) return true;
if (NON_LATIN_LOCALES.includes(locale)) return false;
throw new Error(
`locale ${locale} is on neither LATIN_LOCALES nor NON_LATIN_LOCALES in scripts/i18n-untranslated.mjs`,
);
}
/**
* Whether an unmarked value equal to en's is excused: it is invariant, or a
* translator marked it `// same-as-en` in a Latin-script locale and it holds
* one plain word. The marker is for a word that really is the locale's own
* (German "Status"); a phrase that reads the same as en's is a copy, and the
* marker on it is ignored rather than trusted.
*/
export const isExcused = (value, same, locale) => {
const words = plainWords(value);
return words.length === 0 || (same && isLatinLocale(locale) && words.length === 1);
};
/**
* Every en value a locale's `key` may not repeat. A key en declares has its
* en value. A form of a plural group that en does not declare (Russian
* `.few`, Arabic `.two`) has every form en spells out for the group, since
* it has no en counterpart of its own and a copy of any of them is English.
* A form en declares keeps the exact comparison: Italian "{count} file" for
* en's general "{count} files" is the Italian plural, not en's `.one`.
*/
export function enReference(en, groups) {
const forms = new Map();
for (const [key, value] of en) {
const base = groupOf(key, groups);
if (base !== null) {
if (!forms.has(base)) forms.set(base, new Set());
forms.get(base).add(value);
}
}
return (key) => {
if (en.has(key)) return new Set([en.get(key)]);
const base = groupOf(key, groups);
return base === null ? new Set() : (forms.get(base) ?? new Set());
};
}
/** The plural group `key` belongs to (its base, `key.other` or a category form), else null. */
function groupOf(key, groups) {
if (groups.has(key)) return key;
if (key.endsWith('.other') && groups.has(key.slice(0, -'.other'.length))) return key.slice(0, -'.other'.length);
return pluralFormOf(key, groups)?.base ?? null;
}
/**
* Today's counts: `{ [locale]: { [file]: { marked, identical } } }`, only
* non-zero entries. Throws on a locale missing one of en's files, a locale
* on neither script list or a value the reader cannot parse.
*/
export function countUntranslated(root = I18N_ROOT) {
const locales = listLocales(root);
if (!locales.includes('en')) throw new Error(`${asPath(root)}/en is required as the reference locale`);
const enFiles = listDomainFiles('en', root);
const enValues = new Map(
enFiles.map((f) => {
const en = new Map(readCatalog('en', f, root).map((e) => [e.key, e.value]));
return [f, enReference(en, pluralGroups(en.keys()))];
}),
);
const counts = {};
for (const locale of locales) {
// Classify every folder first: an unlisted locale is an error, not a pass.
isLatinLocale(locale);
if (locale === 'en') continue;
const files = new Set(listDomainFiles(locale, root));
for (const file of enFiles) {
if (!files.has(file)) throw new Error(`${locale}/${file} is missing; run i18n-parity for the file report`);
const enValuesOf = enValues.get(file);
let marked = 0;
let identical = 0;
for (const { key, value, marked: isMarked, same } of readCatalog(locale, file, root)) {
if (isMarked) marked++;
else if (enValuesOf(key).has(value) && !isExcused(value, same, locale)) identical++;
}
if (marked || identical) (counts[locale] ??= {})[file] = { marked, identical };
}
}
return counts;
}
/** The committed baseline. Throws when it is missing or not the expected shape. */
export function readBaseline(path = BASELINE) {
let parsed;
try {
parsed = JSON.parse(readFileSync(path, 'utf8'));
} catch (err) {
throw new Error(`${path} cannot be read: ${err.message}. Restore it from git before running the check again.`);
}
const isCount = (n) => Number.isInteger(n) && n >= 0;
const valid =
parsed !== null &&
typeof parsed === 'object' &&
!Array.isArray(parsed) &&
Object.values(parsed).every(
(files) =>
files !== null &&
typeof files === 'object' &&
Object.values(files).every(
(entry) => entry !== null && typeof entry === 'object' && isCount(entry.marked) && isCount(entry.identical),
),
);
if (!valid) throw new Error(`${path} is not a { locale: { file: { marked, identical } } } map of whole numbers`);
return parsed;
}
const ZERO = { marked: 0, identical: 0 };
/** Every count above its baseline entry, and how many entries could come down. */
export function compare(counts, baseline) {
const grown = [];
let lowerable = 0;
for (const [locale, files] of Object.entries(counts)) {
for (const [file, now] of Object.entries(files)) {
const allowed = baseline[locale]?.[file] ?? ZERO;
for (const kind of ['marked', 'identical']) {
if (now[kind] > allowed[kind]) grown.push({ locale, file, kind, now: now[kind], allowed: allowed[kind] });
}
}
}
for (const [locale, files] of Object.entries(baseline)) {
for (const [file, allowed] of Object.entries(files)) {
const now = counts[locale]?.[file] ?? ZERO;
if (now.marked < allowed.marked || now.identical < allowed.identical) lowerable++;
}
}
return { grown, lowerable };
}
/** The baseline lowered to `counts`: never raised, zero entries dropped, sorted. */
export function lowered(counts, baseline) {
const out = {};
for (const locale of Object.keys(baseline).sort()) {
for (const file of Object.keys(baseline[locale]).sort()) {
const allowed = baseline[locale][file];
const now = counts[locale]?.[file] ?? ZERO;
const entry = {
marked: Math.min(allowed.marked, now.marked),
identical: Math.min(allowed.identical, now.identical),
};
if (entry.marked || entry.identical) (out[locale] ??= {})[file] = entry;
}
}
return out;
}
/** The baseline as committed: one line per file, so a diff shows which counts came down. */
export function serialize(baseline) {
const locales = Object.keys(baseline).map((locale) => {
const files = Object.entries(baseline[locale]).map(
([file, { marked, identical }]) =>
` ${JSON.stringify(file)}: { "marked": ${marked}, "identical": ${identical} }`,
);
return ` ${JSON.stringify(locale)}: {\n${files.join(',\n')}\n }`;
});
return `{\n${locales.join(',\n')}\n}\n`;
}
export function formatGrown(grown) {
return grown.map(
({ locale, file, kind, now, allowed }) =>
` ${locale}/${file}: ${now} ${kind === 'marked' ? 'marked en-fallback' : 'unmarked English copies'}, ` +
`the baseline allows ${allowed}`,
);
}
const total = (map, kind) =>
Object.values(map)
.flatMap((files) => Object.values(files))
.reduce((sum, e) => sum + e[kind], 0);
// Compared by real path, so the check still runs when the script is started through a symlink.
const isCli =
Boolean(process.argv[1]) &&
existsSync(process.argv[1]) &&
realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
if (isCli) {
try {
const counts = countUntranslated();
let baseline = readBaseline();
if (process.argv.includes('--update')) {
baseline = lowered(counts, baseline);
writeFileSync(BASELINE, serialize(baseline));
}
const { grown, lowerable } = compare(counts, baseline);
if (grown.length) {
console.error('Untranslated strings grew past scripts/i18n-untranslated-baseline.json:');
for (const line of formatGrown(grown)) console.error(line);
console.error('Translate the new strings (shared/CLAUDE.md: an English placeholder is not acceptable).');
}
if (lowerable && !process.argv.includes('--update')) {
console.log(
`${lowerable} baseline entr${lowerable === 1 ? 'y is' : 'ies are'} above today's count: run with --update to lower.`,
);
}
console.log(
`untranslated: ${total(counts, 'marked')} marked en-fallback, ${total(counts, 'identical')} unmarked English copies`,
);
if (grown.length) process.exit(1);
} catch (err) {
console.error(`FAIL ${err.message}`);
process.exit(1);
}
}