Files

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}`,
);
}
}
});