160 lines
4.9 KiB
TypeScript
160 lines
4.9 KiB
TypeScript
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<string, TranslationCatalog> = { 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<LocalizationSettings> = {},
|
|
availableCatalogs: Record<string, TranslationCatalog> = catalogs,
|
|
) {
|
|
const messages: Record<string, TranslationCatalog> = {
|
|
...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<typeof createLocalization>;
|
|
export const localizationKey: InjectionKey<Localization> =
|
|
Symbol('localization');
|