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:
parent
c404dd3dd0
commit
2b8161fedc
18 changed files with 1461 additions and 27 deletions
661
packages/runtime/src/i18n.ts
Normal file
661
packages/runtime/src/i18n.ts
Normal 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' };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue