1e62837c68
An audit found the client already i18n-first: one hard-coded UI string (the battle-scene aria-label, now keyed) and en/ru already share an identical 692-key set. - Persist the locale: i18n.setLocale writes localStorage (galaxy-locale) and the store boots from stored > browser detection > default, so a language switch survives reloads. - tests/i18n-completeness.test.ts: en/ru key-set parity, non-empty values, and locale persistence. - Docs: ui/docs/i18n.md; mark F3 done in ui/PLAN-finalize.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
172 lines
5.3 KiB
TypeScript
172 lines
5.3 KiB
TypeScript
// Lightweight i18n primitive used by the login form, the root
|
|
// layout, and the lobby placeholder. The translation table is a
|
|
// per-locale flat dictionary keyed by dotted strings; lookup falls
|
|
// back to the default (English) locale when a key is missing.
|
|
//
|
|
// Adding a new language is a two-step change inside this folder:
|
|
// 1. drop a `locales/<bcp47>.ts` file mirroring the shape of
|
|
// `locales/en.ts` (TypeScript enforces matching keys via the
|
|
// `Record<keyof typeof en, string>` annotation in `ru.ts`);
|
|
// 2. register the file in `SUPPORTED_LOCALES` below — that single
|
|
// list drives the language picker UI and the runtime lookup
|
|
// table at the same time.
|
|
//
|
|
// The locale state is exposed through a Svelte 5 runes singleton
|
|
// (`i18n`) so components stay reactive without ceremony:
|
|
// `<p>{i18n.t('login.title')}</p>` re-renders whenever
|
|
// `i18n.locale` changes. Phase 35 will swap this primitive for a
|
|
// fuller solution once message-format pluralisation and lazy
|
|
// loading become necessary.
|
|
|
|
import enTranslations from "./locales/en";
|
|
import ruTranslations from "./locales/ru";
|
|
|
|
export type Locale = "en" | "ru";
|
|
export type TranslationKey = keyof typeof enTranslations;
|
|
|
|
export interface LocaleEntry {
|
|
readonly code: Locale;
|
|
readonly nativeName: string;
|
|
readonly translations: Readonly<Record<TranslationKey, string>>;
|
|
}
|
|
|
|
export const SUPPORTED_LOCALES: readonly LocaleEntry[] = [
|
|
{
|
|
code: "en",
|
|
nativeName: "English",
|
|
translations: enTranslations,
|
|
},
|
|
{
|
|
code: "ru",
|
|
nativeName: "Русский",
|
|
translations: ruTranslations,
|
|
},
|
|
];
|
|
|
|
export const DEFAULT_LOCALE: Locale = "en";
|
|
|
|
const TRANSLATIONS_BY_LOCALE: Record<
|
|
Locale,
|
|
Readonly<Record<TranslationKey, string>>
|
|
> = SUPPORTED_LOCALES.reduce(
|
|
(acc, entry) => {
|
|
acc[entry.code] = entry.translations;
|
|
return acc;
|
|
},
|
|
{} as Record<Locale, Readonly<Record<TranslationKey, string>>>,
|
|
);
|
|
|
|
/**
|
|
* detectInitialLocale returns the best supported locale match for
|
|
* the supplied BCP 47 preference list. The web target passes
|
|
* `navigator.languages`; native wrappers pass the system locale
|
|
* (one entry). The first preference whose primary subtag matches
|
|
* a `SUPPORTED_LOCALES` entry wins; otherwise [DEFAULT_LOCALE].
|
|
*/
|
|
export function detectInitialLocale(
|
|
preferences?: readonly string[],
|
|
): Locale {
|
|
const prefs = preferences ?? readBrowserPreferences();
|
|
for (const tag of prefs) {
|
|
const primary = primarySubtag(tag);
|
|
if (primary === null) {
|
|
continue;
|
|
}
|
|
const found = SUPPORTED_LOCALES.find((entry) => entry.code === primary);
|
|
if (found !== undefined) {
|
|
return found.code;
|
|
}
|
|
}
|
|
return DEFAULT_LOCALE;
|
|
}
|
|
|
|
function readBrowserPreferences(): readonly string[] {
|
|
if (typeof navigator === "undefined") {
|
|
return [];
|
|
}
|
|
if (Array.isArray(navigator.languages) && navigator.languages.length > 0) {
|
|
return navigator.languages;
|
|
}
|
|
if (typeof navigator.language === "string" && navigator.language !== "") {
|
|
return [navigator.language];
|
|
}
|
|
return [];
|
|
}
|
|
|
|
function primarySubtag(tag: string): Locale | null {
|
|
const trimmed = tag.trim().toLowerCase();
|
|
if (trimmed.length === 0) {
|
|
return null;
|
|
}
|
|
const code = trimmed.split(/[-_]/)[0] ?? "";
|
|
return isLocale(code) ? code : null;
|
|
}
|
|
|
|
function isLocale(value: string): value is Locale {
|
|
return SUPPORTED_LOCALES.some((entry) => entry.code === value);
|
|
}
|
|
|
|
/** `localStorage` key holding the user's explicit locale choice. */
|
|
export const LOCALE_STORAGE_KEY = "galaxy-locale";
|
|
|
|
function readStoredLocale(): Locale | null {
|
|
if (typeof localStorage === "undefined") return null;
|
|
const value = localStorage.getItem(LOCALE_STORAGE_KEY);
|
|
return value !== null && isLocale(value) ? value : null;
|
|
}
|
|
|
|
/**
|
|
* initialLocale resolves the boot locale: an explicit stored choice
|
|
* wins, otherwise the browser/system preference, otherwise the default.
|
|
*/
|
|
function initialLocale(): Locale {
|
|
return readStoredLocale() ?? detectInitialLocale();
|
|
}
|
|
|
|
class I18nStore {
|
|
locale: Locale = $state(initialLocale());
|
|
|
|
/**
|
|
* setLocale changes the active locale and persists the choice to
|
|
* `localStorage`, so it survives a reload. Components reading
|
|
* `i18n.t(...)` re-render automatically through the rune.
|
|
*/
|
|
setLocale(next: Locale): void {
|
|
this.locale = next;
|
|
if (typeof localStorage !== "undefined") {
|
|
localStorage.setItem(LOCALE_STORAGE_KEY, next);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* t looks up `key` in the active locale, falling back to the
|
|
* default locale when the key is missing. `params` is an optional
|
|
* `{name -> value}` map; placeholders in the template (`{name}`)
|
|
* are replaced literally with no escaping — callers are expected
|
|
* to feed user-safe values.
|
|
*/
|
|
t(key: TranslationKey, params?: Record<string, string>): string {
|
|
const active = TRANSLATIONS_BY_LOCALE[this.locale];
|
|
const fallback = TRANSLATIONS_BY_LOCALE[DEFAULT_LOCALE];
|
|
const template = active[key] ?? fallback[key] ?? key;
|
|
if (params === undefined) {
|
|
return template;
|
|
}
|
|
return template.replace(/\{(\w+)\}/g, (match, name: string) => {
|
|
const value = params[name];
|
|
return value === undefined ? match : value;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* resetForTests forces the singleton back to its module-load
|
|
* state. Production code never calls this; the Vitest harness
|
|
* uses it to keep cases independent.
|
|
*/
|
|
resetForTests(initial: Locale = detectInitialLocale()): void {
|
|
this.locale = initial;
|
|
}
|
|
}
|
|
|
|
export const i18n = new I18nStore();
|