100 lines
4.1 KiB
JavaScript
100 lines
4.1 KiB
JavaScript
/**
|
|
* The three ways a locale ships broken without anything failing.
|
|
*
|
|
* `check-i18n.mjs` already answers "is this catalog complete?" on every build. These
|
|
* are the questions it cannot ask, because each one looks like a working page:
|
|
*
|
|
* wiring a locale in LOCALES whose catalog `index.ts` never imports. It does
|
|
* not throw: `catalogFor()` falls back to English by design, so the
|
|
* locale renders the English site under its own `lang`, its own URL
|
|
* prefix and a full reciprocal hreflang set. Twenty-one locales sat in
|
|
* that state once. Nothing caught it but reading the built HTML.
|
|
*
|
|
* brand `site.name` translated as a phrase. The bulk machine translation did
|
|
* this to four catalogs: Greek shipped "Καταιγίδες. Διάστημα" ("Storms.
|
|
* Space") and Japanese "ログイン スペース" ("Login Space") as the name of
|
|
* the site, in the header, the title and every JSON-LD node.
|
|
*
|
|
* placeholder a `{n}` or `{year}` dropped or renamed in translation. `t()` leaves an
|
|
* unknown placeholder alone and silently ignores a missing one, so the
|
|
* string renders as prose with a hole in it, or with a literal "{n}".
|
|
*
|
|
* Plain node, no build step: config.ts is read as text, catalogs as JSON.
|
|
*/
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { readFileSync } from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const i18nDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../src/i18n');
|
|
const read = (file) => readFileSync(path.join(i18nDir, file), 'utf8');
|
|
const catalog = (code) => JSON.parse(read(`${code}.json`));
|
|
|
|
const configSource = read('config.ts');
|
|
const indexSource = read('index.ts');
|
|
|
|
const codes = [...configSource.matchAll(/\{\s*code:\s*'([a-z-]+)'/g)].map((m) => m[1]);
|
|
const defaultLocale = /DEFAULT_LOCALE\s*=\s*'([a-z-]+)'/.exec(configSource)?.[1];
|
|
|
|
const base = catalog(defaultLocale);
|
|
|
|
/** `{n}`, `{year}`, `{mints}` — the names `t()` substitutes, as a comparable set. */
|
|
const placeholders = (value) =>
|
|
new Set(typeof value === 'string' ? (value.match(/\{[a-zA-Z0-9_]+\}/g) ?? []) : []);
|
|
|
|
test('every locale in LOCALES has its own catalog behind it', () => {
|
|
assert.ok(codes.length > 0, 'no locales parsed out of config.ts');
|
|
|
|
for (const code of codes) {
|
|
assert.doesNotThrow(() => catalog(code), `src/i18n/${code}.json is missing or invalid`);
|
|
|
|
// The import and the CATALOGS entry, which are the step that gets forgotten. A
|
|
// hyphenated code cannot be a bare identifier, so `ar-eg` arrives as `areg`.
|
|
const ident = code.replace(/-/g, '');
|
|
assert.match(
|
|
indexSource,
|
|
new RegExp(`import ${ident} from '\\./${code}\\.json'`),
|
|
`index.ts does not import ${code}.json, so /${code}/ would render in ${defaultLocale}`,
|
|
);
|
|
assert.match(
|
|
indexSource,
|
|
new RegExp(`(^|\\s)'?${code}'?:\\s*${ident} as Catalog`, 'm'),
|
|
`index.ts imports ${code}.json but never puts it in CATALOGS`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('config.ts and locales.mjs list the same locales', async () => {
|
|
const { DEFAULT_LOCALE, LOCALE_CODES } = await import(path.join(i18nDir, 'locales.mjs'));
|
|
assert.equal(DEFAULT_LOCALE, defaultLocale);
|
|
assert.deepEqual(LOCALE_CODES, codes, 'the two locale lists have drifted');
|
|
});
|
|
|
|
test('the brand is never translated', () => {
|
|
for (const code of codes) {
|
|
assert.equal(
|
|
catalog(code)['site.name'],
|
|
base['site.name'],
|
|
`${code}.json translated site.name; the brand is a name, not a phrase`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('every translated string keeps the placeholders English has', () => {
|
|
for (const code of codes) {
|
|
if (code === defaultLocale) continue;
|
|
const own = catalog(code);
|
|
|
|
for (const [key, english] of Object.entries(base)) {
|
|
const translated = own[key];
|
|
if (translated === undefined) continue;
|
|
assert.deepEqual(
|
|
[...placeholders(translated)].sort(),
|
|
[...placeholders(english)].sort(),
|
|
`${code}.json ${key}: placeholders differ from ${defaultLocale}`,
|
|
);
|
|
}
|
|
}
|
|
});
|