@texuli/core 0.7.0 → 0.8.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.
- package/README.md +11 -5
- package/dist/data-source/collection.d.ts +67 -0
- package/dist/data-source/collection.js +247 -0
- package/dist/data-source/errors.d.ts +58 -0
- package/dist/data-source/errors.js +138 -0
- package/dist/data-source/index.d.ts +17 -0
- package/dist/data-source/index.js +17 -0
- package/dist/data-source/loader.d.ts +120 -0
- package/dist/data-source/loader.js +571 -0
- package/dist/data-source/query.d.ts +78 -0
- package/dist/data-source/query.js +339 -0
- package/dist/data-source/registry.d.ts +83 -0
- package/dist/data-source/registry.js +63 -0
- package/dist/data-source/schema.d.ts +122 -0
- package/dist/data-source/schema.js +197 -0
- package/dist/expression/analyze.d.ts +17 -0
- package/dist/expression/analyze.js +151 -0
- package/dist/expression/evaluate.d.ts +55 -0
- package/dist/expression/evaluate.js +365 -0
- package/dist/expression/functions.d.ts +7 -0
- package/dist/expression/functions.js +374 -0
- package/dist/expression/index.d.ts +7 -0
- package/dist/expression/index.js +12 -0
- package/dist/expression/parse.d.ts +19 -0
- package/dist/expression/parse.js +284 -0
- package/dist/expression/text.d.ts +15 -0
- package/dist/expression/text.js +489 -0
- package/dist/expression/types.d.ts +197 -0
- package/dist/expression/types.js +35 -0
- package/dist/expression/values.d.ts +14 -0
- package/dist/expression/values.js +68 -0
- package/dist/i18n/catalog.d.ts +51 -0
- package/dist/i18n/catalog.js +109 -0
- package/dist/i18n/format.d.ts +41 -0
- package/dist/i18n/format.js +117 -0
- package/dist/i18n/i18n.d.ts +70 -0
- package/dist/i18n/i18n.js +185 -0
- package/dist/i18n/index.d.ts +8 -0
- package/dist/i18n/index.js +8 -0
- package/dist/i18n/pseudo.d.ts +4 -0
- package/dist/i18n/pseudo.js +41 -0
- package/package.json +4 -1
|
@@ -0,0 +1,185 @@
|
|
|
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.ts'))`), 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 { LOCALE_TAG, parseCatalog } from "./catalog.js";
|
|
11
|
+
import { fillPlaceholders, formatMessage, interpolate, } from "./format.js";
|
|
12
|
+
import { pseudoLocalize } from "./pseudo.js";
|
|
13
|
+
/** The context key components read an `I18n` from (`setContext(I18N_CONTEXT, i18n)`); one per realm. */
|
|
14
|
+
export const I18N_CONTEXT = Symbol.for('texuli.i18n');
|
|
15
|
+
/** The pseudo-locale (`en-XA`): every resolved string is accented and bracketed, so unlocalized text stands out. */
|
|
16
|
+
export const PSEUDO_LOCALE = 'en-XA';
|
|
17
|
+
/** The base language of a tag: `pt-BR` → `pt`; `undefined` for a bare language. */
|
|
18
|
+
function baseLanguage(locale) {
|
|
19
|
+
const dash = locale.indexOf('-');
|
|
20
|
+
return dash > 0 ? locale.slice(0, dash) : undefined;
|
|
21
|
+
}
|
|
22
|
+
function normalizeLocale(locale) {
|
|
23
|
+
return LOCALE_TAG.test(locale) ? locale : 'en';
|
|
24
|
+
}
|
|
25
|
+
/** Create a message runtime. Never throws on bad catalogs; bad messages are dropped with issues. */
|
|
26
|
+
export function createI18n(options = {}) {
|
|
27
|
+
const catalogs = new Map();
|
|
28
|
+
const loaders = new Map(Object.entries(options.loaders ?? {}));
|
|
29
|
+
const loading = new Map();
|
|
30
|
+
const listeners = new Set();
|
|
31
|
+
let locale = normalizeLocale(options.locale ?? 'en');
|
|
32
|
+
let version = 0;
|
|
33
|
+
let request = 0;
|
|
34
|
+
const notify = () => {
|
|
35
|
+
version += 1;
|
|
36
|
+
for (const listener of [...listeners])
|
|
37
|
+
listener();
|
|
38
|
+
};
|
|
39
|
+
const add = (input, silent) => {
|
|
40
|
+
const parsed = parseCatalog(input);
|
|
41
|
+
if (!parsed.catalog)
|
|
42
|
+
return parsed.issues;
|
|
43
|
+
const target = catalogs.get(parsed.catalog.locale) ?? Object.create(null);
|
|
44
|
+
Object.assign(target, parsed.catalog.messages);
|
|
45
|
+
catalogs.set(parsed.catalog.locale, target);
|
|
46
|
+
if (!silent)
|
|
47
|
+
notify();
|
|
48
|
+
return parsed.issues;
|
|
49
|
+
};
|
|
50
|
+
for (const catalog of options.catalogs ?? []) {
|
|
51
|
+
const issues = add(catalog, true);
|
|
52
|
+
if (issues.length > 0)
|
|
53
|
+
options.onIssues?.(catalog.locale ?? '', issues);
|
|
54
|
+
}
|
|
55
|
+
const lookup = (id) => {
|
|
56
|
+
const own = catalogs.get(locale);
|
|
57
|
+
if (own && Object.hasOwn(own, id))
|
|
58
|
+
return own[id];
|
|
59
|
+
const base = baseLanguage(locale);
|
|
60
|
+
const parent = base ? catalogs.get(base) : undefined;
|
|
61
|
+
return parent && Object.hasOwn(parent, id) ? parent[id] : undefined;
|
|
62
|
+
};
|
|
63
|
+
const load = (tag) => {
|
|
64
|
+
if (catalogs.has(tag) || tag === PSEUDO_LOCALE)
|
|
65
|
+
return Promise.resolve(true);
|
|
66
|
+
const pending = loading.get(tag);
|
|
67
|
+
if (pending)
|
|
68
|
+
return pending;
|
|
69
|
+
const loader = loaders.get(tag);
|
|
70
|
+
if (!loader)
|
|
71
|
+
return Promise.resolve(false);
|
|
72
|
+
const promise = Promise.resolve()
|
|
73
|
+
.then(loader)
|
|
74
|
+
.then((module) => {
|
|
75
|
+
const catalog = module && typeof module === 'object' && 'default' in module ? module.default : module;
|
|
76
|
+
const issues = add(catalog && typeof catalog === 'object' ? { ...catalog, locale: tag } : catalog, true);
|
|
77
|
+
if (issues.length > 0)
|
|
78
|
+
options.onIssues?.(tag, issues);
|
|
79
|
+
return catalogs.has(tag);
|
|
80
|
+
})
|
|
81
|
+
.catch((error) => {
|
|
82
|
+
options.onIssues?.(tag, [
|
|
83
|
+
{ id: '', message: error instanceof Error ? error.message : 'the locale failed to load' },
|
|
84
|
+
]);
|
|
85
|
+
return false;
|
|
86
|
+
})
|
|
87
|
+
.finally(() => loading.delete(tag));
|
|
88
|
+
loading.set(tag, promise);
|
|
89
|
+
return promise;
|
|
90
|
+
};
|
|
91
|
+
const i18n = {
|
|
92
|
+
get locale() {
|
|
93
|
+
return locale;
|
|
94
|
+
},
|
|
95
|
+
get version() {
|
|
96
|
+
return version;
|
|
97
|
+
},
|
|
98
|
+
t(id, english, values) {
|
|
99
|
+
const found = lookup(id);
|
|
100
|
+
const text = formatMessage(found ?? english, values, locale);
|
|
101
|
+
return locale === PSEUDO_LOCALE ? pseudoLocalize(text) : text;
|
|
102
|
+
},
|
|
103
|
+
has(id) {
|
|
104
|
+
return lookup(id) !== undefined;
|
|
105
|
+
},
|
|
106
|
+
async setLocale(next) {
|
|
107
|
+
if (!LOCALE_TAG.test(next))
|
|
108
|
+
return false;
|
|
109
|
+
const ticket = ++request;
|
|
110
|
+
const base = baseLanguage(next);
|
|
111
|
+
const [own, parent] = await Promise.all([load(next), base ? load(base) : false]);
|
|
112
|
+
// A later call wins. A locale with neither its own nor a base catalog
|
|
113
|
+
// activates only when it is English (the defaults are English).
|
|
114
|
+
if (ticket !== request)
|
|
115
|
+
return false;
|
|
116
|
+
if (!own && !parent && next !== 'en' && base !== 'en')
|
|
117
|
+
return false;
|
|
118
|
+
if (locale !== next) {
|
|
119
|
+
locale = next;
|
|
120
|
+
notify();
|
|
121
|
+
}
|
|
122
|
+
return true;
|
|
123
|
+
},
|
|
124
|
+
registerLocale(tag, loader) {
|
|
125
|
+
if (!LOCALE_TAG.test(tag) || typeof loader !== 'function')
|
|
126
|
+
return;
|
|
127
|
+
loaders.set(tag, loader);
|
|
128
|
+
},
|
|
129
|
+
addCatalog(catalog) {
|
|
130
|
+
return add(catalog, false);
|
|
131
|
+
},
|
|
132
|
+
subscribe(listener) {
|
|
133
|
+
listeners.add(listener);
|
|
134
|
+
return () => {
|
|
135
|
+
listeners.delete(listener);
|
|
136
|
+
};
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
// A loader registered for the starting locale loads right away.
|
|
140
|
+
if (!catalogs.has(locale) && loaders.has(locale)) {
|
|
141
|
+
void load(locale).then((ok) => {
|
|
142
|
+
if (ok)
|
|
143
|
+
notify();
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
return i18n;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* The resolution order for one label (ADR-016): the instance prop when the
|
|
150
|
+
* caller set one, then the catalog from context, then the English default.
|
|
151
|
+
*/
|
|
152
|
+
export function resolveLabel(instance, i18n, id, english, values) {
|
|
153
|
+
if (typeof instance === 'string')
|
|
154
|
+
return values ? interpolate(instance, values) : instance;
|
|
155
|
+
if (i18n)
|
|
156
|
+
return i18n.t(id, english, values);
|
|
157
|
+
return formatMessage(english, values);
|
|
158
|
+
}
|
|
159
|
+
/** The English-only `Translate`: what components use with no `I18n` in context. */
|
|
160
|
+
export const englishMessages = (_id, english, values) => fillPlaceholders(typeof english === 'string' ? english : english.other, values);
|
|
161
|
+
/** `pageLabel` → `page-label`: the id segment of a label key. */
|
|
162
|
+
function kebab(key) {
|
|
163
|
+
return key.replace(/[A-Z]/g, (char) => `-${char.toLowerCase()}`);
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Resolve a component's English label object through the catalog: every
|
|
167
|
+
* string label becomes `messages(`${prefix}.${kebab(key)}`, english)`. A
|
|
168
|
+
* function label (`remove: (tag) => …`) stays as is unless the catalog has
|
|
169
|
+
* its id, whose message then receives the arguments as `{0}`, `{1}`…
|
|
170
|
+
* Callers spread their instance overrides on top (the instance prop wins).
|
|
171
|
+
*/
|
|
172
|
+
export function localizeLabels(messages, prefix, defaults) {
|
|
173
|
+
const out = {};
|
|
174
|
+
for (const [key, value] of Object.entries(defaults)) {
|
|
175
|
+
const id = `${prefix}.${kebab(key)}`;
|
|
176
|
+
if (typeof value === 'string')
|
|
177
|
+
out[key] = messages(id, value);
|
|
178
|
+
else if (typeof value === 'function' && messages.has?.(id)) {
|
|
179
|
+
out[key] = (...args) => messages(id, '', Object.fromEntries(args.map((arg, index) => [String(index), arg])));
|
|
180
|
+
}
|
|
181
|
+
else
|
|
182
|
+
out[key] = value;
|
|
183
|
+
}
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @texuli/core/i18n: message catalogs, lazy locales and label resolution
|
|
3
|
+
* (ADR-016). Framework-free; components read an `I18n` from context.
|
|
4
|
+
*/
|
|
5
|
+
export { CATALOG_LIMITS, type CatalogIssue, type CatalogParse, checkMessage, LOCALE_TAG, MESSAGE_ID, type MessageCatalog, type MessageCheck, parseCatalog, } from './catalog.js';
|
|
6
|
+
export { fillPlaceholders, formatMessage, interpolate, type Message, type MessageValues, type PluralMessage, type SelectMessage, } from './format.js';
|
|
7
|
+
export { createI18n, englishMessages, I18N_CONTEXT, type I18n, type I18nOptions, type LocaleLoader, localizeLabels, type MessageLookup, PSEUDO_LOCALE, resolveLabel, type Translate, } from './i18n.js';
|
|
8
|
+
export { isPseudoLocalized, pseudoLocalize } from './pseudo.js';
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @texuli/core/i18n: message catalogs, lazy locales and label resolution
|
|
3
|
+
* (ADR-016). Framework-free; components read an `I18n` from context.
|
|
4
|
+
*/
|
|
5
|
+
export { CATALOG_LIMITS, checkMessage, LOCALE_TAG, MESSAGE_ID, parseCatalog, } from "./catalog.js";
|
|
6
|
+
export { fillPlaceholders, formatMessage, interpolate, } from "./format.js";
|
|
7
|
+
export { createI18n, englishMessages, I18N_CONTEXT, localizeLabels, PSEUDO_LOCALE, resolveLabel, } from "./i18n.js";
|
|
8
|
+
export { isPseudoLocalized, pseudoLocalize } from "./pseudo.js";
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** The pseudo form of `text`: `⟦Çlóśé⟧`-style, linear in the length of the text. */
|
|
2
|
+
export declare function pseudoLocalize(text: string): string;
|
|
3
|
+
/** Whether `text` is pseudo-localized (starts with `⟦` and ends with `⟧`). */
|
|
4
|
+
export declare function isPseudoLocalized(text: string): boolean;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pseudo-localization for coverage (ADR-016): accented, bracketed and
|
|
3
|
+
* slightly longer text, so a label that skipped the catalog (still plain
|
|
4
|
+
* English) or a layout that clips longer translations stands out. `{name}`
|
|
5
|
+
* placeholders are kept as is.
|
|
6
|
+
*/
|
|
7
|
+
const ACCENTS = {
|
|
8
|
+
a: 'á',
|
|
9
|
+
c: 'ç',
|
|
10
|
+
e: 'é',
|
|
11
|
+
i: 'í',
|
|
12
|
+
n: 'ñ',
|
|
13
|
+
o: 'ó',
|
|
14
|
+
u: 'ú',
|
|
15
|
+
y: 'ý',
|
|
16
|
+
A: 'Á',
|
|
17
|
+
C: 'Ç',
|
|
18
|
+
E: 'É',
|
|
19
|
+
I: 'Í',
|
|
20
|
+
N: 'Ñ',
|
|
21
|
+
O: 'Ó',
|
|
22
|
+
U: 'Ú',
|
|
23
|
+
Y: 'Ý',
|
|
24
|
+
};
|
|
25
|
+
/** The pseudo form of `text`: `⟦Çlóśé⟧`-style, linear in the length of the text. */
|
|
26
|
+
export function pseudoLocalize(text) {
|
|
27
|
+
let out = '';
|
|
28
|
+
let inPlaceholder = false;
|
|
29
|
+
for (const char of text) {
|
|
30
|
+
if (char === '{')
|
|
31
|
+
inPlaceholder = true;
|
|
32
|
+
else if (char === '}')
|
|
33
|
+
inPlaceholder = false;
|
|
34
|
+
out += inPlaceholder ? char : (ACCENTS[char] ?? char);
|
|
35
|
+
}
|
|
36
|
+
return `⟦${out}⟧`;
|
|
37
|
+
}
|
|
38
|
+
/** Whether `text` is pseudo-localized (starts with `⟦` and ends with `⟧`). */
|
|
39
|
+
export function isPseudoLocalized(text) {
|
|
40
|
+
return text.startsWith('⟦') && text.endsWith('⟧');
|
|
41
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@texuli/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Texels: framework-free behavior and policy that every Texuli component is built from.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"svelte",
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
".": "./dist/index.js",
|
|
27
27
|
"./announcer": "./dist/announcer/index.js",
|
|
28
28
|
"./controllable": "./dist/controllable/controllable-state.js",
|
|
29
|
+
"./data-source": "./dist/data-source/index.js",
|
|
29
30
|
"./dir-locale": "./dist/dir-locale/dir-locale.js",
|
|
30
31
|
"./dismiss": "./dist/dismiss/index.js",
|
|
31
32
|
"./editor-history": "./dist/editor/history.js",
|
|
@@ -34,8 +35,10 @@
|
|
|
34
35
|
"./editor-model": "./dist/editor/model.js",
|
|
35
36
|
"./editor-transforms": "./dist/editor/edit.js",
|
|
36
37
|
"./env": "./dist/env/index.js",
|
|
38
|
+
"./expression": "./dist/expression/index.js",
|
|
37
39
|
"./focus-scope": "./dist/focus-scope/index.js",
|
|
38
40
|
"./form-bridge": "./dist/form-bridge/form-bridge.js",
|
|
41
|
+
"./i18n": "./dist/i18n/index.js",
|
|
39
42
|
"./ids": "./dist/ids/ids.js",
|
|
40
43
|
"./inert": "./dist/inert/index.js",
|
|
41
44
|
"./keyboard": "./dist/keyboard/index.js",
|