Files
galaxy-game/ui/frontend/src/lib/i18n/index.svelte.ts
T
Ilia Denisov 1e62837c68
Tests · UI / test (push) Waiting to run
Tests · UI / test (pull_request) Successful in 2m11s
feat(ui): locale persistence + i18n completeness guards (F3)
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>
2026-05-22 08:48:13 +02:00

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();