Wire locale catalogs, repair mint terminology, and confirm login with a toast.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* 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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user