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