Files
TREK/shared/scripts/i18n-catalog.mjs
T
Maurice f00c354d0d fix(i18n): add the two keys the client named without en having them
BudgetPanel titled its add button with budget.addCategory (since April)
and the trip form labelled its cover remove button with common.remove
(since August). Neither key was ever in en, and t() returns an unknown
key unchanged, so both rendered as the raw key in every language. Both
are added to all 27 locales.

Locale parity compares the locales with en, never en with the code, so
nothing could catch this. client/scripts/i18n-keys.mjs (npm run
lint:i18n-keys) now resolves every literal key src/ names against en:
the first argument of t(), tHtml() and tr() (both sides of a conditional
included), <TransHtml html>, the fallback of translateApiError and any
*Key property or prop. A template key, a prefix with the rest appended
and the help registry's key builders pass when their fixed parts match
an en key; the one template with no fixed part is on an allow-list that
names the keys it resolves to, and an entry nothing uses fails. With
--unused it lists the en keys nothing reaches, as information only.

en is read with shared/scripts/i18n-catalog.mjs, a reader for the
locale tables as text that the parity tooling will share. It refuses a
value that is not one string literal instead of skipping the key.
2026-10-08 13:04:41 +02:00

104 lines
4.0 KiB
JavaScript

/**
* Reads the locale tables as text, for the i18n scripts beside this file.
*
* Plain .mjs outside `src/` for the reason i18n-parity.mjs gives: shared's
* typecheck runs without @types/node, and the CI parity job runs these scripts
* with no install at all. The tables are parsed rather than imported so the
* checks stay synchronous and see the `// en-fallback` markers, which an
* import would strip.
*
* A value is one string literal, single or double quoted, on the key's line or
* the next one (the two shapes Prettier writes). Anything else after a key (a
* concatenation, a template literal, a call) is an error rather than a skipped
* key: a check that silently reads less than the file holds passes for the
* wrong reason.
*/
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
export const I18N_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'i18n');
export const FALLBACK_MARKER = '// en-fallback';
/** A root given as a file URL (what a spec can build without node types) or a path. */
export const asPath = (root) => (root instanceof URL ? fileURLToPath(root) : root);
/** Every locale folder (externalNotifications is a barrel module, not a locale). */
export function listLocales(root = I18N_ROOT) {
const dir = asPath(root);
return readdirSync(dir)
.filter((name) => statSync(join(dir, name)).isDirectory())
.filter((name) => name !== 'externalNotifications');
}
/** The domain files of one locale, sorted. */
export function listDomainFiles(locale, root = I18N_ROOT) {
return readdirSync(join(asPath(root), locale))
.filter((f) => f.endsWith('.ts') && f !== 'index.ts')
.sort();
}
const KEY_LINE_RE = /^\s*'([a-z][a-zA-Z0-9.\-_:]*)'\s*:(.*)$/;
const ESCAPES = { n: '\n', r: '\r', t: '\t', b: '\b', f: '\f', v: '\v', 0: '\0' };
/** The value of a JS string literal body (between the quotes). */
export function decodeLiteral(body) {
return body.replace(/\\(u\{[0-9a-fA-F]+\}|u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2}|\r?\n|[\s\S])/g, (_, esc) => {
if (esc[0] === 'u' && esc.length > 1) return String.fromCodePoint(parseInt(esc.replace(/[u{}]/g, ''), 16));
if (esc[0] === 'x' && esc.length === 3) return String.fromCharCode(parseInt(esc.slice(1), 16));
if (esc === '\n' || esc === '\r\n') return '';
return ESCAPES[esc] ?? esc;
});
}
/** Reads one quoted literal at the start of `text`; null when `text` does not start with one. */
function readLiteral(text) {
const quote = text[0];
if (quote !== "'" && quote !== '"') return null;
for (let i = 1; i < text.length; i++) {
if (text[i] === '\\') {
i++;
continue;
}
if (text[i] === '\n') return null;
if (text[i] === quote) return { value: decodeLiteral(text.slice(1, i)), rest: text.slice(i + 1) };
}
return null;
}
/**
* Every top-level entry of one table's source: key, value, whether the
* declaration carries the fallback marker, and the line it starts on.
* Throws with file and line on anything it cannot read.
*/
export function parseCatalog(source, label = 'catalogue') {
const lines = source.replace(/\r\n?/g, '\n').split('\n');
const entries = [];
for (let i = 0; i < lines.length; i++) {
const match = lines[i].match(KEY_LINE_RE);
if (!match) continue;
const [, key, after] = match;
let text = after.trim();
let end = i;
if (text === '') {
end = i + 1;
text = (lines[end] ?? '').trim();
}
const literal = readLiteral(text);
const tail = literal?.rest.trim() ?? '';
if (!literal || !/^(,|\})?\s*(\/\/.*)?$/.test(tail)) {
throw new Error(`${label}:${i + 1}: '${key}' is not a single string literal`);
}
entries.push({ key, value: literal.value, marked: tail.includes(FALLBACK_MARKER), line: i + 1 });
i = end;
}
return entries;
}
/** The entries of `locale/file`, read from disk. */
export function readCatalog(locale, file, root = I18N_ROOT) {
return parseCatalog(readFileSync(join(asPath(root), locale, file), 'utf8'), `${locale}/${file}`);
}