refactor(i18n): key messages by English text; add Russian

Replace the semantic keys with the English source text, so code reads as the
UI does and English needs no catalog: t('Save keys'), tn(count, one, other)
for plurals, msg() for text stored in data, and bare data-i18n* markup that
translates the element's own English. Other languages are one flat JSON each.

- Simplify src/i18n: drop namespaces, the configurable locale pair
  (GEV_DEFAULT_LOCALE / GEV_SECONDARY_LOCALE) and its build defines. Locale
  comes from ?lang=, the stored choice, then browser languages.
- Add scripts/i18n.mjs (npm run i18n): extracts every message from t/tn/msg
  and markup, and fails on missing, unused, placeholder-mismatched or
  wrongly-shaped plural entries. npm test runs it via catalog.test.mjs.
- Add Russian (src/i18n/locales/ru.json, 833 messages), started from the
  community catalog in #312 and reviewed against the current English.
- Fix data-i18n placed on parents that would wipe their children (cockpit
  LEVEL and local weather cells, first-run EXPLORE MANUALLY tile), and a
  doubled degree sign in the TRACK ACQUIRED signal.
- Rewrite docs/TRANSLATORS.md for the new workflow; drop sections the earlier
  merge resurrected in docs/CURRENT-STATE.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
German KhokhlovandClaude Opus 5.5 committed 2026-10-12 13:48:56 +04:00
1 parent dfbaeafbd8
commit 13efd411b6
112 files changed
+4419 -4856

No files matched your search

