i18n: dependency-free message runtime, catalog checks, plugin strings, native strings

- runtime/i18n.ts: ICU-lite messages (placeholders as text, Intl.PluralRules plurals with exact
  forms, selectordinal, select, number/date/time), fallback chains (pt-BR -> pt -> en, ar-x-fm ->
  ar), X and legacy code aliases, RTL by locale, lazy catalogs, live onChange. Apostrophes are
  literal except right before { } # or another apostrophe.
- runtime/i18nCheck.ts: rules for contributed catalogs (shape, keys, syntax, placeholder parity,
  select options, markup/control characters, lengths). An argument English only uses to choose a
  plural form may be dropped; missing plural forms are warnings; meta "allowEmpty" fragments.
- runtime/pluginI18n.ts: api.i18n bound per plugin (register/t/locale/dir/onChange/number/date/
  relative/list); the UI kit's own words follow the app's language. pageLang.ts reports the
  site's <html lang>. shell/messages.ts: shell-model words.
- waybetter-host i18n.rs: NativeStrings (validated plain text, caps, one-pass {name} fill, page
  payload by prefix), system_locales via sys-locale; TabState.lang.
- JSON Schemas for catalogs and translator notes; npm exports waybetter/i18n, /i18n/check,
  /i18n/plugin.
This commit is contained in:
CRTkafa 2026-09-29 09:53:48 +07:00
parent c404dd3dd0
commit 2b8161fedc
18 changed files with 1461 additions and 27 deletions

View file

@ -0,0 +1,661 @@
// 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/<bcp47>.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<Record<string, string>>;
export type Dir = 'ltr' | 'rtl';
export type Params = Readonly<Record<string, unknown>>;
/** A catalog file (`locales/<bcp47>.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<string, string>;
};
// -------------------------------------------------------------------------------------------
// 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<string, string> = {
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<Record<string, string>> = {}): 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<string, Part[]> }
| { kind: 'select'; name: string; options: Record<string, Part[]> };
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<string, Part[]> => {
const options: Record<string, Part[]> = {};
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<string>();
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<string, { kind: 'plural' | 'selectordinal' | 'select'; options: string[] }> {
const out: Record<string, { kind: 'plural' | 'selectordinal' | 'select'; options: string[] }> = {};
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<string, Intl.NumberFormat | Intl.DateTimeFormat | Intl.PluralRules>();
function cached<T extends Intl.NumberFormat | Intl.DateTimeFormat | Intl.PluralRules>(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<string, Intl.NumberFormatOptions> = {
integer: { maximumFractionDigits: 0 },
percent: { style: 'percent' },
compact: { notation: 'compact' },
};
const DATE_STYLES: Record<string, Intl.DateTimeFormatOptions> = {
short: { dateStyle: 'short' },
medium: { dateStyle: 'medium' },
long: { dateStyle: 'long' },
full: { dateStyle: 'full' },
};
const TIME_STYLES: Record<string, Intl.DateTimeFormatOptions> = {
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<string, Part[] | Error>();
/**
* 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<Record<string, string>>;
/** Loads one locale's messages (lazy import of `locales/<tag>.json`), or null when there are none. */
load?: (locale: string) => Promise<Messages | null | undefined> | 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<K extends string = string> = {
/** 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<string>;
/** 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<string, string>;
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<K extends string = string>(options: I18nOptions): I18n<K> {
const source = options.sourceLocale ?? 'en';
const available = Object.freeze([...new Set([source, ...options.available])]);
const tables = new Map<string, Record<string, string>>();
tables.set(source, { ...options.source });
const loaded = new Set<string>([source]);
const listeners = new Set<(locale: string) => void>();
const reported = new Set<string>();
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<K> = {
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(`<catalog ${tag}: ${error instanceof Error ? error.message : String(error)}>`, 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<string, string> = {};
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' };
}