// wayBetter i18n: a small, dependency-free message runtime for the shell, the panel, the page // runtime and plugins (published as `waybetter/i18n`). // // Messages are plain strings in an ICU MessageFormat subset ("ICU-lite"): // Hello, {name}! placeholder (inserted as text, never HTML) // {count, plural, =0 {No drafts} one {# draft} other {# drafts}} plural (Intl.PluralRules) // {n, selectordinal, one {#st} two {#nd} few {#rd} other {#th}} ordinal // {kind, select, post {Post} reply {Reply} other {Item}} select // {n, number} {n, number, percent} {d, date, short} {d, time} Intl number/date formats // '{literal}' '#' and '' (an apostrophe) ICU quoting // An apostrophe quotes only when it comes right before { } # or another apostrophe, so ordinary // English ("don't", "the developer's") needs no escaping. // `#` inside a plural is the (locale-formatted) number. Numbers, dates, relative times and lists // go through `Intl` in the active locale. // // Catalogs are data only: one JSON file per locale (`locales/.json`), English is the // source. Lookups fall back along a chain (pt-BR -> pt -> en). Missing keys are reported once per // key and locale through `onMissing` (development), never thrown. export type Messages = Readonly>; export type Dir = 'ltr' | 'rtl'; export type Params = Readonly>; /** A catalog file (`locales/.json`). See `i18n.schema.json` in the `waybetter` package. */ export type Catalog = { $schema?: string; /** BCP 47 tag of this file (`tr`, `pt`, `zh-Hant`, `ar-x-fm`). */ locale: string; /** The language's own name ("Türkçe"). */ name: string; /** Its English name ("Turkish"). */ englishName: string; dir?: Dir; /** `source` (English), `machine` (machine-drafted, needs a human), `reviewed` (checked by people). */ status: 'source' | 'machine' | 'reviewed'; reviewers?: string[]; /** Falls back to this locale before English (e.g. `ar-x-fm` -> `ar`). */ fallback?: string; messages: Record; }; // ------------------------------------------------------------------------------------------- // Locales /** Scripts written right to left, by primary language subtag. */ const RTL = new Set(['ar', 'arc', 'ckb', 'dv', 'fa', 'he', 'iw', 'ks', 'ku-arab', 'ps', 'sd', 'syr', 'ug', 'ur', 'yi']); /** * Site and legacy codes mapped to BCP 47: X's own settings use `msa`, `zh-cn`, `zh-tw`, `no` and * `en-gb`; old platforms say `iw`, `in`, `tl`. */ const ALIASES: Record = { msa: 'ms', 'zh-cn': 'zh-Hans', 'zh-sg': 'zh-Hans', zh: 'zh-Hans', 'zh-tw': 'zh-Hant', 'zh-hk': 'zh-Hant', 'zh-mo': 'zh-Hant', no: 'nb', nn: 'nb', iw: 'he', in: 'id', tl: 'fil', ji: 'yi', }; const TAG = /^[A-Za-z]{2,3}(-[A-Za-z0-9]{1,8}){0,4}$/; /** * A tag in canonical BCP 47 form (`en_gb` -> `en-GB`, `zh-cn` -> `zh-Hans`, `msa` -> `ms`), or null * when it is not a language tag at all. Private-use subtags survive (`ar-x-fm`). */ export function normalizeLocale(input: unknown): string | null { if (typeof input !== 'string') return null; const raw = input.trim().replace(/_/g, '-'); if (!raw || raw.length > 35 || !TAG.test(raw)) return null; const lower = raw.toLowerCase(); if (ALIASES[lower]) return ALIASES[lower]; // `zh-Hans-CN` / `zh-Hant-TW`: the script decides; the region is noise for our catalogs. const parts = lower.split('-'); if (parts[0] === 'zh') { if (parts.includes('hant') || ['tw', 'hk', 'mo'].includes(parts[1] ?? '')) return 'zh-Hant'; return 'zh-Hans'; } const privateUse = lower.indexOf('-x-'); if (privateUse > 0) { const base = normalizeLocale(raw.slice(0, privateUse)); return base ? `${base}-x-${lower.slice(privateUse + 3)}` : null; } const alias = ALIASES[parts[0]!]; const head = alias && !alias.includes('-') ? [alias, ...parts.slice(1)].join('-') : raw; try { return Intl.getCanonicalLocales(head)[0] ?? null; } catch { return null; } } /** The primary language subtag (`pt-BR` -> `pt`). */ export function languageOf(locale: string): string { return locale.split('-')[0]!.toLowerCase(); } /** Writing direction of a locale. */ export function direction(locale: string): Dir { const lower = locale.toLowerCase(); if (lower.startsWith('ku-arab')) return 'rtl'; return RTL.has(languageOf(lower)) ? 'rtl' : 'ltr'; } /** * Where lookups go, most specific first, ending with the source locale: `pt-BR` -> [pt-BR, pt, en]. * `fallbacks` maps a locale to the one it inherits from (a catalog's `fallback`, e.g. ar-x-fm -> ar). * Script variants never fall back to another script: `zh-Hant` does not use `zh-Hans`. */ export function fallbackChain(locale: string, source = 'en', fallbacks: Readonly> = {}): string[] { const out: string[] = []; const push = (tag: string | null | undefined) => { if (tag && !out.includes(tag)) out.push(tag); }; let current: string | null = normalizeLocale(locale); for (let guard = 0; current && guard < 8; guard += 1) { push(current); const explicit = fallbacks[current]; if (explicit) { current = normalizeLocale(explicit); continue; } const privateUse = current.indexOf('-x-'); if (privateUse > 0) { current = current.slice(0, privateUse); continue; } const parts = current.split('-'); if (parts.length <= 1) break; // zh-Hans / zh-Hant, sr-Latn: a script subtag is its own catalog. if (parts.length === 2 && /^[A-Z][a-z]{3}$/.test(parts[1]!)) break; current = parts.slice(0, -1).join('-'); } push(source); return out; } /** * Picks the best available locale for a list of preferences (most wanted first), by fallback chain: * `['pt-PT', 'en']` with `pt` available -> `pt`. Returns `fallback` when nothing matches. */ export function matchLocale(preferred: readonly unknown[], available: readonly string[], fallback = 'en'): string { const set = new Set(available); for (const wanted of preferred) { const tag = normalizeLocale(wanted); if (!tag) continue; for (const candidate of fallbackChain(tag, '')) { if (candidate && set.has(candidate)) return candidate; } } return fallback; } // ------------------------------------------------------------------------------------------- // Messages (ICU-lite) type Part = | string | { kind: 'arg'; name: string; format?: 'number' | 'date' | 'time'; style?: string } | { kind: 'pound' } | { kind: 'plural'; name: string; ordinal: boolean; offset: number; options: Record } | { kind: 'select'; name: string; options: Record }; export class MessageSyntaxError extends Error { readonly source: string; readonly index: number; constructor(message: string, source: string, index: number) { super(`${message} at ${index} in ${JSON.stringify(source.length > 80 ? `${source.slice(0, 80)}…` : source)}`); this.name = 'MessageSyntaxError'; this.source = source; this.index = index; } } const NAME = /^[A-Za-z_][A-Za-z0-9_]*$/; /** Parses a message into parts; throws `MessageSyntaxError` on malformed ICU. */ export function parseMessage(source: string): Part[] { let i = 0; const parseParts = (inPlural: boolean, depth: number): Part[] => { const parts: Part[] = []; let text = ''; const flush = () => { if (text) parts.push(text); text = ''; }; while (i < source.length) { const ch = source[i]!; if (ch === "'") { const next = source[i + 1]; if (next === "'") { text += "'"; i += 2; continue; } // An apostrophe only quotes before { } # (or another apostrophe); "don't" stays literal. if (next === '{' || next === '}' || next === '#') { // Quoted literal up to the next lone apostrophe. i += 1; while (i < source.length) { if (source[i] === "'") { if (source[i + 1] === "'") { text += "'"; i += 2; continue; } i += 1; break; } text += source[i]; i += 1; } continue; } text += ch; i += 1; continue; } if (ch === '}') { if (depth > 0) break; throw new MessageSyntaxError('unexpected }', source, i); } if (ch === '#' && inPlural) { flush(); parts.push({ kind: 'pound' }); i += 1; continue; } if (ch === '{') { flush(); parts.push(parseArgument(depth)); continue; } text += ch; i += 1; } flush(); return parts; }; const skipSpace = () => { while (i < source.length && /\s/.test(source[i]!)) i += 1; }; const readWord = () => { const start = i; while (i < source.length && /[^\s,{}]/.test(source[i]!)) i += 1; return source.slice(start, i); }; const expect = (ch: string) => { if (source[i] !== ch) throw new MessageSyntaxError(`expected ${ch}`, source, i); i += 1; }; const parseOptions = (inPlural: boolean, depth: number): Record => { const options: Record = {}; for (;;) { skipSpace(); if (source[i] === '}') break; const selector = readWord(); if (!selector) throw new MessageSyntaxError('expected a selector', source, i); skipSpace(); expect('{'); options[selector] = parseParts(inPlural, depth + 1); expect('}'); } if (!('other' in options)) throw new MessageSyntaxError('a plural or select needs an "other" option', source, i); return options; }; const parseArgument = (depth: number): Part => { expect('{'); skipSpace(); const name = readWord(); if (!NAME.test(name)) throw new MessageSyntaxError(`invalid placeholder name ${JSON.stringify(name)}`, source, i); skipSpace(); if (source[i] === '}') { i += 1; return { kind: 'arg', name }; } expect(','); skipSpace(); const type = readWord(); skipSpace(); if (type === 'plural' || type === 'selectordinal') { expect(','); skipSpace(); let offset = 0; const offsetMatch = /^offset:(\d+)/.exec(source.slice(i)); if (offsetMatch) { offset = Number(offsetMatch[1]); i += offsetMatch[0].length; } const options = parseOptions(true, depth); expect('}'); return { kind: 'plural', name, ordinal: type === 'selectordinal', offset, options }; } if (type === 'select') { expect(','); const options = parseOptions(false, depth); expect('}'); return { kind: 'select', name, options }; } if (type === 'number' || type === 'date' || type === 'time') { let style: string | undefined; if (source[i] === ',') { i += 1; skipSpace(); style = readWord(); skipSpace(); } expect('}'); return { kind: 'arg', name, format: type, style }; } throw new MessageSyntaxError(`unknown argument type ${JSON.stringify(type)}`, source, i); }; const parts = parseParts(false, 0); if (i < source.length) throw new MessageSyntaxError('unexpected }', source, i); return parts; } /** Placeholder names a message uses (plural/select arguments included), sorted. */ export function placeholders(source: string): string[] { const names = new Set(); const walk = (parts: Part[]) => { for (const part of parts) { if (typeof part === 'string' || part.kind === 'pound') continue; names.add(part.name); if (part.kind === 'plural' || part.kind === 'select') for (const option of Object.values(part.options)) walk(option); } }; walk(parseMessage(source)); return [...names].sort(); } /** Plural/select structure of a message: `{ count: { kind: 'plural', options: ['one', 'other'] } }`. */ export function argumentShapes(source: string): Record { const out: Record = {}; const walk = (parts: Part[]) => { for (const part of parts) { if (typeof part === 'string' || part.kind === 'pound' || part.kind === 'arg') continue; const kind = part.kind === 'select' ? 'select' : part.ordinal ? 'selectordinal' : 'plural'; const entry = (out[part.name] ??= { kind, options: [] }); for (const [selector, option] of Object.entries(part.options)) { if (!entry.options.includes(selector)) entry.options.push(selector); walk(option); } } }; walk(parseMessage(source)); return out; } const formatterCache = new Map(); function cached(key: string, make: () => T): T { let value = formatterCache.get(key) as T | undefined; if (!value) { value = make(); if (formatterCache.size > 256) formatterCache.clear(); formatterCache.set(key, value); } return value; } function safeLocale(locale: string): string { // Private-use tags (ar-x-fm) are valid BCP 47 but Intl may not know them: format as the base. const base = locale.split('-x-')[0]!; try { return Intl.getCanonicalLocales(base)[0] ?? 'en'; } catch { return 'en'; } } export function numberFormat(locale: string, options?: Intl.NumberFormatOptions): Intl.NumberFormat { const tag = safeLocale(locale); return cached(`n|${tag}|${JSON.stringify(options ?? {})}`, () => new Intl.NumberFormat(tag, options)); } export function dateFormat(locale: string, options?: Intl.DateTimeFormatOptions): Intl.DateTimeFormat { const tag = safeLocale(locale); return cached(`d|${tag}|${JSON.stringify(options ?? {})}`, () => new Intl.DateTimeFormat(tag, options)); } export function pluralRules(locale: string, ordinal = false): Intl.PluralRules { const tag = safeLocale(locale); return cached(`p|${tag}|${ordinal}`, () => new Intl.PluralRules(tag, { type: ordinal ? 'ordinal' : 'cardinal' })); } /** CLDR plural categories a locale uses (`tr`: one, other; `ar`: zero, one, two, few, many, other). */ export function pluralCategories(locale: string, ordinal = false): string[] { return [...pluralRules(locale, ordinal).resolvedOptions().pluralCategories]; } const NUMBER_STYLES: Record = { integer: { maximumFractionDigits: 0 }, percent: { style: 'percent' }, compact: { notation: 'compact' }, }; const DATE_STYLES: Record = { short: { dateStyle: 'short' }, medium: { dateStyle: 'medium' }, long: { dateStyle: 'long' }, full: { dateStyle: 'full' }, }; const TIME_STYLES: Record = { short: { timeStyle: 'short' }, medium: { timeStyle: 'medium' }, long: { timeStyle: 'long' }, full: { timeStyle: 'full' }, }; function toDate(value: unknown): Date | null { const date = value instanceof Date ? value : typeof value === 'number' || typeof value === 'string' ? new Date(value) : null; return date && Number.isFinite(date.getTime()) ? date : null; } function render(parts: Part[], params: Params, locale: string, pound: number | null, onMissingParam?: (name: string) => void): string { let out = ''; for (const part of parts) { if (typeof part === 'string') { out += part; continue; } if (part.kind === 'pound') { out += pound === null ? '#' : numberFormat(locale).format(pound); continue; } const has = Object.prototype.hasOwnProperty.call(params, part.name); const value = has ? params[part.name] : undefined; if (!has || value === undefined || value === null) { onMissingParam?.(part.name); out += `{${part.name}}`; continue; } if (part.kind === 'arg') { if (part.format === 'number' && typeof value === 'number') { out += numberFormat(locale, NUMBER_STYLES[part.style ?? ''] ?? undefined).format(value); } else if ((part.format === 'date' || part.format === 'time') && toDate(value)) { const styles = part.format === 'date' ? DATE_STYLES : TIME_STYLES; out += dateFormat(locale, styles[part.style ?? 'medium'] ?? styles.medium).format(toDate(value)!); } else if (typeof value === 'number') { out += numberFormat(locale).format(value); } else { out += String(value); } continue; } if (part.kind === 'select') { const key = String(value); out += render(part.options[key] ?? part.options.other!, params, locale, pound, onMissingParam); continue; } const n = typeof value === 'number' ? value : Number(value); if (!Number.isFinite(n)) { out += String(value); continue; } const exact = part.options[`=${n}`]; const shifted = n - part.offset; const option = exact ?? part.options[pluralRules(locale, part.ordinal).select(shifted)] ?? part.options.other!; out += render(option, params, locale, shifted, onMissingParam); } return out; } const compiled = new Map(); /** * Formats one message. Placeholder values are inserted as plain text; a malformed message renders * as its raw text (and `onError` hears about it) rather than throwing in the UI. */ export function formatMessage( source: string, params: Params = {}, locale = 'en', hooks: { onError?: (error: Error) => void; onMissingParam?: (name: string) => void } = {}, ): string { if (!source.includes('{') && !source.includes("'")) return source; let parts = compiled.get(source); if (!parts) { try { parts = parseMessage(source); } catch (error) { parts = error instanceof Error ? error : new Error(String(error)); } if (compiled.size > 4096) compiled.clear(); compiled.set(source, parts); } if (parts instanceof Error) { hooks.onError?.(parts); return source; } return render(parts, params, locale, null, hooks.onMissingParam); } // ------------------------------------------------------------------------------------------- // Runtime export type I18nOptions = { /** The source locale; its messages are complete (default `en`). */ sourceLocale?: string; /** Source messages (English). Always loaded. */ source: Messages; /** Locales that have catalogs (the source included). */ available: readonly string[]; /** Per-locale inheritance before the source (a catalog's `fallback`). */ fallbacks?: Readonly>; /** Loads one locale's messages (lazy import of `locales/.json`), or null when there are none. */ load?: (locale: string) => Promise | Messages | null | undefined; /** Initial locale (default: the source). */ locale?: string; /** A key the chain does not have, or a message that failed to parse. Once per key and locale. */ onMissing?: (key: string, locale: string, reason: 'key' | 'syntax' | 'param') => void; }; export type I18n = { /** Active locale (a catalog tag, e.g. `pt`, `zh-Hant`). */ readonly locale: string; readonly dir: Dir; /** Lookup order for the active locale, ending with the source. */ readonly chain: readonly string[]; readonly available: readonly string[]; /** The message for `key` in the active locale, formatted with `params`. Unknown keys return the key. */ t(key: K, params?: Params): string; /** Like `t` for keys built at runtime; returns `fallback` (default the key) when missing. */ tx(key: string, params?: Params, fallback?: string): string; has(key: string): boolean; /** Switches locale (loading its catalog and its chain's). Resolves with the effective locale. */ setLocale(locale: string): Promise; /** Adds messages for a locale (framework catalogs, plugin strings); later adds win. */ add(locale: string, messages: Messages): void; onChange(listener: (locale: string) => void): () => void; /** Resolved messages of the active locale for keys starting with one of `prefixes` (the chain merged). */ resolved(prefixes?: readonly string[]): Record; number(value: number, options?: Intl.NumberFormatOptions): string; date(value: Date | number | string, options?: Intl.DateTimeFormatOptions): string; /** "3 minutes ago", "in 2 days" (Intl.RelativeTimeFormat, numeric: auto). */ relative(value: number, unit: Intl.RelativeTimeFormatUnit, options?: Intl.RelativeTimeFormatOptions): string; /** "a, b and c" (Intl.ListFormat). */ list(items: readonly string[], options?: { type?: 'conjunction' | 'disjunction' | 'unit'; style?: 'long' | 'short' | 'narrow' }): string; plural(count: number, ordinal?: boolean): Intl.LDMLPluralRule; }; export function createI18n(options: I18nOptions): I18n { const source = options.sourceLocale ?? 'en'; const available = Object.freeze([...new Set([source, ...options.available])]); const tables = new Map>(); tables.set(source, { ...options.source }); const loaded = new Set([source]); const listeners = new Set<(locale: string) => void>(); const reported = new Set(); let locale = source; let chain: string[] = [source]; const report = (key: string, where: string, reason: 'key' | 'syntax' | 'param') => { const id = `${reason}|${where}|${key}`; if (reported.has(id) || !options.onMissing) return; if (reported.size > 2048) reported.clear(); reported.add(id); try { options.onMissing(key, where, reason); } catch { /* a reporter must not break the UI */ } }; const chainFor = (tag: string) => fallbackChain(tag, source, options.fallbacks).filter(entry => available.includes(entry)); const lookup = (key: string): string | undefined => { for (const entry of chain) { const value = tables.get(entry)?.[key]; if (typeof value === 'string') { if (entry !== locale && locale !== source && entry === source) report(key, locale, 'key'); return value; } } return undefined; }; const format = (key: string, message: string, params?: Params) => formatMessage(message, params ?? {}, locale, { onError: () => report(key, locale, 'syntax'), onMissingParam: () => report(key, locale, 'param'), }); const i18n: I18n = { get locale() { return locale; }, get dir() { return direction(locale); }, get chain() { return chain; }, available, t(key, params) { const message = lookup(key); if (message === undefined) { report(key, locale, 'key'); return key; } return format(key, message, params); }, tx(key, params, fallback) { const message = lookup(key); if (message === undefined) return fallback ?? key; return format(key, message, params); }, has: key => lookup(key) !== undefined, async setLocale(requested) { const target = matchLocale([requested], available, source); const nextChain = chainFor(target); if (options.load) { await Promise.all(nextChain.filter(tag => !loaded.has(tag)).map(async tag => { try { const messages = await options.load!(tag); if (messages) tables.set(tag, { ...messages, ...tables.get(tag) }); } catch (error) { report(``, tag, 'key'); } loaded.add(tag); })); } const changed = target !== locale; locale = target; chain = nextChain; if (changed) for (const listener of [...listeners]) { try { listener(locale); } catch { /* listeners must not break each other */ } } return locale; }, add(tag, messages) { const key = normalizeLocale(tag) ?? tag; tables.set(key, { ...tables.get(key), ...messages }); }, onChange(listener) { listeners.add(listener); return () => listeners.delete(listener); }, resolved(prefixes) { const out: Record = {}; for (const entry of [...chain].reverse()) { for (const [key, value] of Object.entries(tables.get(entry) ?? {})) { if (!prefixes || prefixes.some(prefix => key.startsWith(prefix))) out[key] = value; } } return out; }, number: (value, opts) => numberFormat(locale, opts).format(value), date: (value, opts) => { const date = toDate(value); return date ? dateFormat(locale, opts).format(date) : ''; }, relative: (value, unit, opts) => { try { return new Intl.RelativeTimeFormat(safeLocale(locale), { numeric: 'auto', ...opts }).format(value, unit); } catch { return `${value} ${unit}`; } }, list: (items, opts) => { try { return new Intl.ListFormat(safeLocale(locale), { type: opts?.type ?? 'conjunction', style: opts?.style ?? 'long' }).format(items); } catch { return items.join(', '); } }, plural: (count, ordinal = false) => pluralRules(locale, ordinal).select(count), }; return i18n; } /** Picks a relative-time unit for a timestamp: "just now" granularity down to seconds. */ export function relativeParts(then: number, now = Date.now()): { value: number; unit: Intl.RelativeTimeFormatUnit } { const seconds = Math.round((then - now) / 1000); const abs = Math.abs(seconds); if (abs < 60) return { value: seconds, unit: 'second' }; if (abs < 3600) return { value: Math.round(seconds / 60), unit: 'minute' }; if (abs < 86400) return { value: Math.round(seconds / 3600), unit: 'hour' }; if (abs < 86400 * 30) return { value: Math.round(seconds / 86400), unit: 'day' }; if (abs < 86400 * 365) return { value: Math.round(seconds / (86400 * 30)), unit: 'month' }; return { value: Math.round(seconds / (86400 * 365)), unit: 'year' }; }