// 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/.ts` file mirroring the shape of // `locales/en.ts` (TypeScript enforces matching keys via the // `Record` 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: // `

{i18n.t('login.title')}

` 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>; } 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> > = SUPPORTED_LOCALES.reduce( (acc, entry) => { acc[entry.code] = entry.translations; return acc; }, {} as Record>>, ); /** * 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 { 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();