Files
WRNexusJS/packages/i18n/src/advanced.ts
T
Clintchiz 586a6db8ff
Quality / quality (ubuntu-latest) (push) Failing after 21s
Quality / quality (windows-latest) (push) Canceled after 0s
release: WRNexusJS 0.8.0
2026-08-02 23:18:51 +05:30

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;
}