import { readonly, ref } from 'vue'; import type { App, InjectionKey } from 'vue'; import { en } from '@/locales/en'; import type { LocalizationSettings, TranslationCatalog, TranslationKey, TranslationParameters, } from '@/types/localization'; const defaultLocale = 'en'; const catalogs: Record = { en }; function placeholders(message: string): string[] { return [ ...new Set( [...message.matchAll(/\{(\w+)\}/g)].map((match) => match[1]), ), ].sort(); } /** Validate every catalog, including messages that no component has rendered yet. */ export function validateCatalog(catalog: TranslationCatalog): void { for (const key of Object.keys(en) as TranslationKey[]) { const message = Object.hasOwn(catalog, key) ? catalog[key] : undefined; const source = en[key]; if (message === undefined || typeof message !== typeof source) { throw new Error(`Missing or invalid translation: ${key}`); } const expected = placeholders( typeof source === 'string' ? source : source.other, ); const forms = typeof message === 'string' ? [message] : Object.values(message ?? {}); if ( typeof message !== 'string' && (!message || typeof message.other !== 'string') ) { throw new Error(`Missing plural fallback: ${key}`); } for (const form of forms) { if ( typeof form !== 'string' || form.trim() === '' || JSON.stringify(placeholders(form)) !== JSON.stringify(expected) ) { throw new Error(`Invalid translation placeholders: ${key}`); } } } } export function createLocalization( settings: Partial = {}, availableCatalogs: Record = catalogs, ) { const messages: Record = { ...availableCatalogs, en, }; for (const catalog of Object.values(messages)) { validateCatalog(catalog); } function resolveLocale(requested: string): string { try { const [canonical] = Intl.getCanonicalLocales( requested.replaceAll('_', '-'), ); if (Object.hasOwn(messages, canonical)) { return canonical; } const language = canonical.split('-')[0]; return Object.hasOwn(messages, language) ? language : defaultLocale; } catch { return defaultLocale; } } const locale = ref(resolveLocale(settings.locale ?? defaultLocale)); const timeZone = settings.timeZone ?? 'UTC'; function number( value: number, options: Intl.NumberFormatOptions = {}, ): string { return new Intl.NumberFormat(locale.value, options).format(value); } function date( value: Date | number, options: Intl.DateTimeFormatOptions = {}, ): string { return new Intl.DateTimeFormat(locale.value, { timeZone, ...options, }).format(value); } /** Interpolate plain text only; render with Vue text bindings, never v-html. */ function t( key: TranslationKey, parameters: TranslationParameters = {}, ): string { const catalog = messages[locale.value]; if (!Object.hasOwn(catalog, key)) { throw new Error(`Missing translation: ${key}`); } const message = catalog[key]; let template: string; if (typeof message === 'string') { template = message; } else { const count = parameters.count; if (typeof count !== 'number' || !Number.isFinite(count)) { throw new Error(`Translation requires a finite count: ${key}`); } template = message[new Intl.PluralRules(locale.value).select(count)] ?? message.other; } return template.replace(/\{(\w+)\}/g, (_, name: string) => { if (!Object.hasOwn(parameters, name)) { throw new Error( `Missing translation parameter: ${key}.${name}`, ); } const value = parameters[name]; return typeof value === 'number' ? number(value) : value; }); } const localization = { locale: readonly(locale), t, number, date, setLocale(requested: string): void { locale.value = resolveLocale(requested); }, install(app: App): void { app.provide(localizationKey, localization); }, }; return localization; } export type Localization = ReturnType; export const localizationKey: InjectionKey = Symbol('localization');