mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
feat(i18n): locale core + English catalogs + parity gates
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.
This commit is contained in:
1 parent
e7707d9a0f
commit
bde1e430ef
12 files changed
+2702
No files matched your search
@@ -0,0 +1,348 @@
|
||||
// God's Eye View i18n core (phase 1): catalog registry, translation, and
|
||||
// Intl-based formatting. English is the source/fallback catalog; a missing key
|
||||
// in another locale falls back to English with a development-only warning.
|
||||
// English is also the ONLY shipped catalog in this foundation change — each
|
||||
// additional locale lands as a stacked follow-up PR following the recipe in
|
||||
// docs/TRANSLATORS.md (catalog files + registration + CATALOG_LOCALES entry).
|
||||
// Every shipped catalog is built UNCONDITIONALLY — the build cost is trivial
|
||||
// and the registry stays declarative — but only locales in the configured pair
|
||||
// are offered/accepted (see locale.js resolveLocalePair).
|
||||
//
|
||||
// APPEND-ONLY NAMESPACE REGISTRATION
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// A worker adding a namespace creates locales/<locale>/<ns>.js (exporting
|
||||
// NAMESPACE plus a default message map of namespace-relative keys), then
|
||||
// appends ONE import line per locale and pushes the module into that locale's
|
||||
// array below. Nothing else changes, so concurrent workers merge trivially.
|
||||
// Namespace-relative keys are prefixed at registration time: shell.*,
|
||||
// cockpit.*, layers.*, setup.*.
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
normalizeLocale,
|
||||
writeStoredLocale,
|
||||
} from './locale.js';
|
||||
|
||||
import * as enShell from './locales/en/shell.js';
|
||||
import * as enCockpit from './locales/en/cockpit.js';
|
||||
import * as enLayers from './locales/en/layers.js';
|
||||
import * as enSetup from './locales/en/setup.js';
|
||||
|
||||
// Append new namespace modules here (one import + one entry per locale).
|
||||
const EN_NAMESPACES = [enShell, enCockpit, enLayers, enSetup];
|
||||
|
||||
/**
|
||||
* Prefix one namespace module's flat, namespace-relative keys with its
|
||||
* declared namespace: { 'title.subtitle': … } + NAMESPACE 'shell' becomes
|
||||
* 'shell.title.subtitle'.
|
||||
* @param {{NAMESPACE: string, default?: object}} namespaceModule
|
||||
* @returns {Record<string, string|object>} Frozen prefixed message map.
|
||||
*/
|
||||
export function mergeNamespace(namespaceModule) {
|
||||
const prefix =
|
||||
typeof namespaceModule?.NAMESPACE === 'string'
|
||||
? namespaceModule.NAMESPACE.trim()
|
||||
: '';
|
||||
if (!/^[a-z][a-z0-9-]*$/.test(prefix)) {
|
||||
throw new Error(
|
||||
`i18n namespace module must export a lowercase NAMESPACE, got ${JSON.stringify(prefix)}`,
|
||||
);
|
||||
}
|
||||
const merged = {};
|
||||
for (const [key, value] of Object.entries(namespaceModule.default || {})) {
|
||||
merged[`${prefix}.${key}`] = value;
|
||||
}
|
||||
return Object.freeze(merged);
|
||||
}
|
||||
|
||||
/** Merge namespaces into one catalog, rejecting duplicate dot-prefixed keys. */
|
||||
function buildCatalog(namespaceModules) {
|
||||
const catalog = {};
|
||||
for (const namespaceModule of namespaceModules) {
|
||||
for (const [key, value] of Object.entries(
|
||||
mergeNamespace(namespaceModule),
|
||||
)) {
|
||||
if (Object.hasOwn(catalog, key)) {
|
||||
throw new Error(`i18n duplicate catalog key: ${key}`);
|
||||
}
|
||||
catalog[key] = value;
|
||||
}
|
||||
}
|
||||
return Object.freeze(catalog);
|
||||
}
|
||||
|
||||
const CATALOGS = Object.freeze({
|
||||
[DEFAULT_LOCALE]: buildCatalog(EN_NAMESPACES),
|
||||
});
|
||||
|
||||
/**
|
||||
* The merged catalog for a locale (frozen; for coverage/parity tests and
|
||||
* tooling — runtime lookups go through t()).
|
||||
* @param {string} locale
|
||||
* @returns {Record<string, string|object>|null}
|
||||
*/
|
||||
export function getCatalog(locale) {
|
||||
const normalized = normalizeLocale(locale);
|
||||
return (normalized && CATALOGS[normalized]) || null;
|
||||
}
|
||||
|
||||
let currentLocale = DEFAULT_LOCALE;
|
||||
|
||||
/** Active locale (any catalog locale; pair-scoped by resolveLocale at boot). */
|
||||
export function getLocale() {
|
||||
return currentLocale;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch the active locale. Pure state change: call applyDocumentLanguage /
|
||||
* applyDocumentTranslations to reflect it in the DOM.
|
||||
* @returns {string} The locale actually applied (normalized).
|
||||
*/
|
||||
export function setLocale(locale) {
|
||||
const normalized = normalizeLocale(locale) || DEFAULT_LOCALE;
|
||||
if (normalized !== currentLocale) {
|
||||
currentLocale = normalized;
|
||||
formatterCache.clear();
|
||||
}
|
||||
return currentLocale;
|
||||
}
|
||||
|
||||
/** Development-only missing-key warning. Off under node:test and prod builds. */
|
||||
function warnMissingKey(key, detail) {
|
||||
// Vite replaces import.meta.env at build time; under node:test it is absent,
|
||||
// so warnings never fire in CI — only on the dev server.
|
||||
if (import.meta.env?.DEV !== true) return;
|
||||
console.warn(`[i18n] missing key "${key}"${detail ? ` (${detail})` : ''}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure lookup shared by t() and the parity tests: the requested locale first,
|
||||
* then the English fallback catalog.
|
||||
* @param {Record<string, object>} catalogs Catalog map to read from.
|
||||
* @param {string} locale Locale to resolve within that map.
|
||||
* @param {string} key Dot-prefixed message key.
|
||||
* @returns {string|object|undefined} Raw entry, or undefined when nowhere.
|
||||
*/
|
||||
export function resolveMessage(catalogs, locale, key) {
|
||||
const source = catalogs || CATALOGS;
|
||||
const normalized = normalizeLocale(locale);
|
||||
if (
|
||||
normalized &&
|
||||
source[normalized] &&
|
||||
Object.hasOwn(source[normalized], key)
|
||||
) {
|
||||
return source[normalized][key];
|
||||
}
|
||||
const fallback = source[DEFAULT_LOCALE];
|
||||
return fallback && Object.hasOwn(fallback, key) ? fallback[key] : undefined;
|
||||
}
|
||||
|
||||
/** Whether the key resolves in the active locale or the English fallback. */
|
||||
export function hasMessage(key) {
|
||||
return resolveMessage(CATALOGS, currentLocale, key) !== undefined;
|
||||
}
|
||||
|
||||
const pluralRulesCache = new Map();
|
||||
|
||||
function pluralRulesFor(locale) {
|
||||
let rules = pluralRulesCache.get(locale);
|
||||
if (!rules) {
|
||||
rules = new Intl.PluralRules(locale);
|
||||
pluralRulesCache.set(locale, rules);
|
||||
}
|
||||
return rules;
|
||||
}
|
||||
|
||||
/** Select a plural variant via Intl.PluralRules for the ACTIVE locale;
|
||||
* an entry lacking the selected category degrades to entry.other. Exported
|
||||
* for tests: full-parity catalogs can no longer stage a partial entry, so
|
||||
* the fallback is pinned synthetically (i18n.test.mjs). */
|
||||
export function selectPluralPattern(entry, values, key) {
|
||||
const count = values?.count;
|
||||
if (typeof count !== 'number' || !Number.isFinite(count)) {
|
||||
warnMissingKey(key, 'plural entry requires a finite {count} value');
|
||||
return entry.other ?? entry.one;
|
||||
}
|
||||
const category = pluralRulesFor(currentLocale).select(count);
|
||||
return entry[category] ?? entry.other;
|
||||
}
|
||||
|
||||
const PLACEHOLDER_PATTERN = /\{([A-Za-z0-9_]+)\}/g;
|
||||
|
||||
/** Named {placeholder} interpolation. Unprovided names stay literal so a gap
|
||||
* stays visible during development instead of silently vanishing. */
|
||||
function interpolate(pattern, values) {
|
||||
if (!values || typeof pattern !== 'string') return pattern;
|
||||
return pattern.replace(PLACEHOLDER_PATTERN, (literal, name) =>
|
||||
Object.hasOwn(values, name) ? String(values[name]) : literal,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate one dot-prefixed key in the active locale. Missing keys in a
|
||||
* non-English locale fall back to English; keys missing everywhere return the
|
||||
* key itself. Plural entries ({ one, other }) require a numeric `count` value.
|
||||
* @param {string} key e.g. 'layers.clear.toast.cleared'
|
||||
* @param {object} [values] Named interpolation values, e.g. { count: 3, detail: '…' }
|
||||
* @returns {string}
|
||||
*/
|
||||
export function t(key, values) {
|
||||
const entry = resolveMessage(CATALOGS, currentLocale, key);
|
||||
if (entry === undefined) {
|
||||
warnMissingKey(key, `not in ${currentLocale} or en catalog`);
|
||||
return key;
|
||||
}
|
||||
if (
|
||||
currentLocale !== DEFAULT_LOCALE &&
|
||||
!Object.hasOwn(CATALOGS[currentLocale] || {}, key)
|
||||
) {
|
||||
warnMissingKey(key, `missing in ${currentLocale}, using en fallback`);
|
||||
}
|
||||
const pattern =
|
||||
typeof entry === 'string' ? entry : selectPluralPattern(entry, values, key);
|
||||
return interpolate(pattern, values);
|
||||
}
|
||||
|
||||
/*
|
||||
* Intl formatters are memoized per (kind, locale, options): the constructors
|
||||
* are expensive and stage-3 extraction will format numbers and dates on panel
|
||||
* refresh paths. The cache is cleared whenever the active locale changes.
|
||||
*/
|
||||
const formatterCache = new Map();
|
||||
|
||||
function cachedFormatter(kind, Constructor, options) {
|
||||
const cacheKey = `${kind}|${currentLocale}|${options ? JSON.stringify(options) : ''}`;
|
||||
let formatter = formatterCache.get(cacheKey);
|
||||
if (!formatter) {
|
||||
formatter = new Constructor(currentLocale, options);
|
||||
formatterCache.set(cacheKey, formatter);
|
||||
}
|
||||
return formatter;
|
||||
}
|
||||
|
||||
/** Locale-aware number formatting (Intl.NumberFormat). */
|
||||
export function formatNumber(value, options) {
|
||||
return cachedFormatter('number', Intl.NumberFormat, options).format(value);
|
||||
}
|
||||
|
||||
/** Locale-aware date/time formatting (Intl.DateTimeFormat). */
|
||||
export function formatDate(value, options) {
|
||||
return cachedFormatter('date', Intl.DateTimeFormat, options).format(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Locale-aware relative time (Intl.RelativeTimeFormat). `value` is the signed
|
||||
* distance in `unit`, e.g. formatRelativeTime(-5, 'minute') →
|
||||
* '5 minutes ago' (en) / 'hace 5 minutos' (es).
|
||||
* @param {number} value Signed distance in `unit`.
|
||||
* @param {string} [unit] Intl unit ('second', 'minute', 'hour', 'day', …).
|
||||
* @param {object} [options] Intl.RelativeTimeFormat options (numeric, style).
|
||||
*/
|
||||
export function formatRelativeTime(value, unit = 'second', options) {
|
||||
return cachedFormatter('relative', Intl.RelativeTimeFormat, {
|
||||
numeric: 'auto',
|
||||
...options,
|
||||
}).format(value, unit);
|
||||
}
|
||||
|
||||
/** DOM write targets for applyDocumentTranslations: textContent plus the
|
||||
* three presentation attributes vision.md requires to agree with visible
|
||||
* labels. Never innerHTML — translation functions return text. */
|
||||
const DOCUMENT_TRANSLATION_TARGETS = Object.freeze([
|
||||
Object.freeze({
|
||||
attribute: 'data-i18n',
|
||||
apply(element, text) {
|
||||
element.textContent = text;
|
||||
},
|
||||
}),
|
||||
Object.freeze({
|
||||
attribute: 'data-i18n-title',
|
||||
apply(element, text) {
|
||||
element.setAttribute('title', text);
|
||||
},
|
||||
}),
|
||||
Object.freeze({
|
||||
attribute: 'data-i18n-aria-label',
|
||||
apply(element, text) {
|
||||
element.setAttribute('aria-label', text);
|
||||
},
|
||||
}),
|
||||
Object.freeze({
|
||||
attribute: 'data-i18n-placeholder',
|
||||
apply(element, text) {
|
||||
element.setAttribute('placeholder', text);
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Apply catalog text to every element carrying an i18n attribute in `doc`.
|
||||
* Elements whose key resolves nowhere are left untouched (warned in dev).
|
||||
* @param {Document} doc
|
||||
* @returns {number} Count of element/attribute writes performed.
|
||||
*/
|
||||
export function applyDocumentTranslations(doc) {
|
||||
if (typeof doc?.querySelectorAll !== 'function') return 0;
|
||||
let applied = 0;
|
||||
for (const target of DOCUMENT_TRANSLATION_TARGETS) {
|
||||
for (const element of doc.querySelectorAll(`[${target.attribute}]`)) {
|
||||
const key = element.getAttribute?.(target.attribute);
|
||||
if (!key) continue;
|
||||
if (!hasMessage(key)) {
|
||||
warnMissingKey(key, `attribute ${target.attribute}`);
|
||||
continue;
|
||||
}
|
||||
target.apply(element, t(key));
|
||||
applied += 1;
|
||||
}
|
||||
}
|
||||
return applied;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a locale choice, then reload preserving window.location.hash exactly
|
||||
* and stripping ?lang from the search string — the override is one-shot and
|
||||
* must never graduate into the stored URL. Storage is best-effort: where
|
||||
* storage is blocked (private mode) the preference simply will not survive.
|
||||
* @param {string} locale Locale the user chose (normalized here).
|
||||
* @param {object} [deps]
|
||||
* @param {{href: string, assign: Function}} [deps.location]
|
||||
* @param {object} [deps.storage] Injected localStorage-like store.
|
||||
* @returns {boolean} true when a reload was triggered.
|
||||
*/
|
||||
export function persistLocaleAndReload(
|
||||
locale,
|
||||
{
|
||||
location = globalThis.location,
|
||||
history = globalThis.history,
|
||||
storage,
|
||||
} = {},
|
||||
) {
|
||||
const normalized = normalizeLocale(locale) || DEFAULT_LOCALE;
|
||||
writeStoredLocale(normalized, storage);
|
||||
if (!location || typeof location.assign !== 'function') return false;
|
||||
const stripLang = () => {
|
||||
try {
|
||||
const url = new URL(location.href);
|
||||
url.searchParams.delete('lang');
|
||||
return url.href;
|
||||
} catch {
|
||||
return location.href;
|
||||
}
|
||||
};
|
||||
// location.assign() to the byte-identical URL performs NO navigation in
|
||||
// real browsers (the common case: no ?lang present, hash unchanged), so a
|
||||
// stored locale change would never reload. Strip ?lang via replaceState
|
||||
// (no extra history entry) and force a real reload instead.
|
||||
const target = stripLang();
|
||||
try {
|
||||
history?.replaceState?.(null, '', target);
|
||||
} catch {
|
||||
// replaceState is best-effort; the reload below still applies.
|
||||
}
|
||||
if (typeof location.reload === 'function') {
|
||||
location.reload();
|
||||
} else {
|
||||
location.assign(target);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
Reference in new issue
Block a user