178 lines
5.9 KiB
TypeScript
178 lines
5.9 KiB
TypeScript
import type { Messages, ResolvedI18n } from "./index.ts";
|
|
|
|
export function flattenMessages(
|
|
messages: Messages,
|
|
prefix = "",
|
|
output: Record<string, string> = {},
|
|
): Record<string, string> {
|
|
for (const [key, value] of Object.entries(messages)) {
|
|
const path = prefix ? `${prefix}.${key}` : key;
|
|
if (typeof value === "string") output[path] = value;
|
|
else if (value && typeof value === "object" && !Array.isArray(value))
|
|
flattenMessages(value as Messages, path, output);
|
|
}
|
|
return output;
|
|
}
|
|
|
|
export function localeFallbacks(locale: string, fallback = "en"): string[] {
|
|
const normalized = locale.replace(/_/g, "-");
|
|
const values = [normalized];
|
|
const base = normalized.split("-")[0]!;
|
|
if (base !== normalized) values.push(base);
|
|
if (!values.includes(fallback)) values.push(fallback);
|
|
return values;
|
|
}
|
|
|
|
export function translationCoverage(i18n: ResolvedI18n): Record<
|
|
string,
|
|
{
|
|
translated: number;
|
|
total: number;
|
|
percentage: number;
|
|
missing: string[];
|
|
extra: string[];
|
|
}
|
|
> {
|
|
const canonical = flattenMessages(i18n.messages[i18n.default] ?? {});
|
|
const canonicalKeys = new Set(Object.keys(canonical));
|
|
const total = canonicalKeys.size;
|
|
const output: Record<
|
|
string,
|
|
{ translated: number; total: number; percentage: number; missing: string[]; extra: string[] }
|
|
> = {};
|
|
for (const lang of i18n.langs) {
|
|
const messages = flattenMessages(i18n.messages[lang] ?? {});
|
|
const keys = new Set(Object.keys(messages));
|
|
const missing = [...canonicalKeys].filter((key) => !keys.has(key)).sort();
|
|
const extra = [...keys].filter((key) => !canonicalKeys.has(key)).sort();
|
|
const translated = total - missing.length;
|
|
output[lang] = {
|
|
translated,
|
|
total,
|
|
percentage: total ? Math.round((translated / total) * 10000) / 100 : 100,
|
|
missing,
|
|
extra,
|
|
};
|
|
}
|
|
return output;
|
|
}
|
|
|
|
export interface LocaleFormatter {
|
|
number(value: number, options?: Intl.NumberFormatOptions): string;
|
|
currency(
|
|
value: number,
|
|
currency: string,
|
|
options?: Omit<Intl.NumberFormatOptions, "style" | "currency">,
|
|
): string;
|
|
date(value: Date | number | string, options?: Intl.DateTimeFormatOptions): string;
|
|
relative(
|
|
value: number,
|
|
unit: Intl.RelativeTimeFormatUnit,
|
|
options?: Intl.RelativeTimeFormatOptions,
|
|
): string;
|
|
list(values: string[], options?: Intl.ListFormatOptions): string;
|
|
}
|
|
|
|
export function createLocaleFormatter(
|
|
locale: string,
|
|
timeZone?: string,
|
|
calendar?: string,
|
|
): LocaleFormatter {
|
|
const numberCache = new Map<string, Intl.NumberFormat>();
|
|
const dateCache = new Map<string, Intl.DateTimeFormat>();
|
|
const key = (value: unknown) => JSON.stringify(value ?? {});
|
|
return {
|
|
number(value, options) {
|
|
const cacheKey = key(options);
|
|
let format = numberCache.get(cacheKey);
|
|
if (!format) {
|
|
format = new Intl.NumberFormat(locale, options);
|
|
numberCache.set(cacheKey, format);
|
|
}
|
|
return format.format(value);
|
|
},
|
|
currency(value, currency, options) {
|
|
return new Intl.NumberFormat(locale, { ...options, style: "currency", currency }).format(
|
|
value,
|
|
);
|
|
},
|
|
date(value, options = { dateStyle: "medium" }) {
|
|
const resolved = {
|
|
...options,
|
|
...(timeZone ? { timeZone } : {}),
|
|
...(calendar ? { calendar } : {}),
|
|
};
|
|
const cacheKey = key(resolved);
|
|
let format = dateCache.get(cacheKey);
|
|
if (!format) {
|
|
format = new Intl.DateTimeFormat(locale, resolved);
|
|
dateCache.set(cacheKey, format);
|
|
}
|
|
return format.format(value instanceof Date ? value : new Date(value));
|
|
},
|
|
relative: (value, unit, options) =>
|
|
new Intl.RelativeTimeFormat(locale, options).format(value, unit),
|
|
list: (values, options) => new Intl.ListFormat(locale, options).format(values),
|
|
};
|
|
}
|
|
|
|
function complexExpression(template: string, start: number) {
|
|
const header = /^\{(\w+),\s*(plural|select),\s*/.exec(template.slice(start));
|
|
if (!header) return null;
|
|
let cursor = start + header[0].length;
|
|
const choices: Record<string, string> = {};
|
|
while (cursor < template.length) {
|
|
while (/\s/.test(template[cursor] ?? "")) cursor++;
|
|
if (template[cursor] === "}")
|
|
return { name: header[1]!, kind: header[2]!, choices, end: cursor + 1 };
|
|
const key = /^(=?[\w-]+)/.exec(template.slice(cursor));
|
|
if (!key) return null;
|
|
cursor += key[0].length;
|
|
while (/\s/.test(template[cursor] ?? "")) cursor++;
|
|
if (template[cursor] !== "{") return null;
|
|
const bodyStart = ++cursor;
|
|
let depth = 1;
|
|
while (cursor < template.length && depth) {
|
|
if (template[cursor] === "{") depth++;
|
|
else if (template[cursor] === "}") depth--;
|
|
cursor++;
|
|
}
|
|
if (depth) return null;
|
|
choices[key[0]] = template.slice(bodyStart, cursor - 1);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** ICU-style plural/select templates with exact values and recursive interpolation. */
|
|
export function formatMessage(
|
|
template: string,
|
|
params: Record<string, string | number>,
|
|
locale: string,
|
|
): string {
|
|
let result = "";
|
|
for (let cursor = 0; cursor < template.length;) {
|
|
const expression = template[cursor] === "{" ? complexExpression(template, cursor) : null;
|
|
if (!expression) {
|
|
result += template[cursor++]!;
|
|
continue;
|
|
}
|
|
const raw = params[expression.name];
|
|
const selector = expression.kind === "plural" ? `=${Number(raw ?? 0)}` : String(raw ?? "other");
|
|
const category =
|
|
expression.kind === "plural"
|
|
? new Intl.PluralRules(locale).select(Number(raw ?? 0))
|
|
: selector;
|
|
const selected =
|
|
expression.choices[selector] ??
|
|
expression.choices[category] ??
|
|
expression.choices.other ??
|
|
"";
|
|
result += formatMessage(selected.replace(/#/g, String(raw ?? 0)), params, locale);
|
|
cursor = expression.end;
|
|
}
|
|
result = result.replace(/\{(\w+)\}/g, (_match, name: string) =>
|
|
name in params ? String(params[name]) : `{${name}}`,
|
|
);
|
|
return result;
|
|
}
|