+29 -256
View File
@@ -1,249 +1,52 @@
// Locale identity for God's Eye View (i18n phase 1).
//
// English is the default-without-configuration, the ALWAYS-shipped fallback
// catalog, and the compatibility baseline (docs/TRANSLATORS.md). It is the
// only locale with a shipped catalog in this foundation change: additional
// locales land as stacked follow-up PRs, one per locale, each following the
// recipe in docs/TRANSLATORS.md. Which pair the app actually offers is
// configured at build/dev time through GEV_DEFAULT_LOCALE /
// GEV_SECONDARY_LOCALE (vite.config.js client defines) and resolved here —
// pair members are validated against the shipped catalogs, so while only
// English ships the pair degenerates to en-only (a single EN button).
// This module owns ONLY locale resolution and document language metadata —
// catalogs and translation helpers live in src/i18n/index.js.
// Which locale the page runs in: ?lang= → stored choice → browser languages →
// English. Only shipped locales are ever selected.
/** Durable preference key. Matches the repo convention gev:<name>:v1. */
export const LOCALE_STORAGE_KEY = 'gev:locale:v1';
/**
* Locales with a shipped catalog. 'en' is the fallback catalog locale
* (DEFAULT_LOCALE); every entry here can be normalized and looked up, though
* only locales in the configured PAIR (see resolveLocalePair) are offered in
* the UI and accepted during resolution. A locale PR appends its code here
* alongside its catalogs (docs/TRANSLATORS.md) — nothing else flips shipping.
*/
export const CATALOG_LOCALES = Object.freeze(['en']);
/**
* Locale codes normalizeLocale folds regional variants for. Kept ahead of
* shipping on purpose: normalization is locale-code hygiene ('es-MX' → 'es'),
* while SHIPPING is catalog-driven through CATALOG_LOCALES — so a locale PR
* edits only CATALOG_LOCALES plus its catalog files, and a code listed here
* without a catalog normalizes but is never offerable or resolvable.
*/
const NORMALIZABLE_LOCALES = Object.freeze(['en', 'es', 'fr', 'ru', 'uk']);
/** Fallback catalog locale and no-config default: always English. */
/** Source language and fallback. */
export const DEFAULT_LOCALE = 'en';
/**
* Built-in pair used when no env/config shapes one, or a configured one is
* unusable. English-only while English is the only shipped catalog: the
* selector degenerates to a single EN button until a secondary locale PR
* restores the built-in pair.
* Shipped locales, in selector order. Adding one: locales/<code>.json, an
* import in index.js, and an entry here (see docs/TRANSLATORS.md).
*/
const FALLBACK_PAIR = Object.freeze({
defaultLocale: 'en',
secondaryLocale: 'en',
});
export const LOCALES = Object.freeze(['en', 'ru']);
/** Document metadata per normalizable locale code. All of them are LTR. */
export const LOCALE_METADATA = Object.freeze({
en: Object.freeze({ dir: 'ltr' }),
es: Object.freeze({ dir: 'ltr' }),
fr: Object.freeze({ dir: 'ltr' }),
ru: Object.freeze({ dir: 'ltr' }),
uk: Object.freeze({ dir: 'ltr' }),
});
/** Each locale named in its own language, for the selector. */
export const LOCALE_NAMES = Object.freeze({ en: 'English', ru: 'Русский' });
/** Right-to-left locales. */
const RTL_LOCALES = Object.freeze([]);
/**
* Map any candidate language tag to a known locale code. 'es'/'fr'/'ru'/'uk'
* and every regional variant ('es-MX', 'fr_CA', 'ru-KZ', 'uk_UA') resolve to the
* primary tag; anything unknown (or not a string) resolves to null so the
* caller's precedence chain can keep looking. Knowing a code is hygiene, not
* shipping: offering/resolving a locale additionally requires a catalog
* (CATALOG_LOCALES) — see resolveLocalePair.
* @param {*} candidate Raw locale tag (?lang= value, stored value, navigator entry).
* @returns {'en'|'es'|'fr'|'ru'|'uk'|null}
* Map any language tag to a shipped locale: 'ru-RU' and 'RU' → 'ru'.
* @returns {string|null}
*/
export function normalizeLocale(candidate) {
if (typeof candidate !== 'string') return null;
const primary = candidate.trim().toLowerCase().split(/[-_]/)[0];
return NORMALIZABLE_LOCALES.includes(primary) ? primary : null;
return LOCALES.includes(primary) ? primary : null;
}
/**
* Read the configured pair from the two client defines. Both spellings are
* replaced TEXTUALLY by Vite (vite.config.js `define`), so they must stay
* verbatim member expressions. Under node:test `import.meta.env` is absent
* and the first access throws; the catch turns that into "use the defaults".
* @returns {{defaultLocale?: string, secondaryLocale?: string}}
*/
function readEnvLocalePair() {
function queryLocale(location) {
try {
return {
defaultLocale: import.meta.env.GEV_DEFAULT_LOCALE,
secondaryLocale: import.meta.env.GEV_SECONDARY_LOCALE,
};
} catch {
return {};
}
}
/** A blank string means "unset" (an empty .env line), not an invalid value. */
function orBuiltIn(value, builtIn) {
return typeof value === 'string' && value.trim() ? value : builtIn;
}
/** Development-only diagnostics, same gate as warnMissingKey in index.js. */
function warnInvalidPair(rawDefault, rawSecondary) {
// 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] unusable locale pair ${JSON.stringify(String(rawDefault))}/` +
`${JSON.stringify(String(rawSecondary))} — offering English only`,
);
}
/**
* Resolve the offered locale pair. With no argument the two client defines are
* read (GEV_DEFAULT_LOCALE / GEV_SECONDARY_LOCALE; blank means the built-in
* value); an explicit { defaultLocale, secondaryLocale } injects a pair for
* tests and embedders — injected fields never fall through to the env.
*
* SHIPPING IS CATALOG-DRIVEN: both members must name a locale in
* CATALOG_LOCALES. An unknown code, an unshipped code (a locale PR not yet
* merged), or a degenerate pair (the same locale twice) returns the built-in
* pair instead, and a dev-only console.warn explains why — except when the
* raw values already ARE the built-in pair, which is the normal unconfigured
* state and must stay silent. While English is the only shipped catalog the
* built-in pair is en-only, so the selector renders a single EN button.
*
* availableLocales is the deduped [default, secondary, 'en'] triple — English
* is always shippable because its catalog is the unconditional fallback.
* @param {{defaultLocale?: string, secondaryLocale?: string}} [config]
* @returns {{defaultLocale: string, secondaryLocale: string, availableLocales: readonly string[]}} Frozen.
*/
export function resolveLocalePair(config) {
const injected =
typeof config === 'object' &&
config !== null &&
(config.defaultLocale !== undefined ||
config.secondaryLocale !== undefined);
const raw = injected ? config : readEnvLocalePair();
const rawDefault = orBuiltIn(raw?.defaultLocale, FALLBACK_PAIR.defaultLocale);
const rawSecondary = orBuiltIn(
raw?.secondaryLocale,
FALLBACK_PAIR.secondaryLocale,
);
const defaultLocale = normalizeLocale(rawDefault);
const secondaryLocale = normalizeLocale(rawSecondary);
const offerable = Boolean(
defaultLocale &&
secondaryLocale &&
CATALOG_LOCALES.includes(defaultLocale) &&
CATALOG_LOCALES.includes(secondaryLocale),
);
if (!offerable || defaultLocale === secondaryLocale) {
// The built-in pair itself is never a misconfiguration: only a raw value
// that had to be corrected warns on the dev server.
if (
rawDefault !== FALLBACK_PAIR.defaultLocale ||
rawSecondary !== FALLBACK_PAIR.secondaryLocale
) {
warnInvalidPair(rawDefault, rawSecondary);
}
return pairOf(FALLBACK_PAIR.defaultLocale, FALLBACK_PAIR.secondaryLocale);
}
return pairOf(defaultLocale, secondaryLocale);
}
/** Freeze the deduped pair + always-shipped English. */
function pairOf(defaultLocale, secondaryLocale) {
const availableLocales = [
...new Set([defaultLocale, secondaryLocale, DEFAULT_LOCALE]),
];
return Object.freeze({
defaultLocale,
secondaryLocale,
availableLocales: Object.freeze(availableLocales),
});
}
/**
* Locales the app offers and accepts, in selector order: the configured
* default, the secondary locale, then English if not already present.
* @param {{defaultLocale?: string, secondaryLocale?: string}} [config] Injectable pair (tests/embedders).
* @returns {readonly string[]}
*/
export function availableLocales(config) {
return resolveLocalePair(config).availableLocales;
}
/**
* One-shot `?lang=` override, read from the SEARCH string only.
*
* The override is never persisted, and it must never leak into the URL hash or
* share links: it dies with the URL it arrived in (persistLocaleAndReload in
* src/i18n/index.js strips it when a choice is stored). An unsupported value —
* or a shipped locale outside the configured pair — is ignored rather than
* forced to English; the rest of the precedence chain still gets to speak.
* @param {{search?: string}|null} location
* @param {readonly string[]} availableLocales Resolved pair locales.
* @returns {string|null}
*/
function queryLocaleOverride(location, availableLocales) {
const search = location?.search;
if (!search) return null;
try {
return normalizeIfAvailable(
new URLSearchParams(search).get('lang'),
availableLocales,
);
return normalizeLocale(new URLSearchParams(location?.search).get('lang'));
} catch {
return null;
}
}
/** Normalize a candidate, keeping it only when the pair offers that locale. */
function normalizeIfAvailable(candidate, availableLocales) {
const normalized = normalizeLocale(candidate);
return normalized && availableLocales.includes(normalized)
? normalized
: null;
}
/*
* STORAGE ACCESS IS LAZY AND GUARDED (same pattern as src/firstRunExperience.js):
* the `globalThis.localStorage` getter itself can throw SecurityError in Safari
* private mode or under enterprise policies, so it is only touched inside a
* try/catch at the moment of use — never in a default parameter. An injected
* `storage` (tests, embedders) short-circuits the global entirely; `undefined`
* means "use the global".
*/
/** Read the stored preference, treating every failure as "nothing stored".
* A stored locale outside the current pair reads as absent. */
function readStoredLocale(storage, availableLocales) {
function readStoredLocale(storage) {
try {
const store = storage !== undefined ? storage : globalThis.localStorage;
return normalizeIfAvailable(
store?.getItem?.(LOCALE_STORAGE_KEY),
availableLocales,
);
return normalizeLocale(store?.getItem?.(LOCALE_STORAGE_KEY));
} catch {
return null;
}
}
/**
* Write the stored preference, best-effort. Never throws. The stored value is
* any known locale code (pair membership is re-checked at resolution time, so
* a choice made under one configuration — or for a locale whose catalog has
* not shipped yet — degrades gracefully to the pair's default).
* @returns {boolean} true only if the value actually landed.
*/
/** Store the choice; false when storage is unavailable. */
export function writeStoredLocale(locale, storage) {
const normalized = normalizeLocale(locale) || DEFAULT_LOCALE;
try {
@@ -252,63 +55,33 @@ export function writeStoredLocale(locale, storage) {
store.setItem(LOCALE_STORAGE_KEY, normalized);
return true;
} catch {
// Privacy-restricted or quota-exhausted storage must never break the app.
return false;
}
}
/**
* Resolve the active locale. Precedence (docs/TRANSLATORS.md):
*
* ?lang=<locale> → stored preference → navigator.languages → configured default
*
* Every step only accepts locales in the configured pair (which always
* includes English); each step that yields nothing (absent or out-of-pair)
* defers to the next. Without env configuration the built-in pair is en-only
* (English is the only shipped catalog), so every step resolves English —
* byte-identical to the pre-configuration resolution chain.
* @param {object} [input]
* @param {{search?: string, href?: string}|null} [input.location] Location to read ?lang= from.
* @param {object|null} [input.storage] Injected localStorage-like store; `undefined` uses the global.
* @param {readonly string[]|null} [input.languages] Browser language list.
* @param {{defaultLocale?: string, secondaryLocale?: string}} [input.config] Injectable pair (tests/embedders); omit to read the client defines.
* @returns {string}
*/
/** Resolve the boot locale. Every input is injectable for tests. */
export function resolveLocale({
location = globalThis.location,
storage,
languages = globalThis.navigator?.languages,
config,
} = {}) {
const { availableLocales: offered, defaultLocale } =
resolveLocalePair(config);
const override = queryLocaleOverride(location, offered);
if (override) return override;
const stored = readStoredLocale(storage, offered);
const fromQuery = queryLocale(location);
if (fromQuery) return fromQuery;
const stored = readStoredLocale(storage);
if (stored) return stored;
if (Array.isArray(languages)) {
for (const candidate of languages) {
const normalized = normalizeIfAvailable(candidate, offered);
if (normalized) return normalized;
}
for (const candidate of Array.isArray(languages) ? languages : []) {
const normalized = normalizeLocale(candidate);
if (normalized) return normalized;
}
return defaultLocale;
return DEFAULT_LOCALE;
}
/**
* Reflect the active locale on the document: `<html lang>` and `<html dir>`
* must always agree with the language actually being rendered.
* @param {Document} doc Document whose documentElement carries the metadata.
* @param {string} locale Locale to apply (normalized here).
* @returns {boolean} true when the metadata was written.
*/
/** Set <html lang> and <html dir>. */
export function applyDocumentLanguage(doc, locale = DEFAULT_LOCALE) {
const root = doc?.documentElement;
if (!root) return false;
const normalized = normalizeLocale(locale) || DEFAULT_LOCALE;
root.lang = normalized;
root.dir = (
LOCALE_METADATA[normalized] || LOCALE_METADATA[DEFAULT_LOCALE]
).dir;
root.dir = RTL_LOCALES.includes(normalized) ? 'rtl' : 'ltr';
return true;
}