@texuli/core 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +11 -5
  2. package/dist/data-source/collection.d.ts +67 -0
  3. package/dist/data-source/collection.js +247 -0
  4. package/dist/data-source/errors.d.ts +58 -0
  5. package/dist/data-source/errors.js +138 -0
  6. package/dist/data-source/index.d.ts +17 -0
  7. package/dist/data-source/index.js +17 -0
  8. package/dist/data-source/loader.d.ts +120 -0
  9. package/dist/data-source/loader.js +571 -0
  10. package/dist/data-source/query.d.ts +78 -0
  11. package/dist/data-source/query.js +339 -0
  12. package/dist/data-source/registry.d.ts +83 -0
  13. package/dist/data-source/registry.js +63 -0
  14. package/dist/data-source/schema.d.ts +122 -0
  15. package/dist/data-source/schema.js +197 -0
  16. package/dist/expression/analyze.d.ts +17 -0
  17. package/dist/expression/analyze.js +151 -0
  18. package/dist/expression/evaluate.d.ts +55 -0
  19. package/dist/expression/evaluate.js +365 -0
  20. package/dist/expression/functions.d.ts +7 -0
  21. package/dist/expression/functions.js +374 -0
  22. package/dist/expression/index.d.ts +7 -0
  23. package/dist/expression/index.js +12 -0
  24. package/dist/expression/parse.d.ts +19 -0
  25. package/dist/expression/parse.js +284 -0
  26. package/dist/expression/text.d.ts +15 -0
  27. package/dist/expression/text.js +489 -0
  28. package/dist/expression/types.d.ts +197 -0
  29. package/dist/expression/types.js +35 -0
  30. package/dist/expression/values.d.ts +14 -0
  31. package/dist/expression/values.js +68 -0
  32. package/dist/i18n/catalog.d.ts +51 -0
  33. package/dist/i18n/catalog.js +109 -0
  34. package/dist/i18n/format.d.ts +41 -0
  35. package/dist/i18n/format.js +117 -0
  36. package/dist/i18n/i18n.d.ts +70 -0
  37. package/dist/i18n/i18n.js +185 -0
  38. package/dist/i18n/index.d.ts +8 -0
  39. package/dist/i18n/index.js +8 -0
  40. package/dist/i18n/pseudo.d.ts +4 -0
  41. package/dist/i18n/pseudo.js +41 -0
  42. package/dist/index.d.ts +1 -1
  43. package/dist/index.js +1 -1
  44. package/dist/portal/index.d.ts +10 -1
  45. package/dist/portal/index.js +44 -32
  46. package/dist/portal/scope.d.ts +58 -0
  47. package/dist/portal/scope.js +118 -0
  48. package/package.json +4 -1
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The closed expression language of ADR-015: one JSON AST, one bounded
3
+ * evaluator. Every node names its operation in `op`. The base is the
4
+ * blueprint spec's `numberFormula` and `condition` shapes (ADR-008), which
5
+ * stay valid expressions; the extensions add strings, booleans, null,
6
+ * field references, calls to a closed function table, and the `ref` and
7
+ * `range` reference nodes a dialect (the planned sheets formula layer) can
8
+ * enable. There is no loop, no closure, no dynamic reference and no way to
9
+ * reach host objects.
10
+ */
11
+ /** The limits of ADR-015. Exceeding one is a diagnostic at parse time or `#LIMIT!` at run time. */
12
+ export const EXPRESSION_LIMITS = Object.freeze({
13
+ /** AST nodes per expression. */
14
+ nodes: 1000,
15
+ /** Nesting depth per expression. */
16
+ depth: 32,
17
+ /** Evaluation steps per expression. */
18
+ steps: 100_000,
19
+ /** Evaluation steps per recalculation pass (an `ExpressionBudget`). */
20
+ passSteps: 10_000_000,
21
+ /** UTF-16 code units in any string value. */
22
+ stringLength: 32_768,
23
+ /** Items in any list value. */
24
+ listItems: 10_000,
25
+ /** Characters of expression text the text parser reads. */
26
+ sourceLength: 16_384,
27
+ /** Arguments of one `and`, `or`, `min`, `max`, `concat` or `call`. */
28
+ args: 64,
29
+ /** Largest absolute `pow` exponent. */
30
+ exponent: 64,
31
+ /** `round` digits range (±). */
32
+ roundDigits: 12,
33
+ });
34
+ /** Field, var and function names: a letter or underscore, then letters, digits or underscores. */
35
+ export const EXPRESSION_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
@@ -0,0 +1,14 @@
1
+ import { type ExpressionError, type ExpressionErrorCode, type ExpressionValue } from './types.js';
2
+ export declare const ERRORS: Readonly<Record<ExpressionErrorCode, ExpressionError>>;
3
+ /** The (frozen, shared) error value for a code. */
4
+ export declare function expressionError(code: ExpressionErrorCode): ExpressionError;
5
+ /** Whether a value is an error value. */
6
+ export declare function isExpressionError(value: unknown): value is ExpressionError;
7
+ /** Only `true` is true: errors, `null` (unknown) and everything else are not. */
8
+ export declare function isTrue(value: ExpressionValue): boolean;
9
+ export declare const LIMIT: ExpressionError;
10
+ export declare const VALUE: ExpressionError;
11
+ /** Normalize an outside value (an input or a function result) into an expression value. */
12
+ export declare function toExpressionValue(raw: unknown): ExpressionValue;
13
+ /** Rounds half away from zero to `digits` decimals (negative: tens, hundreds…). */
14
+ export declare function roundHalfAway(value: number, digits?: number): number | null;
@@ -0,0 +1,68 @@
1
+ import { EXPRESSION_LIMITS, } from "./types.js";
2
+ /* Values: the error singletons, normalization of outside values and rounding. */
3
+ export const ERRORS = Object.freeze({
4
+ '#DIV/0!': Object.freeze({ error: '#DIV/0!' }),
5
+ '#VALUE!': Object.freeze({ error: '#VALUE!' }),
6
+ '#REF!': Object.freeze({ error: '#REF!' }),
7
+ '#NAME?': Object.freeze({ error: '#NAME?' }),
8
+ '#NUM!': Object.freeze({ error: '#NUM!' }),
9
+ '#N/A': Object.freeze({ error: '#N/A' }),
10
+ '#CYCLE!': Object.freeze({ error: '#CYCLE!' }),
11
+ '#LIMIT!': Object.freeze({ error: '#LIMIT!' }),
12
+ });
13
+ /** The (frozen, shared) error value for a code. */
14
+ export function expressionError(code) {
15
+ return ERRORS[code];
16
+ }
17
+ /** Whether a value is an error value. */
18
+ export function isExpressionError(value) {
19
+ return (typeof value === 'object' &&
20
+ value !== null &&
21
+ !Array.isArray(value) &&
22
+ typeof value.error === 'string' &&
23
+ Object.hasOwn(ERRORS, value.error));
24
+ }
25
+ /** Only `true` is true: errors, `null` (unknown) and everything else are not. */
26
+ export function isTrue(value) {
27
+ return value === true;
28
+ }
29
+ export const LIMIT = ERRORS['#LIMIT!'];
30
+ export const VALUE = ERRORS['#VALUE!'];
31
+ function scalar(raw) {
32
+ if (raw === null || raw === undefined)
33
+ return null;
34
+ if (typeof raw === 'number')
35
+ return Number.isFinite(raw) ? (raw === 0 ? 0 : raw) : null;
36
+ if (typeof raw === 'string')
37
+ return raw.length > EXPRESSION_LIMITS.stringLength ? LIMIT : raw;
38
+ if (typeof raw === 'boolean')
39
+ return raw;
40
+ if (isExpressionError(raw))
41
+ return ERRORS[raw.error];
42
+ return VALUE;
43
+ }
44
+ /** Normalize an outside value (an input or a function result) into an expression value. */
45
+ export function toExpressionValue(raw) {
46
+ if (!Array.isArray(raw))
47
+ return scalar(raw);
48
+ if (raw.length > EXPRESSION_LIMITS.listItems)
49
+ return LIMIT;
50
+ const out = [];
51
+ for (const item of raw) {
52
+ const value = scalar(item);
53
+ if (isExpressionError(value))
54
+ return value;
55
+ out.push(value);
56
+ }
57
+ return Object.freeze(out);
58
+ }
59
+ /** Rounds half away from zero to `digits` decimals (negative: tens, hundreds…). */
60
+ export function roundHalfAway(value, digits = 0) {
61
+ const factor = 10 ** Math.abs(digits);
62
+ const scaled = digits >= 0 ? Math.abs(value) * factor : Math.abs(value) / factor;
63
+ const rounded = Math.round(scaled + scaled * Number.EPSILON);
64
+ const result = Math.sign(value) * (digits >= 0 ? rounded / factor : rounded * factor);
65
+ if (!Number.isFinite(result))
66
+ return null;
67
+ return result === 0 ? 0 : result;
68
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Message catalogs (ADR-016): `{ locale, messages }`, where each message id
3
+ * is namespaced (`texuli.dialog.close`) and maps to a `Message`. Catalogs
4
+ * may come from untrusted JSON (a translation file a host loads), so
5
+ * `parseCatalog` checks every id, message and size and copies the result
6
+ * into a prototype-free map.
7
+ */
8
+ import type { Message } from './format.js';
9
+ export interface MessageCatalog {
10
+ /** A BCP 47 language tag, such as `en`, `pt-BR` or the pseudo-locale `en-XA`. */
11
+ readonly locale: string;
12
+ readonly messages: Readonly<Record<string, Message>>;
13
+ }
14
+ export interface CatalogIssue {
15
+ /** The message id, or `''` for the catalog itself. */
16
+ id: string;
17
+ message: string;
18
+ }
19
+ export interface CatalogParse {
20
+ /** The checked catalog (only its valid messages), or `null` when the catalog itself is unusable. */
21
+ catalog: MessageCatalog | null;
22
+ issues: CatalogIssue[];
23
+ }
24
+ /** Caps for `parseCatalog`. */
25
+ export declare const CATALOG_LIMITS: Readonly<{
26
+ /** Messages in one catalog. */
27
+ messages: 5000;
28
+ /** Characters in one message string or plural or select case. */
29
+ messageLength: 2000;
30
+ /** Cases in one plural or select message. */
31
+ cases: 16;
32
+ }>;
33
+ /** A message id: two or more dot-separated segments of letters, digits and `-`, at most 128 characters. */
34
+ export declare const MESSAGE_ID: RegExp;
35
+ /** A BCP 47-like tag: letters and digits in `-` separated subtags (`en`, `pt-BR`, `zh-Hant-TW`, `en-XA`). */
36
+ export declare const LOCALE_TAG: RegExp;
37
+ export type MessageCheck = {
38
+ ok: true;
39
+ message: Message;
40
+ } | {
41
+ ok: false;
42
+ reason: string;
43
+ };
44
+ /** A checked, frozen copy of one message, or the reason it is refused. */
45
+ export declare function checkMessage(value: unknown): MessageCheck;
46
+ /**
47
+ * Check an untrusted catalog: the locale tag, every id (`MESSAGE_ID`), every
48
+ * message and the caps in `CATALOG_LIMITS`. Invalid messages are dropped
49
+ * with an issue; the rest are kept. Never throws.
50
+ */
51
+ export declare function parseCatalog(input: unknown): CatalogParse;
@@ -0,0 +1,109 @@
1
+ /** Caps for `parseCatalog`. */
2
+ export const CATALOG_LIMITS = Object.freeze({
3
+ /** Messages in one catalog. */
4
+ messages: 5_000,
5
+ /** Characters in one message string or plural or select case. */
6
+ messageLength: 2_000,
7
+ /** Cases in one plural or select message. */
8
+ cases: 16,
9
+ });
10
+ /** A message id: two or more dot-separated segments of letters, digits and `-`, at most 128 characters. */
11
+ export const MESSAGE_ID = /^[A-Za-z0-9-]{1,48}(?:\.[A-Za-z0-9-]{1,48}){1,6}$/;
12
+ /** A BCP 47-like tag: letters and digits in `-` separated subtags (`en`, `pt-BR`, `zh-Hant-TW`, `en-XA`). */
13
+ export const LOCALE_TAG = /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8}){0,4}$/;
14
+ const PLURAL_KEYS = new Set(['plural', 'other', 'zero', 'one', 'two', 'few', 'many']);
15
+ const EXACT_KEY = /^=\d{1,9}$/;
16
+ const CASE_KEY = /^[A-Za-z0-9_-]{1,48}$/;
17
+ const FORBIDDEN = new Set(['__proto__', 'constructor', 'prototype']);
18
+ function isRecord(value) {
19
+ return (typeof value === 'object' &&
20
+ value !== null &&
21
+ !Array.isArray(value) &&
22
+ (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null));
23
+ }
24
+ function checkString(value) {
25
+ return typeof value === 'string' && value.length <= CATALOG_LIMITS.messageLength;
26
+ }
27
+ const refuse = (reason) => ({ ok: false, reason });
28
+ /** A checked, frozen copy of one message, or the reason it is refused. */
29
+ export function checkMessage(value) {
30
+ if (typeof value === 'string') {
31
+ return value.length <= CATALOG_LIMITS.messageLength
32
+ ? { ok: true, message: value }
33
+ : refuse('the message is too long');
34
+ }
35
+ if (!isRecord(value))
36
+ return refuse('a message is a string, or a plural or select object');
37
+ const keys = Object.keys(value);
38
+ if (keys.length > CATALOG_LIMITS.cases + 1)
39
+ return refuse('the message has too many cases');
40
+ const isPlural = typeof value.plural === 'string';
41
+ const isSelect = typeof value.select === 'string';
42
+ if (isPlural === isSelect) {
43
+ return refuse('a message object has exactly one of `plural` or `select`');
44
+ }
45
+ if (!checkString(value.other))
46
+ return refuse('a plural or select message needs an `other` string');
47
+ const out = Object.create(null);
48
+ for (const key of keys) {
49
+ if (FORBIDDEN.has(key))
50
+ return refuse(`the case \`${key}\` is not allowed`);
51
+ const text = value[key];
52
+ if (!checkString(text)) {
53
+ return refuse(`the case \`${key}\` is not a string of at most ${CATALOG_LIMITS.messageLength} characters`);
54
+ }
55
+ if (key === 'plural' || key === 'select') {
56
+ if (!CASE_KEY.test(text))
57
+ return refuse(`\`${key}\` names a value: letters, digits, \`_\` or \`-\``);
58
+ }
59
+ else if (isPlural ? !PLURAL_KEYS.has(key) && !EXACT_KEY.test(key) : !CASE_KEY.test(key)) {
60
+ return refuse(`the case \`${key}\` is not allowed`);
61
+ }
62
+ out[key] = text;
63
+ }
64
+ return { ok: true, message: Object.freeze(out) };
65
+ }
66
+ /**
67
+ * Check an untrusted catalog: the locale tag, every id (`MESSAGE_ID`), every
68
+ * message and the caps in `CATALOG_LIMITS`. Invalid messages are dropped
69
+ * with an issue; the rest are kept. Never throws.
70
+ */
71
+ export function parseCatalog(input) {
72
+ const issues = [];
73
+ if (!isRecord(input)) {
74
+ return { catalog: null, issues: [{ id: '', message: 'a catalog is an object' }] };
75
+ }
76
+ const locale = input.locale;
77
+ if (typeof locale !== 'string' || !LOCALE_TAG.test(locale)) {
78
+ issues.push({ id: '', message: 'the catalog needs a BCP 47 `locale`' });
79
+ return { catalog: null, issues };
80
+ }
81
+ if (!isRecord(input.messages)) {
82
+ issues.push({ id: '', message: 'the catalog needs a `messages` object' });
83
+ return { catalog: null, issues };
84
+ }
85
+ const ids = Object.keys(input.messages);
86
+ if (ids.length > CATALOG_LIMITS.messages) {
87
+ issues.push({
88
+ id: '',
89
+ message: `the catalog has more than ${CATALOG_LIMITS.messages} messages`,
90
+ });
91
+ return { catalog: null, issues };
92
+ }
93
+ const messages = Object.create(null);
94
+ for (const id of ids) {
95
+ if (!MESSAGE_ID.test(id)) {
96
+ if (issues.length < 50)
97
+ issues.push({ id, message: 'not a namespaced message id' });
98
+ continue;
99
+ }
100
+ const checked = checkMessage(input.messages[id]);
101
+ if (!checked.ok) {
102
+ if (issues.length < 50)
103
+ issues.push({ id, message: checked.reason });
104
+ continue;
105
+ }
106
+ messages[id] = checked.message;
107
+ }
108
+ return { catalog: Object.freeze({ locale, messages: Object.freeze(messages) }), issues };
109
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Message formatting for `@texuli/core/i18n` (ADR-016): `{name}`
3
+ * placeholders, and plural or select objects resolved through
4
+ * `Intl.PluralRules`. One left-to-right pass, no recursion: a substituted
5
+ * value is never scanned again, so a value that contains `{name}` stays
6
+ * literal text. Output is always a string (text, never markup).
7
+ */
8
+ /** A plural message: `#` in a case is the formatted count. `=0`, `=1`… match exact counts first. */
9
+ export interface PluralMessage {
10
+ readonly plural: string;
11
+ readonly other: string;
12
+ readonly zero?: string;
13
+ readonly one?: string;
14
+ readonly two?: string;
15
+ readonly few?: string;
16
+ readonly many?: string;
17
+ readonly [exact: `=${number}`]: string | undefined;
18
+ }
19
+ /** A select message: the case named by the value of `select`, else `other`. */
20
+ export interface SelectMessage {
21
+ readonly select: string;
22
+ readonly other: string;
23
+ readonly [value: string]: string;
24
+ }
25
+ /** A message: a string with `{name}` placeholders, or a plural or select object of such strings. */
26
+ export type Message = string | PluralMessage | SelectMessage;
27
+ /** Values a message substitutes. Strings are inserted as is; numbers are formatted for the locale. */
28
+ export type MessageValues = Readonly<Record<string, string | number>>;
29
+ /**
30
+ * Substitute `{name}` placeholders in one pass. An unknown or malformed
31
+ * placeholder stays literal; `count` (when given) replaces a bare `#`.
32
+ */
33
+ export declare function interpolate(template: string, values: MessageValues | undefined, locale?: string, count?: string): string;
34
+ /**
35
+ * The small English-path formatter: `{name}` placeholders only, values
36
+ * inserted with `String()`, unknown names left as written. One regex pass
37
+ * with a bounded name, so it is linear and never rescans a value.
38
+ */
39
+ export declare function fillPlaceholders(template: string, values?: MessageValues): string;
40
+ /** Format one message for `locale`. A malformed message formats as the empty string. */
41
+ export declare function formatMessage(message: Message, values?: MessageValues, locale?: string): string;
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Message formatting for `@texuli/core/i18n` (ADR-016): `{name}`
3
+ * placeholders, and plural or select objects resolved through
4
+ * `Intl.PluralRules`. One left-to-right pass, no recursion: a substituted
5
+ * value is never scanned again, so a value that contains `{name}` stays
6
+ * literal text. Output is always a string (text, never markup).
7
+ */
8
+ /** A placeholder name: letters, digits, `_`, `-` and `.`, at most 64 characters. */
9
+ const NAME = /^[A-Za-z0-9_.-]{1,64}$/;
10
+ const numberFormats = new Map();
11
+ const pluralRules = new Map();
12
+ function cached(cache, locale, make) {
13
+ if (cache.has(locale))
14
+ return cache.get(locale) ?? null;
15
+ let value = null;
16
+ try {
17
+ value = make();
18
+ }
19
+ catch {
20
+ value = null;
21
+ }
22
+ if (cache.size > 64)
23
+ cache.clear();
24
+ cache.set(locale, value);
25
+ return value;
26
+ }
27
+ function formatNumber(value, locale) {
28
+ if (!Number.isFinite(value))
29
+ return String(value);
30
+ const format = cached(numberFormats, locale, () => new Intl.NumberFormat(locale));
31
+ return format ? format.format(value) : String(value);
32
+ }
33
+ function valueText(value, locale) {
34
+ if (typeof value === 'number')
35
+ return formatNumber(value, locale);
36
+ return typeof value === 'string' ? value : undefined;
37
+ }
38
+ /**
39
+ * Substitute `{name}` placeholders in one pass. An unknown or malformed
40
+ * placeholder stays literal; `count` (when given) replaces a bare `#`.
41
+ */
42
+ export function interpolate(template, values, locale = 'en', count) {
43
+ if (template.indexOf('{') === -1 && (count === undefined || template.indexOf('#') === -1)) {
44
+ return template;
45
+ }
46
+ let out = '';
47
+ let index = 0;
48
+ while (index < template.length) {
49
+ const char = template[index];
50
+ if (char === '#' && count !== undefined) {
51
+ out += count;
52
+ index += 1;
53
+ continue;
54
+ }
55
+ if (char === '{') {
56
+ // Names are short: look at most 65 characters ahead, so a run of `{` stays linear.
57
+ const close = template.slice(index + 1, index + 66).indexOf('}') + index + 1;
58
+ if (close > index) {
59
+ const name = template.slice(index + 1, close);
60
+ const text = NAME.test(name) ? valueText(values?.[name], locale) : undefined;
61
+ if (text !== undefined) {
62
+ out += text;
63
+ index = close + 1;
64
+ continue;
65
+ }
66
+ }
67
+ }
68
+ out += char;
69
+ index += 1;
70
+ }
71
+ return out;
72
+ }
73
+ /**
74
+ * The small English-path formatter: `{name}` placeholders only, values
75
+ * inserted with `String()`, unknown names left as written. One regex pass
76
+ * with a bounded name, so it is linear and never rescans a value.
77
+ */
78
+ export function fillPlaceholders(template, values) {
79
+ if (!values || template.indexOf('{') === -1)
80
+ return template;
81
+ return template.replace(/\{([A-Za-z0-9_.-]{1,64})\}/g, (match, name) => Object.hasOwn(values, name) ? String(values[name]) : match);
82
+ }
83
+ function isRecord(value) {
84
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
85
+ }
86
+ function caseText(message, key) {
87
+ if (!Object.hasOwn(message, key))
88
+ return undefined;
89
+ const value = message[key];
90
+ return typeof value === 'string' ? value : undefined;
91
+ }
92
+ /** Format one message for `locale`. A malformed message formats as the empty string. */
93
+ export function formatMessage(message, values, locale = 'en') {
94
+ if (typeof message === 'string')
95
+ return interpolate(message, values, locale);
96
+ if (!isRecord(message))
97
+ return '';
98
+ const other = caseText(message, 'other') ?? '';
99
+ if (typeof message.plural === 'string') {
100
+ const raw = values?.[message.plural];
101
+ const count = typeof raw === 'number' ? raw : Number(raw);
102
+ if (!Number.isFinite(count))
103
+ return interpolate(other, values, locale);
104
+ const exact = caseText(message, `=${count}`);
105
+ const rules = cached(pluralRules, locale, () => new Intl.PluralRules(locale));
106
+ const category = rules ? rules.select(count) : count === 1 ? 'one' : 'other';
107
+ const chosen = exact ?? caseText(message, category) ?? other;
108
+ return interpolate(chosen, values, locale, formatNumber(count, locale));
109
+ }
110
+ if (typeof message.select === 'string') {
111
+ const raw = values?.[message.select];
112
+ const key = raw === undefined ? '' : String(raw);
113
+ const chosen = key !== '' && key !== 'select' && key !== 'other' ? (caseText(message, key) ?? other) : other;
114
+ return interpolate(chosen, values, locale);
115
+ }
116
+ return '';
117
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * The message runtime (ADR-016). An `I18n` holds the catalogs of the
3
+ * locales it has loaded, loads a locale lazily the first time it becomes
4
+ * active (`registerLocale(locale, () => import('./pt-BR.js'))`), and
5
+ * resolves an id against the active locale, then its base language
6
+ * (`pt-BR` → `pt`), then the English default the component passes. It is
7
+ * framework-free: components read it from context (`I18N_CONTEXT`) and
8
+ * re-read their labels when `subscribe` reports a change.
9
+ */
10
+ import { type CatalogIssue, type MessageCatalog } from './catalog.js';
11
+ import { type Message, type MessageValues } from './format.js';
12
+ /** What a lazy locale loader resolves to: a catalog, or a module whose default export is one. */
13
+ export type LocaleLoader = () => Promise<MessageCatalog | {
14
+ default: MessageCatalog;
15
+ }>;
16
+ /** The context key components read an `I18n` from (`setContext(I18N_CONTEXT, i18n)`); one per realm. */
17
+ export declare const I18N_CONTEXT: unique symbol;
18
+ /** The pseudo-locale (`en-XA`): every resolved string is accented and bracketed, so unlocalized text stands out. */
19
+ export declare const PSEUDO_LOCALE = "en-XA";
20
+ export interface I18nOptions {
21
+ /** The active locale. Default `en`. */
22
+ locale?: string;
23
+ /** Catalogs available at once (no loading). */
24
+ catalogs?: readonly MessageCatalog[];
25
+ /** Lazy loaders by locale; only the active locale loads. */
26
+ loaders?: Readonly<Record<string, LocaleLoader>>;
27
+ /** Called with the issues of a loaded catalog that `parseCatalog` reported, or a load failure. */
28
+ onIssues?: (locale: string, issues: readonly CatalogIssue[]) => void;
29
+ }
30
+ /** Resolve a message id to text. */
31
+ export type Translate = (id: string, english: Message, values?: MessageValues) => string;
32
+ export interface I18n {
33
+ /** The active locale. Changes only once its catalog is loaded. */
34
+ readonly locale: string;
35
+ /** Bumped on every change (locale switch, catalog added); handy as a reactive key. */
36
+ readonly version: number;
37
+ /** Resolve `id` (active locale, then base language), else format `english`. */
38
+ t: Translate;
39
+ /** Whether the active locale (or its base language) has `id`. */
40
+ has(id: string): boolean;
41
+ /** Load (if needed) and activate `locale`. Resolves `false` when it could not load (the locale stays). */
42
+ setLocale(locale: string): Promise<boolean>;
43
+ /** Register a lazy loader. Loading happens when the locale becomes active. */
44
+ registerLocale(locale: string, loader: LocaleLoader): void;
45
+ /** Add or merge a catalog (checked with `parseCatalog`); returns its issues. */
46
+ addCatalog(catalog: MessageCatalog | unknown): readonly CatalogIssue[];
47
+ /** Listen for changes; returns the unsubscribe function. */
48
+ subscribe(listener: () => void): () => void;
49
+ }
50
+ /** Create a message runtime. Never throws on bad catalogs; bad messages are dropped with issues. */
51
+ export declare function createI18n(options?: I18nOptions): I18n;
52
+ /**
53
+ * The resolution order for one label (ADR-016): the instance prop when the
54
+ * caller set one, then the catalog from context, then the English default.
55
+ */
56
+ export declare function resolveLabel(instance: string | null | undefined, i18n: Pick<I18n, 't'> | null | undefined, id: string, english: Message, values?: MessageValues): string;
57
+ /** The English-only `Translate`: what components use with no `I18n` in context. */
58
+ export declare const englishMessages: Translate;
59
+ /** A `Translate` that can also tell whether the catalog has an id (what components get from context). */
60
+ export type MessageLookup = Translate & {
61
+ has?: (id: string) => boolean;
62
+ };
63
+ /**
64
+ * Resolve a component's English label object through the catalog: every
65
+ * string label becomes `messages(`${prefix}.${kebab(key)}`, english)`. A
66
+ * function label (`remove: (tag) => …`) stays as is unless the catalog has
67
+ * its id, whose message then receives the arguments as `{0}`, `{1}`…
68
+ * Callers spread their instance overrides on top (the instance prop wins).
69
+ */
70
+ export declare function localizeLabels<T extends object>(messages: MessageLookup, prefix: string, defaults: T): T;