mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 14:06:27 +00:00
5f0865a476
The ratchet excused any value equal to en that held at most one plain word, so "Settings", "Appearance", "Readability" or "Users" copied into another locale were never counted, Japanese and Arabic included. Buttons, tabs and section titles are mostly one word, so a feature could still add 26 English copies of its labels and stay green. An identical value is now excused only when nothing ordinary is left after dropping placeholders, markup, addresses, a listed set of product names (BRAND_NAMES, which now holds one-word names such as Atlas, Immich and Mapbox) and unit symbols (UNITS). A word that really is the locale's own, German "Status" or French "Transport", carries a `// same-as-en` marker on its declaration, which the catalogue reader now reports. The marker is honoured only in Latin-script locales: a Latin word left in Japanese or Russian is English whatever it says. Every locale folder must be on LATIN_LOCALES or NON_LATIN_LOCALES, so a new language cannot pass the rule unclassified. The baseline is re-recorded from the narrower rule: the unmarked copies go from 1252 to 3238, the marked fallbacks stay at 3157, and no entry went down. This is the measured state under the new rule, not a raised gate; translating, or marking a word that is genuinely the same, lowers it with --update as before.
310 lines
12 KiB
JavaScript
310 lines
12 KiB
JavaScript
#!/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 it with `// same-as-en` in a
|
||
* Latin-script locale (`isExcused`)
|
||
*
|
||
* 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 { 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) {
|
||
const text = value.replace(PLACEHOLDER_RE, ' ').replace(TAG_RE, ' ').replace(ADDRESS_RE, ' ').replace(NAME_RE, ' ');
|
||
return !(text.match(WORD_RE) ?? []).some(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.
|
||
*/
|
||
export const isExcused = (value, same, locale) => isInvariant(value) || (same && isLatinLocale(locale));
|
||
|
||
/**
|
||
* 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) => [f, new Map(readCatalog('en', f, root).map((e) => [e.key, e.value]))]));
|
||
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 en = 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 (en.get(key) === 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);
|
||
}
|
||
}
|