import type { Messages, ResolvedI18n } from "./index.ts"; export function flattenMessages( messages: Messages, prefix = "", output: Record = {}, ): Record { 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, ): 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(); const dateCache = new Map(); 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 = {}; 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, 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; }