87 lines
2.7 KiB
TypeScript
87 lines
2.7 KiB
TypeScript
import { derived, writable, type Readable } from 'svelte/store';
|
|
import { browser } from '$app/environment';
|
|
|
|
/**
|
|
* Minimal i18n layer — no external deps.
|
|
*
|
|
* Lookup is key-based (`t('panel.title')`) against flat or nested JSON
|
|
* dictionaries. Interpolation uses `{name}` placeholders. Missing keys fall
|
|
* back to the key itself so screens remain functional during translation.
|
|
*
|
|
* Adding a locale
|
|
* ---------------
|
|
* 1. Drop a JSON file into `src/lib/i18n/locales/<code>.json`.
|
|
* 2. Register it in `loaders` below.
|
|
* 3. Expose it in the `Locale` type.
|
|
*/
|
|
|
|
export type Locale = 'ru' | 'en';
|
|
|
|
export const DEFAULT_LOCALE: Locale = 'ru';
|
|
export const SUPPORTED_LOCALES: Locale[] = ['ru', 'en'];
|
|
const STORAGE_KEY = 'locale';
|
|
|
|
type Messages = Record<string, unknown>;
|
|
|
|
const loaders: Record<Locale, () => Promise<{ default: Messages }>> = {
|
|
ru: () => import('./locales/ru.json'),
|
|
en: () => import('./locales/en.json'),
|
|
};
|
|
|
|
const messages = writable<Record<Locale, Messages>>({} as Record<Locale, Messages>);
|
|
const locale = writable<Locale>(DEFAULT_LOCALE);
|
|
|
|
async function loadLocale(code: Locale): Promise<void> {
|
|
const mod = await loaders[code]();
|
|
messages.update((m) => ({ ...m, [code]: mod.default }));
|
|
}
|
|
|
|
export async function setLocale(code: Locale): Promise<void> {
|
|
if (!SUPPORTED_LOCALES.includes(code)) return;
|
|
await loadLocale(code);
|
|
locale.set(code);
|
|
if (browser) localStorage.setItem(STORAGE_KEY, code);
|
|
}
|
|
|
|
export async function initI18n(): Promise<void> {
|
|
const stored = browser ? (localStorage.getItem(STORAGE_KEY) as Locale | null) : null;
|
|
const next = stored && SUPPORTED_LOCALES.includes(stored) ? stored : DEFAULT_LOCALE;
|
|
await setLocale(next);
|
|
}
|
|
|
|
function lookup(dict: Messages, key: string): string | undefined {
|
|
const parts = key.split('.');
|
|
let node: unknown = dict;
|
|
for (const p of parts) {
|
|
if (node && typeof node === 'object' && p in (node as Messages)) {
|
|
node = (node as Messages)[p];
|
|
} else {
|
|
return undefined;
|
|
}
|
|
}
|
|
return typeof node === 'string' ? node : undefined;
|
|
}
|
|
|
|
function interpolate(template: string, values: Record<string, string | number>): string {
|
|
return template.replace(/\{(\w+)\}/g, (_, name) =>
|
|
name in values ? String(values[name]) : `{${name}}`,
|
|
);
|
|
}
|
|
|
|
export interface Translator {
|
|
(key: string, values?: Record<string, string | number>): string;
|
|
}
|
|
|
|
export const t: Readable<Translator> = derived(
|
|
[locale, messages],
|
|
([$locale, $messages]) => {
|
|
const dict = $messages[$locale];
|
|
return (key: string, values?: Record<string, string | number>) => {
|
|
const str = dict ? lookup(dict, key) : undefined;
|
|
if (str === undefined) return key;
|
|
return values ? interpolate(str, values) : str;
|
|
};
|
|
},
|
|
);
|
|
|
|
export const currentLocale: Readable<Locale> = { subscribe: locale.subscribe };
|