Add Fedimint discovery, dual SQLite/Postgres storage, richer review handling, and generated social imagery.
346 lines
13 KiB
JavaScript
346 lines
13 KiB
JavaScript
/**
|
|
* 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
|
|
'fedimint.module.', // localModuleName(module)
|
|
'fedimint.network.', // localNetworkName(network)
|
|
'feed.ecosystem.', // the /reviews filter, one option per ECOSYSTEMS entry
|
|
'reviews.dialog.bodyPlaceholder.', // one prompt per ecosystem, keyed by subject.type
|
|
];
|
|
|
|
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 <script> ships to the browser. Only the second half is limited to
|
|
* the client namespaces, so the two are checked separately.
|
|
*/
|
|
const scripts = [...source.matchAll(/<script(?![^>]*\bis:inline\b)[^>]*>([\s\S]*?)<\/script>/g)]
|
|
.map((m) => m[1])
|
|
.join('\n');
|
|
// A .ts file under lib/ or scripts/ is island code wholesale.
|
|
const shipsToBrowser = /\/(lib|scripts)\//.test(relative) && relative.endsWith('.ts');
|
|
|
|
for (const match of source.matchAll(T_CALL)) {
|
|
const key = match[2];
|
|
used.add(key);
|
|
if (!baseStems.has(stem(key))) {
|
|
undefinedKeys.push({ key, file: relative });
|
|
}
|
|
}
|
|
|
|
const clientSource = shipsToBrowser ? source : scripts;
|
|
for (const match of clientSource.matchAll(T_CALL)) {
|
|
const key = match[2];
|
|
const namespace = key.split('.')[0];
|
|
if (!clientNamespaces.has(namespace)) {
|
|
clientLeaks.push({ key, file: relative, namespace });
|
|
}
|
|
}
|
|
}
|
|
|
|
if (undefinedKeys.length > 0) {
|
|
problems.push({
|
|
code: defaultLocale,
|
|
kind: 'undefined',
|
|
keys: undefinedKeys.map((u) => `${u.key} (${u.file})`),
|
|
note: `${undefinedKeys.length} key(s) used in the source are not defined in ${defaultLocale}.json`,
|
|
});
|
|
}
|
|
|
|
if (clientLeaks.length > 0) {
|
|
problems.push({
|
|
code: '-',
|
|
kind: 'client',
|
|
keys: clientLeaks.map((c) => `${c.key} (${c.file}, namespace "${c.namespace}")`),
|
|
note: `${clientLeaks.length} island key(s) outside CLIENT_NAMESPACES: add the namespace to config.ts or move the string`,
|
|
});
|
|
}
|
|
|
|
/* ---------- keys nothing reads ---------- */
|
|
|
|
const unused = [...baseStems]
|
|
.filter((key) => !used.has(key) && !isDynamic(key))
|
|
.filter((key) => !base[key] === false || true)
|
|
.sort();
|
|
|
|
/* ---------- report ---------- */
|
|
|
|
const report = (list, level) => {
|
|
for (const entry of list) {
|
|
console.log(`\n${level} [${entry.kind}] ${entry.code}: ${entry.note}`);
|
|
for (const key of entry.keys.slice(0, 40)) console.log(` ${key}`);
|
|
if (entry.keys.length > 40) console.log(` … and ${entry.keys.length - 40} more`);
|
|
}
|
|
};
|
|
|
|
const total = Object.keys(base).length;
|
|
console.log(
|
|
`check-i18n: ${localeCodes.length} locales, ${total} keys in ${defaultLocale}.json, ` +
|
|
`${used.size} literal key(s) used in src/.`,
|
|
);
|
|
|
|
if (process.env['LIST_UNUSED'] && unused.length > 0) {
|
|
console.log(`\n ${unused.length} key(s) with no literal or dynamic use:`);
|
|
for (const key of unused) console.log(` ${key}`);
|
|
}
|
|
|
|
report(warnings, 'WARN ');
|
|
report(problems, 'ERROR');
|
|
|
|
if (problems.length > 0) {
|
|
console.log(`\ncheck-i18n failed with ${problems.length} problem group(s).`);
|
|
process.exit(1);
|
|
}
|
|
|
|
if (warnings.length > 0) {
|
|
console.log('\ncheck-i18n passed with warnings. Untranslated keys fall back to English.');
|
|
} else {
|
|
console.log('Every locale is complete and every key used in the source is defined.');
|
|
}
|