/** * Catalog checker. Runs before every build, and on its own via `pnpm check:i18n`. * * Four questions, all of which have bitten a multilingual site before: * * missing a key English has and this locale does not (falls back, but silently) * unknown a key a locale has and English does not (a typo, or a stale key) * undefined a `t('...')` in the source that no catalog defines (would render a * humanised key on a real page) * client an island reaching for a key outside CLIENT_NAMESPACES, which would * not be in the page's inline payload and would render as a humanised key * * Missing keys are a warning: English is a working fallback and a half-translated * language is better than no language. Everything else is an error, because every one * of them puts a wrong string in front of a visitor. * * Usage: node scripts/check-i18n.mjs [--strict] * --strict also fails on missing keys, for a release build. */ import { readdir, readFile } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const i18nDir = path.join(root, 'src/i18n'); const srcDir = path.join(root, 'src'); const strict = process.argv.includes('--strict'); /* ---------- the locale table, read from config.ts ---------- */ /* * Parsed out of the source rather than imported, because config.ts is TypeScript and * this script runs on plain node before anything is compiled. The two shapes it reads * are the two the file is allowed to have; a change to either fails loudly here rather * than quietly checking nothing. */ const configSource = await readFile(path.join(i18nDir, 'config.ts'), 'utf8'); const localeCodes = [...configSource.matchAll(/\{\s*code:\s*'([a-z-]+)'/g)].map((m) => m[1]); const defaultLocale = /DEFAULT_LOCALE\s*=\s*'([a-z-]+)'/.exec(configSource)?.[1]; const clientBlock = /CLIENT_NAMESPACES\s*=\s*\[([^\]]*)\]/s.exec(configSource)?.[1] ?? ''; const clientNamespaces = new Set([...clientBlock.matchAll(/'([\w-]+)'/g)].map((m) => m[1])); if (localeCodes.length === 0 || !defaultLocale || clientNamespaces.size === 0) { console.error('check-i18n: could not read LOCALES, DEFAULT_LOCALE or CLIENT_NAMESPACES from src/i18n/config.ts'); process.exit(2); } /* * `locales.mjs` is the same list in plain JavaScript, for `astro.config.mjs` and this * script, both of which run before TypeScript exists. Two lists is one more than * ideal, so they are compared here rather than trusted. */ const { DEFAULT_LOCALE: mjsDefault, LOCALE_CODES: mjsCodes } = await import( path.join(i18nDir, 'locales.mjs') ); if (mjsDefault !== defaultLocale || mjsCodes.join(',') !== localeCodes.join(',')) { console.error('check-i18n: src/i18n/config.ts and src/i18n/locales.mjs disagree.'); console.error(` config.ts: default ${defaultLocale}, locales [${localeCodes.join(', ')}]`); console.error(` locales.mjs: default ${mjsDefault}, locales [${mjsCodes.join(', ')}]`); console.error('Both need the new language. See README, "Adding a language".'); process.exit(2); } /* ---------- catalogs ---------- */ const catalogs = new Map(); for (const code of localeCodes) { const file = path.join(i18nDir, `${code}.json`); try { catalogs.set(code, JSON.parse(await readFile(file, 'utf8'))); } catch (error) { console.error(`check-i18n: cannot read src/i18n/${code}.json (${error.message})`); console.error(`Every locale in LOCALES needs a catalog. Copy en.json to ${code}.json and translate it.`); process.exit(2); } } const base = catalogs.get(defaultLocale); /** CLDR plural categories, which are a key's suffix rather than a key of their own. */ const PLURAL_SUFFIX = /\.(zero|one|two|few|many|other)$/; /** `card.reviews.one` and `card.reviews` are the same key as far as coverage goes. */ const stem = (key) => key.replace(PLURAL_SUFFIX, ''); /** * Every stem a catalog can resolve. * * Stems rather than literal keys, because a locale is allowed more plural categories * than English has: Spanish spells `time.ago.month` as `.one` and `.other` where * English needs one form, and neither is missing anything. */ const stems = (catalog) => new Set(Object.keys(catalog).map(stem)); const baseStems = stems(base); const problems = []; const warnings = []; for (const code of localeCodes) { if (code === defaultLocale) continue; const catalog = catalogs.get(code); const own = stems(catalog); const missing = [...baseStems].filter((key) => !own.has(key)).sort(); const unknown = [...own].filter((key) => !baseStems.has(key)).sort(); if (missing.length > 0) { (strict ? problems : warnings).push({ code, kind: 'missing', keys: missing, note: `${missing.length} key(s) fall back to ${defaultLocale}`, }); } if (unknown.length > 0) { problems.push({ code, kind: 'unknown', keys: unknown, note: `${unknown.length} key(s) are not in ${defaultLocale}.json, so nothing reads them`, }); } } /* ---------- the warning copy, which lives in two packages ---------- */ /* * `shared/src/warnings.ts` decides which banner a mint gets and writes the English * sentence for it, because the API's fixture tests assert on that sentence. The * catalogs carry the same sentences so they can be translated. Two copies of safety * copy is exactly the sort of thing that drifts, and a mint page and a mint card * disagreeing about whether withdrawals work is the worst possible thing to get wrong, * so the two are diffed here on every build. * * Read from shared's build output rather than its source: it is a prerequisite of the * web build, so it is always there, and importing it beats parsing TypeScript. */ const SHARED_TABLES = [ { module: 'WARNING_COPY_EN', prefix: 'mint.warnings.' }, { module: 'CHIP_COPY_EN', prefix: 'mint.chip.' }, ]; try { const shared = await import(path.join(root, '../shared/dist/warnings.js')); for (const { module, prefix } of SHARED_TABLES) { const table = shared[module]; if (!table) { problems.push({ code: defaultLocale, kind: 'shared', keys: [module], note: `shared/dist/warnings.js no longer exports ${module}`, }); continue; } const drift = []; for (const [key, english] of Object.entries(table)) { const catalogKey = prefix + key; const inCatalog = base[catalogKey]; if (inCatalog === undefined) drift.push(`${catalogKey} (in ${module}, not in ${defaultLocale}.json)`); else if (inCatalog !== english) { drift.push(`${catalogKey}\n shared: ${english}\n catalog: ${inCatalog}`); } } // And the other direction: a catalog key under this prefix that shared never writes // is a key no banner will ever ask for. for (const key of Object.keys(base)) { if (!key.startsWith(prefix)) continue; if (table[key.slice(prefix.length)] === undefined) { drift.push(`${key} (in ${defaultLocale}.json, not in ${module})`); } } if (drift.length > 0) { problems.push({ code: defaultLocale, kind: 'shared', keys: drift, note: `${drift.length} difference(s) between shared's ${module} and ${defaultLocale}.json`, }); } } } catch (error) { problems.push({ code: defaultLocale, kind: 'shared', keys: [String(error.message)], note: 'could not read shared/dist/warnings.js. Run `pnpm --filter ./shared build` first', }); } /* ---------- key usage in the source ---------- */ async function walk(dir) { const out = []; for (const entry of await readdir(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name); if (entry.isDirectory()) { if (entry.name === 'i18n' || entry.name === 'bones') continue; out.push(...(await walk(full))); } else if (/\.(astro|ts)$/.test(entry.name)) { out.push(full); } } return out; } /** * `t('some.key')` and `t('some.key', { ... })`, with either quote. * * Only literal keys are found, which is the point: a key built at runtime cannot be * checked, so the codebase does not build them. The two places that legitimately need * a computed key (`nut.${n}` and `status.${mint.status}`) use a helper with its own * exhaustive list, declared below. */ const T_CALL = /\bt\(\s*(['"])([\w.-]+)\1/g; /** * Key families built from a value at runtime, listed here so the checker knows they * are reached. Each entry is a prefix; every catalog key under it counts as used. */ const DYNAMIC_PREFIXES = [ 'nut.', // nutName(n) 'status.', // statusLabel(mint.status) 'reviews.dialog.stars.', // the star picker's phrase per rating 'reviews.breakdown.', // ratingBreakdown, one key per star count 'mint.warnings.', // warningStrings(), keyed by kind 'mint.chip.', // mintChip() 'login.method.', // methodChipHtml(session.method) 'wallets.platform.', // the platform list on each wallet card 'wallets.item.', // one blurb per wallet, keyed by its id 'login.connect.', // CONNECT_COPY, keyed by method 'time.ago.', // formatRelative, keyed by unit 'time.short.', // formatShortDuration, keyed by unit ]; const isDynamic = (key) => DYNAMIC_PREFIXES.some((prefix) => key.startsWith(prefix)); const files = await walk(srcDir); const used = new Set(); const undefinedKeys = []; const clientLeaks = []; for (const file of files) { const source = await readFile(file, 'utf8'); const relative = path.relative(root, file); /* * An .astro file is two programs: frontmatter and template render at build time, and * anything inside a