mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Add the i18n foundation: src/i18n/locale.js (locale resolution with a configurable GEV_DEFAULT_LOCALE / GEV_SECONDARY_LOCALE pair, guarded preference storage, one-shot ?lang= override, <html lang>/<html dir> metadata) and src/i18n/index.js (catalog registry, t(), Intl formatters, applyDocumentTranslations, persistLocaleAndReload). English is the only shipped locale in this PR: the four en catalogs (shell/cockpit/layers/setup) are the reference and the always-shipped fallback, the built-in pair degenerates to en-only, and English output is byte-identical to the strings these replace. Parity gates land with the core (catalog key/placeholder/plural-shape parity, markup coverage of every data-i18n* attribute, repair-pass anchors pinning en byte-identity); additional locales ship as stacked follow-up PRs through the recipe in docs/TRANSLATORS.md. Rebased onto current main: the client defines live in build/vite.js / server/standalone/vite.config.js as before.
199 lines
9.3 KiB
JavaScript
199 lines
9.3 KiB
JavaScript
// Catalog parity gates: every shipped non-English locale (the es follow-up,
|
|
// then one PR per further locale) is checked against en — it may never LEAD en
|
|
// (no extra keys), every shared key must keep identical placeholder names,
|
|
// and plural entries must carry every en variant plus only categories
|
|
// Intl.PluralRules reports valid for that locale (see assertVariantSuperset
|
|
// below); the strict gate demands exact key-set equality so a forgotten
|
|
// translation cannot ship silently behind the English fallback. While English
|
|
// is the only shipped locale these loops are vacuous — the gate machinery
|
|
// stays so each locale PR meets it from day one.
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { getCatalog, mergeNamespace } from './index.js';
|
|
import { CATALOG_LOCALES } from './locale.js';
|
|
|
|
/*
|
|
* PARITY FLIP — how the strict gate is previewed or relaxed.
|
|
* ─────────────────────────────────────────────────────────────────────────────
|
|
* REQUIRE_FULL_PARITY is on by default: every shipped locale must hold the
|
|
* exact en key set. While a NEW catalog is still an untranslated seed that
|
|
* lags en, run CI with GEV_I18N_REQUIRE_FULL_LOCALE_PARITY=0 to drop back to
|
|
* the subset rules (a locale may be a subset of en, never a superset). The
|
|
* pre-generalization name GEV_I18N_REQUIRE_FULL_ES_PARITY is still honored as
|
|
* an alias. Record every flip in docs/TRANSLATORS.md.
|
|
*/
|
|
const PARITY_OPT_OUT = process.env.GEV_I18N_REQUIRE_FULL_LOCALE_PARITY
|
|
?? process.env.GEV_I18N_REQUIRE_FULL_ES_PARITY;
|
|
const REQUIRE_FULL_PARITY = PARITY_OPT_OUT !== '0';
|
|
|
|
const PLACEHOLDER_PATTERN = /\{([A-Za-z0-9_]+)\}/g;
|
|
|
|
/** Placeholder names used by one catalog entry (string or plural variants). */
|
|
function placeholdersOf(entry) {
|
|
const patterns = typeof entry === 'string' ? [entry] : Object.values(entry || {});
|
|
const names = new Set();
|
|
for (const pattern of patterns) {
|
|
if (typeof pattern !== 'string') continue;
|
|
for (const match of pattern.matchAll(PLACEHOLDER_PATTERN)) names.add(match[1]);
|
|
}
|
|
return names;
|
|
}
|
|
|
|
/*
|
|
* PLURAL-SHAPE PARITY, GENERALIZED FOR MULTI-CATEGORY LOCALES
|
|
* ─────────────────────────────────────────────────────────────────────────────
|
|
* en plural entries carry { one, other } — the only cardinal categories
|
|
* Intl.PluralRules reports for English. Slavic locales such as ru/uk select
|
|
* one/few/many/other, so exact-shape equality with en would forbid their
|
|
* grammars. The rule is now, per locale and shared key:
|
|
* (a) the entry must carry EVERY variant en has (they are mandatory — the
|
|
* runtime reads entry.<category> ?? entry.other, so dropping an en
|
|
* variant strands the fallback), and
|
|
* (b) every variant added beyond en's must be a cardinal category
|
|
* Intl.PluralRules reports VALID for that locale
|
|
* (resolvedOptions().validCategories, spelled pluralCategories on
|
|
* older runtimes; e.g. 'few'/'many' are valid for ru/uk but not en).
|
|
* en itself can never grow a variant (English's valid set is exactly
|
|
* one/other), and a typo'd variant like 'xother' fails loudly for any
|
|
* locale. The runtime degrades gracefully the other way: an entry lacking a
|
|
* category its locale would select renders entry.other (selectPluralPattern
|
|
* in src/i18n/index.js; pinned in i18n.test.mjs).
|
|
*/
|
|
|
|
/** Cardinal plural categories Intl may select for a locale. */
|
|
function validPluralCategories(locale) {
|
|
const { validCategories, pluralCategories } = new Intl.PluralRules(locale).resolvedOptions();
|
|
// The property was renamed pluralCategories → validCategories in newer
|
|
// ECMAScript drafts; read whichever the running ICU exposes.
|
|
const categories = validCategories ?? pluralCategories;
|
|
assert.ok(Array.isArray(categories), `Intl.PluralRules(${locale}) exposes no category set`);
|
|
return new Set(categories);
|
|
}
|
|
|
|
/** Assert the plural-shape rule for one locale entry against its en twin. */
|
|
function assertVariantSuperset(localeEntry, enEntry, locale, key) {
|
|
assert.equal(
|
|
typeof localeEntry,
|
|
typeof enEntry,
|
|
`variant-shape drift on ${locale}:${key}: string ↔ plural-object mismatch`,
|
|
);
|
|
if (typeof enEntry === 'string') return;
|
|
const enVariants = Object.keys(enEntry);
|
|
const variants = Object.keys(localeEntry);
|
|
const missing = enVariants.filter((variant) => !variants.includes(variant));
|
|
assert.deepEqual(
|
|
missing,
|
|
[],
|
|
`${locale}:${key} must keep every en variant (${enVariants.join(',')})`,
|
|
);
|
|
const valid = validPluralCategories(locale);
|
|
const invalid = variants.filter((variant) => !enVariants.includes(variant) && !valid.has(variant));
|
|
assert.deepEqual(
|
|
invalid,
|
|
[],
|
|
`${locale}:${key} adds categories Intl.PluralRules(${locale}) never selects: ${invalid.join(',')}`,
|
|
);
|
|
}
|
|
|
|
const enKeys = Object.keys(getCatalog('en')).sort();
|
|
// Every shipped non-en locale with its sorted key list.
|
|
const localeKeys = new Map(
|
|
CATALOG_LOCALES
|
|
.filter((locale) => locale !== 'en')
|
|
.map((locale) => [locale, Object.keys(getCatalog(locale)).sort()]),
|
|
);
|
|
|
|
test('every shipped locale ships a merged catalog', () => {
|
|
// English only in the foundation state; a locale PR appends its code here
|
|
// alongside its catalog files (docs/TRANSLATORS.md).
|
|
assert.deepEqual([...CATALOG_LOCALES].sort(), ['en']);
|
|
for (const locale of CATALOG_LOCALES) {
|
|
assert.ok(getCatalog(locale), `merged catalog for ${locale}`);
|
|
}
|
|
});
|
|
|
|
test('no locale carries keys that en does not have', () => {
|
|
for (const [locale, keys] of localeKeys) {
|
|
const extras = keys.filter((key) => !enKeys.includes(key));
|
|
assert.deepEqual(
|
|
extras,
|
|
[],
|
|
`extra ${locale} keys leak untranslated-only surfaces: ${extras.join(', ')}`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('placeholder names match en; plural variants form an en-superset of Intl-valid categories', () => {
|
|
const enCatalog = getCatalog('en');
|
|
for (const [locale, keys] of localeKeys) {
|
|
const catalog = getCatalog(locale);
|
|
for (const key of keys) {
|
|
assert.deepEqual(
|
|
[...placeholdersOf(catalog[key])].sort(),
|
|
[...placeholdersOf(enCatalog[key])].sort(),
|
|
`placeholder drift on ${locale}:${key}: a renamed {name} would break interpolation at runtime`,
|
|
);
|
|
assertVariantSuperset(catalog[key], enCatalog[key], locale, key);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('plural-shape rule: ru accepts its full Intl category set and rejects categories it never selects', () => {
|
|
const enEntry = { one: 'Cleared {count} layer', other: 'Cleared {count} layers' };
|
|
// ru selects one/few/many/other, so the full four-variant translation of
|
|
// an en { one, other } twin is a legal superset (adds only valid ru
|
|
// categories) — exactly what stage-B translators must produce…
|
|
assert.doesNotThrow(() => assertVariantSuperset(
|
|
{ one: '…', few: '…', many: '…', other: '…' },
|
|
enEntry,
|
|
'ru',
|
|
'layers.clear.toast.cleared',
|
|
));
|
|
// …while a variant Intl never selects for ru (a typo like 'xother') fails.
|
|
assert.throws(
|
|
() => assertVariantSuperset({ one: '…', other: '…', xother: '…' }, enEntry, 'ru', 'demo.key'),
|
|
/xother/,
|
|
);
|
|
// Dropping a mandatory en variant fails even when all additions are valid.
|
|
assert.throws(
|
|
() => assertVariantSuperset({ few: '…', many: '…', other: '…' }, enEntry, 'ru', 'demo.key'),
|
|
/every en variant/,
|
|
);
|
|
// String ↔ plural-object drift still fails exactly as before.
|
|
assert.throws(
|
|
() => assertVariantSuperset('plain string', enEntry, 'ru', 'demo.key'),
|
|
/string ↔ plural-object/,
|
|
);
|
|
// The gate stays honest about what the runtime can select: validCategories
|
|
// is read from Intl, not hardcoded per locale.
|
|
assert.deepEqual([...validPluralCategories('en')].sort(), ['one', 'other']);
|
|
assert.deepEqual([...validPluralCategories('ru')].sort(), ['few', 'many', 'one', 'other']);
|
|
});
|
|
|
|
test('exact key parity for every shipped locale once translation is complete (parity flip)', () => {
|
|
if (!REQUIRE_FULL_PARITY) {
|
|
// Subset mode: every locale key resolves; en-only keys fall back silently.
|
|
for (const [locale, keys] of localeKeys) {
|
|
for (const key of keys) assert.ok(enKeys.includes(key), `${locale}:${key}`);
|
|
}
|
|
return;
|
|
}
|
|
for (const [locale, keys] of localeKeys) {
|
|
assert.deepEqual(keys, enKeys, `strict gate is on: ${locale} must be key-complete`);
|
|
}
|
|
});
|
|
|
|
test('mergeNamespace prefixes relative keys and rejects malformed namespaces', () => {
|
|
const merged = mergeNamespace({ NAMESPACE: 'demo', default: { 'a.b': 'x', plain: 'y' } });
|
|
assert.deepEqual({ ...merged }, { 'demo.a.b': 'x', 'demo.plain': 'y' });
|
|
assert.throws(() => mergeNamespace({}), /must export a lowercase NAMESPACE/);
|
|
assert.throws(() => mergeNamespace({ NAMESPACE: 'Nope' }), /must export a lowercase NAMESPACE/);
|
|
assert.throws(() => mergeNamespace({ NAMESPACE: '' }), /must export a lowercase NAMESPACE/);
|
|
assert.deepEqual({ ...mergeNamespace({ NAMESPACE: 'empty' }) }, {});
|
|
});
|
|
|
|
test('registered namespaces produce the documented dot prefixes', () => {
|
|
const prefixes = new Set(enKeys.map((key) => key.split('.')[0]));
|
|
assert.deepEqual([...prefixes].sort(), ['cockpit', 'layers', 'setup', 'shell']);
|
|
});
|