@pie-element/shared-i18n 0.0.0-stage → 0.1.1-next.20261016234357

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 CHANGED
@@ -1,3 +1,167 @@
1
- # Temporary Holding Version
1
+ # @pie-element/shared-i18n
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Interface strings for PIE elements and the libraries they render through: one typed catalog per
4
+ package, `en-US` resident in the bundle, every other locale loaded on demand, and one page-wide
5
+ registry that a host call reaches across every bundled copy of this engine.
6
+
7
+ The engine is a port of pie-players' `players-shared/src/i18n`. The two repositories share its
8
+ semantics (tag resolution, lookup order, plural handling, host messages) and no code.
9
+
10
+ `@pie-lib/translator` stays in place for every package not yet migrated;
11
+ [`docs/prds/interface-i18n/PRD.md`](../../../docs/prds/interface-i18n/PRD.md) has the design and
12
+ the migration order.
13
+
14
+ ## Locale resolution
15
+
16
+ An element translates in the language its item declares in `model.language` (`en_US`, `es_ES`,
17
+ `es-MX`), and in `en-US` when the item declares none. That is `@pie-lib/translator`'s source, kept
18
+ so a migrated element renders as it did; the PRD moves interface strings to a locale the host
19
+ publishes, with `en-US` as the default. The browser locale is never consulted.
20
+
21
+ A tag resolves by RFC 4647 lookup, then by its primary subtag: `es_MX` reads `es-MX`, then `es`. A
22
+ tag nothing ships renders English key by key, unless a host registered messages for it.
23
+
24
+ Each key is looked up in order: host messages for the locale, the shipped catalog for the locale,
25
+ host messages for `en-US`, the shipped `en-US` catalog. A miss on all four returns the key.
26
+
27
+ ## For a host
28
+
29
+ ```ts
30
+ import { loadLocale, registerMessages } from '@pie-element/shared-i18n';
31
+
32
+ // Rename one label, or serve a locale no package ships.
33
+ registerMessages('@pie-lib/correct-answer-toggle', 'en-US', { showCorrectAnswer: 'Reveal answer' });
34
+ registerMessages('@pie-lib/correct-answer-toggle', 'fr-CA', { showCorrectAnswer: 'Afficher la réponse' });
35
+
36
+ // Optional: load a locale before mounting, so nothing renders while it loads.
37
+ await loadLocale('es-MX');
38
+ ```
39
+
40
+ `registerMessages` applies to every copy of the package on the page and to copies evaluated later;
41
+ an element already rendered picks the messages up on its next render. A plural group replaces the
42
+ shipped group whole, so a host's wording never mixes with the shipped forms.
43
+
44
+ ## For an element
45
+
46
+ The delivery element holds its first render until the locale is resident, then stamps its own host:
47
+
48
+ ```ts
49
+ import { isLocaleReady, loadLocale, stampElementLanguage } from '@pie-element/shared-i18n';
50
+ import { catalogs } from '../i18n/index.js';
51
+
52
+ private _rerender() {
53
+ const language = this._model?.language;
54
+ if (!isLocaleReady(language)) {
55
+ void loadLocale(language).then(() => this._rerender());
56
+ return;
57
+ }
58
+ stampElementLanguage(this, language);
59
+ // …render; `catalogs.forLocale(language).t('multipleChoiceQuestion')`
60
+ }
61
+ ```
62
+
63
+ `isLocaleReady` and `loadLocale` cover every catalog set evaluated on the page, so one wait covers
64
+ the libraries the element renders through. `loadLocale` never rejects: a failed chunk is reported
65
+ once and renders English.
66
+
67
+ `stampElementLanguage` sets `lang` and `dir` on the element's host for a declared language and
68
+ removes both otherwise. The host wraps authored content, and content that declares no language
69
+ inherits the page's instead of being asserted English. It never writes `document.documentElement`.
70
+
71
+ ## For a library component
72
+
73
+ A library component cannot assume the element rendering it waited for the locale: an element still
74
+ on `@pie-lib/translator` never calls `loadLocale`. It passes its re-render to `forLocale`, which
75
+ starts the load and calls back once:
76
+
77
+ ```tsx
78
+ relabel = () => this.forceUpdate();
79
+
80
+ render() {
81
+ const i18n = catalogs.forLocale(this.props.language, this.relabel);
82
+ const showMessage = this.props.showMessage ?? i18n.t('showCorrectAnswer');
83
+ }
84
+ ```
85
+
86
+ Under a migrated element the locale is already resident and the callback never runs. Under an
87
+ unmigrated one the component renders one English frame, then the locale. A label the caller passes
88
+ wins over the catalog.
89
+
90
+ ## Catalogs
91
+
92
+ Each package keeps its strings in `src/i18n/`:
93
+
94
+ | File | Holds |
95
+ |---|---|
96
+ | `en-US.ts` | The source catalog, imported statically. Its shape types every key. |
97
+ | `<tag>.ts` | One file per shipped locale, `satisfies MessageCatalog`. |
98
+ | `index.ts` | `defineCatalogs({ packageName, source, loaders })`. |
99
+ | `catalog-manifest.json` | Each locale's status and provenance. |
100
+
101
+ ```ts
102
+ export const catalogs = defineCatalogs({
103
+ packageName: '@pie-element/multiple-choice',
104
+ source: enUS,
105
+ loaders: { es: () => import(/* webpackMode: "eager" */ './es.js') },
106
+ });
107
+ ```
108
+
109
+ The `webpackMode: "eager"` comment survives the Vite build into `dist/i18n/index.js`. The browser
110
+ ESM build splits the locale into its own chunk; the webpack bundles built from npm, which load
111
+ chunks relative to the host page and cannot fetch one, inline it. The IIFE build drops every loader
112
+ (`tools/vite/iife-english-only-catalogs.ts`) and ships English; its host registers any other locale
113
+ with `registerMessages`.
114
+
115
+ A key missing from `en-US.ts` is a compile error. A key assembled at runtime from an id the catalog
116
+ cannot enumerate goes through `dynamicMessageKey(key)`.
117
+
118
+ Conventions:
119
+
120
+ - `{placeholder}` slots interpolate, with the same names in every locale. i18next's `{{name}}`
121
+ renders literally and fails the gate.
122
+ - A plural group is an object of CLDR categories with `other` required. `Intl.PluralRules` selects,
123
+ so a locale may carry forms English lacks (Arabic `few`, Polish `many`). Call `plural()`, never a
124
+ `count === 1` branch.
125
+ - Keys ending `A11y` are for assistive technology only.
126
+ - Author whole phrases. Concatenating fragments, or lower-casing one, is an English-only transform.
127
+ - Developer diagnostics (`throw`, `console`) stay English and stay out of catalogs.
128
+
129
+ ## Adding or changing a key
130
+
131
+ 1. Add or change it in `en-US.ts`.
132
+ 2. Add it to every locale file. A missing key falls back to English silently, so the gate fails it.
133
+ 3. For a `reviewed` locale, translate the key, then move the anchor:
134
+ `bun scripts/write-i18n-english-snapshot.mjs packages/<group>/<pkg>/src/i18n es`.
135
+
136
+ `bun run verify:i18n-catalogs`, part of `bun run lint`, checks every `src/i18n` directory: locale
137
+ file, loader and manifest entry present together; key parity both ways; leaf kind; `other` in every
138
+ plural group; placeholder parity per form; no `{{slot}}`.
139
+
140
+ Freshness follows the manifest status. `generated` is machine output nobody reviewed: it records the
141
+ hash of the English it came from, fails on any English change, and regenerates whole. `reviewed`
142
+ carries human work regeneration must not clobber: the manifest holds the English each key was
143
+ reviewed against, and only the keys whose English moved fail.
144
+
145
+ ## Locales
146
+
147
+ | Tag | Status | Serves |
148
+ |---|---|---|
149
+ | `en-US` | source | every tag nothing else matches |
150
+ | `es` | reviewed, carried from `@pie-lib/translator` | every `es-*` tag; Latin American wording |
151
+
152
+ ## Build artifacts
153
+
154
+ `tests/interface-i18n-artifacts.test.ts` holds each migrated element's built bundles to the
155
+ contract: English in the static graph of the browser delivery and print views and in the IIFE,
156
+ every other shipped locale outside that graph and absent from the IIFE, no i18next in either, and
157
+ every loader eager. A newly migrated element joins its `migratedElements` list.
158
+
159
+ ## Module layout
160
+
161
+ | Module | Owns |
162
+ |---|---|
163
+ | `provider.ts` | `SimpleI18n`: lookup, interpolation, plurals, direction, formatting |
164
+ | `catalogs.ts` | `defineCatalogs`, the page registry, `isLocaleReady`, `loadLocale`, `registerMessages` |
165
+ | `language-tags.ts` | Tag normalization and RFC 4647 matching |
166
+ | `element-language.ts` | `lang` and `dir` for an element's host |
167
+ | `types.ts` | Catalog, key and translator types |
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Per-package catalogs and the page-wide registry that ties them together.
3
+ *
4
+ * The package that renders a string owns it: an element and each library on its render path
5
+ * define their own catalogs with `defineCatalogs`. The `en-US` catalog is a static import of the
6
+ * defining module, so the typical English render needs no load; every other locale is a loader
7
+ * the bundler can split.
8
+ *
9
+ * An element renders through libraries whose catalogs it does not know, so readiness is asked of
10
+ * the page: `isLocaleReady` and `loadLocale` cover every catalog set evaluated on it. The
11
+ * registry lives on `globalThis` under `Symbol.for`, because every IIFE bundle and every
12
+ * webpack-built player bundle carries its own copy of this engine, and one host call has to
13
+ * reach all of them.
14
+ */
15
+ import type { CatalogLoader, MessageCatalog, NestedKeys, Translator } from './types.js';
16
+ export interface CatalogsOptions<C extends MessageCatalog> {
17
+ /** The npm package that owns these strings; host messages are registered against it. */
18
+ packageName: string;
19
+ /** The `en-US` catalog, imported statically by the defining module. */
20
+ source: C;
21
+ /**
22
+ * Every other shipped locale by BCP-47 tag. Write each as
23
+ * `() => import(/* webpackMode: "eager" *\/ './es-ES.js')`: Vite's browser build still splits
24
+ * it, the IIFE build drops it, and the multi-element webpack builds, which load chunks relative
25
+ * to the host page, inline it instead of emitting a chunk they cannot fetch.
26
+ */
27
+ loaders?: Readonly<Record<string, CatalogLoader>>;
28
+ }
29
+ export interface Catalogs<C extends MessageCatalog> {
30
+ readonly packageName: string;
31
+ /** Shipped locale tags, `en-US` first. */
32
+ readonly locales: readonly string[];
33
+ /**
34
+ * A translator fixed to `locale`, `en-US` when none is given. Synchronous: it renders what is
35
+ * resident and English for any key it lacks.
36
+ *
37
+ * With `onLoad`, a locale that is not resident starts loading and `onLoad` runs once the load
38
+ * settles. A library component passes its re-render, because the element rendering it may
39
+ * never call `loadLocale`; an element that does call it first renders no English frame.
40
+ */
41
+ forLocale(locale?: string | null, onLoad?: () => void): Translator<NestedKeys<C>>;
42
+ /** Whether `locale` would load a catalog for this package. */
43
+ needsLoad(locale: string): boolean;
44
+ /** Load `locale` for this package; a failed load is reported once and then served as English. */
45
+ load(locale: string): Promise<void>;
46
+ }
47
+ /** Define one package's catalogs and register them on the page. */
48
+ export declare function defineCatalogs<C extends MessageCatalog>(options: CatalogsOptions<C>): Catalogs<C>;
49
+ /** Whether every catalog set on the page renders `locale` without a load. */
50
+ export declare function isLocaleReady(locale: string | null | undefined): boolean;
51
+ /** Load `locale` for every catalog set on the page. Never rejects; a failure renders English. */
52
+ export declare function loadLocale(locale: string | null | undefined): Promise<void>;
53
+ /**
54
+ * Supply or override one package's messages for one locale, deep-merged over what it ships.
55
+ *
56
+ * The runtime injection channel: a host renames a label, or serves a locale no package ships,
57
+ * without a rebuild. It applies to every copy of that package on the page and to copies
58
+ * evaluated later. An element already rendered picks the messages up on its next render.
59
+ */
60
+ export declare function registerMessages(packageName: string, locale: string, messages: MessageCatalog): void;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * `lang` and `dir` for an element's own host, which wraps the item's authored content.
3
+ *
4
+ * Both are stamped only for a language the item declares in a usable tag. An item that declares
5
+ * none, or declares something that is not a language tag (`Spanish`), leaves both off and inherits
6
+ * the page's: imported content in another language often declares nothing, and a default would
7
+ * assert English over it. pie-players' content-language design makes the same rule.
8
+ *
9
+ * Nothing here touches `document.documentElement`, which belongs to the page.
10
+ */
11
+ import type { TextDirection } from './types.js';
12
+ export interface LanguageAttributes {
13
+ lang: string;
14
+ dir: TextDirection;
15
+ }
16
+ /** The canonical tag for a declared language, or `undefined` when it is not a usable tag. */
17
+ export declare function canonicalLanguageTag(language: string | null | undefined): string | undefined;
18
+ /** `lang` and `dir` for an item that declares `language`; `undefined` when it declares none. */
19
+ export declare function elementLanguageAttributes(language: string | null | undefined): LanguageAttributes | undefined;
20
+ /** Stamp `lang` and `dir` for a declared language on the element's own host, and clear both otherwise. */
21
+ export declare function stampElementLanguage(host: Element, language: string | null | undefined): void;
@@ -0,0 +1,5 @@
1
+ export { type Catalogs, type CatalogsOptions, defineCatalogs, isLocaleReady, loadLocale, registerMessages, } from './catalogs.js';
2
+ export { canonicalLanguageTag, elementLanguageAttributes, type LanguageAttributes, stampElementLanguage, } from './element-language.js';
3
+ export { findBestLanguageMatch, languageTagLookupSequence, languageTagsEqual, normalizeLanguageTag, toLanguageTag, } from './language-tags.js';
4
+ export { DEFAULT_LOCALE, dynamicMessageKey, localeDirection, SimpleI18n } from './provider.js';
5
+ export type { CatalogLoader, DynamicMessageKey, I18nConfig, I18nServiceApi, InterpolationValues, MessageCatalog, MessageNode, NestedKeys, PluralOptions, PluralTranslation, TextDirection, Translator, } from './types.js';
package/dist/index.js ADDED
@@ -0,0 +1,309 @@
1
+ //#region src/language-tags.ts
2
+ function e(e) {
3
+ return e ? e.trim().replace(/_/g, "-").split(/[.@]/)[0].toLowerCase() : "";
4
+ }
5
+ function t(e) {
6
+ return e ? e.trim().replace(/_/g, "-") : "";
7
+ }
8
+ function n(t, n) {
9
+ let r = e(t);
10
+ return r ? r === e(n) : !1;
11
+ }
12
+ function r(t) {
13
+ let n = e(t);
14
+ if (!n) return [];
15
+ let r = n.split("-"), i = [];
16
+ for (let e = r.length; e > 0; e--) {
17
+ let t = r.slice(0, e);
18
+ t.length > 1 && t[t.length - 1].length === 1 || i.push(t.join("-"));
19
+ }
20
+ return i;
21
+ }
22
+ function i(t, n) {
23
+ for (let i of r(t)) {
24
+ let t = n.find((t) => e(t) === i);
25
+ if (t != null) return t;
26
+ }
27
+ }
28
+ //#endregion
29
+ //#region src/provider.ts
30
+ var a = "en-US";
31
+ function o(e) {
32
+ return e;
33
+ }
34
+ var s = /* @__PURE__ */ new Set([
35
+ "ar",
36
+ "ckb",
37
+ "dv",
38
+ "fa",
39
+ "he",
40
+ "iw",
41
+ "ks",
42
+ "ku",
43
+ "nqo",
44
+ "ps",
45
+ "sd",
46
+ "syr",
47
+ "ug",
48
+ "ur",
49
+ "yi"
50
+ ]), c = /* @__PURE__ */ new Set([
51
+ "zero",
52
+ "one",
53
+ "two",
54
+ "few",
55
+ "many",
56
+ "other"
57
+ ]);
58
+ function l(e) {
59
+ if (typeof e != "object" || !e) return !1;
60
+ let t = Object.keys(e);
61
+ return t.length > 0 && t.every((e) => c.has(e));
62
+ }
63
+ function u(t) {
64
+ try {
65
+ let e = new Intl.Locale(t).textInfo;
66
+ if (e?.direction === "rtl" || e?.direction === "ltr") return e.direction;
67
+ } catch {}
68
+ let n = e(t).split("-")[0] ?? "";
69
+ return s.has(n) ? "rtl" : "ltr";
70
+ }
71
+ var d = class n {
72
+ static {
73
+ this.pluralRules = /* @__PURE__ */ new Map();
74
+ }
75
+ constructor(e) {
76
+ this.listeners = /* @__PURE__ */ new Set(), this.config = { ...e }, this.fallbackLocale = t(e.fallbackLocale) || "en-US", this.catalogs = /* @__PURE__ */ new Map([[a, e.source]]), this.customMessages = { ...e.customMessages ?? {} }, this.loading = /* @__PURE__ */ new Map(), this.locale = this.resolveLocaleTag(e.locale) ?? "en-US";
77
+ }
78
+ getLocale() {
79
+ return this.locale;
80
+ }
81
+ async setLocale(e) {
82
+ let n = this.resolveLocaleTag(e);
83
+ if (!n) {
84
+ this.locale = t(e) || "en-US", this.notify();
85
+ return;
86
+ }
87
+ await this.ensureLoaded(n), this.locale = n, this.notify();
88
+ }
89
+ t(e, t) {
90
+ let n = this.lookup(e);
91
+ return n === void 0 ? (this.config.onMissingKey?.(e, this.locale), e) : p(n, t);
92
+ }
93
+ plural(e, t) {
94
+ let n = this.lookupNode(e);
95
+ return n === void 0 || !l(n) ? this.t(e, t) : p(n[this.selectPluralCategory(t.count)] ?? n.other, t);
96
+ }
97
+ getDirection() {
98
+ return u(this.locale);
99
+ }
100
+ getAvailableLocales() {
101
+ return [.../* @__PURE__ */ new Set([
102
+ ...this.catalogs.keys(),
103
+ ...this.config.availableLocales ?? [],
104
+ ...Object.keys(this.customMessages)
105
+ ])];
106
+ }
107
+ isLocaleLoaded(e) {
108
+ let t = this.resolveLocaleTag(e);
109
+ return t !== void 0 && this.catalogs.has(t);
110
+ }
111
+ needsLoad(e) {
112
+ let t = this.resolveLocaleTag(e);
113
+ return t !== void 0 && !this.catalogs.has(t) && this.config.availableLocales?.includes(t) === !0;
114
+ }
115
+ subscribe(e) {
116
+ return this.listeners.add(e), () => {
117
+ this.listeners.delete(e);
118
+ };
119
+ }
120
+ hasKey(e) {
121
+ return this.lookup(e) !== void 0;
122
+ }
123
+ addCustomMessages(e, n) {
124
+ let r = t(e);
125
+ this.customMessages[r] = m(this.customMessages[r] ?? {}, n), this.notify();
126
+ }
127
+ withLocale(e) {
128
+ let r = this.resolveLocaleTag(e) ?? t(e);
129
+ if (r === this.locale) return this;
130
+ let i = new n({
131
+ ...this.config,
132
+ locale: r
133
+ });
134
+ return i.locale = r || "en-US", i.catalogs = this.catalogs, i.customMessages = this.customMessages, i.loading = this.loading, i;
135
+ }
136
+ formatNumber(e, t) {
137
+ try {
138
+ return new Intl.NumberFormat(this.locale, t).format(e);
139
+ } catch {
140
+ return new Intl.NumberFormat(a, t).format(e);
141
+ }
142
+ }
143
+ formatDate(e, t) {
144
+ try {
145
+ return new Intl.DateTimeFormat(this.locale, t).format(e);
146
+ } catch {
147
+ return new Intl.DateTimeFormat(a, t).format(e);
148
+ }
149
+ }
150
+ async ensureLoaded(e) {
151
+ if (this.catalogs.has(e) || !this.config.availableLocales?.includes(e)) return;
152
+ let t = this.loading.get(e);
153
+ if (t) {
154
+ await t;
155
+ return;
156
+ }
157
+ let n = this.loadCatalog(e);
158
+ this.loading.set(e, n);
159
+ try {
160
+ await n;
161
+ } finally {
162
+ this.loading.delete(e);
163
+ }
164
+ }
165
+ resolveLocaleTag(t) {
166
+ if (!t) return;
167
+ let n = this.getAvailableLocales(), r = i(t, n);
168
+ if (r) return r;
169
+ let a = e(t).split("-")[0];
170
+ if (a) return n.find((t) => e(t).split("-")[0] === a);
171
+ }
172
+ async loadCatalog(e) {
173
+ let t = this.config.loadCatalog;
174
+ if (t) try {
175
+ this.catalogs.set(e, await t(e));
176
+ } catch (t) {
177
+ throw Error(`Failed to load i18n catalog for locale: ${e}`, { cause: t });
178
+ }
179
+ }
180
+ lookupNode(e) {
181
+ let t = [
182
+ this.customMessages[this.locale],
183
+ this.catalogs.get(this.locale),
184
+ this.customMessages[this.fallbackLocale],
185
+ this.catalogs.get(this.fallbackLocale)
186
+ ];
187
+ for (let n of t) {
188
+ let t = f(n, e);
189
+ if (t !== void 0) return t;
190
+ }
191
+ }
192
+ lookup(e) {
193
+ let t = this.lookupNode(e);
194
+ if (typeof t == "string") return t;
195
+ if (t !== void 0 && l(t)) return t.other;
196
+ }
197
+ selectPluralCategory(e) {
198
+ let t = n.pluralRules.get(this.locale);
199
+ if (!t) {
200
+ try {
201
+ t = new Intl.PluralRules(this.locale);
202
+ } catch {
203
+ return e === 1 ? "one" : "other";
204
+ }
205
+ n.pluralRules.set(this.locale, t);
206
+ }
207
+ return t.select(e);
208
+ }
209
+ notify() {
210
+ for (let e of this.listeners) e();
211
+ }
212
+ };
213
+ function f(e, t) {
214
+ if (!e) return;
215
+ let n = e;
216
+ for (let e of t.split(".")) if (typeof n != "object" || !n || (n = n[e], n === void 0)) return;
217
+ return n;
218
+ }
219
+ function p(e, t) {
220
+ return t ? e.replace(/\{(\w+)\}/g, (e, n) => {
221
+ let r = t[n];
222
+ return r == null ? e : String(r);
223
+ }) : e;
224
+ }
225
+ function m(e, t) {
226
+ let n = { ...e };
227
+ for (let [e, r] of Object.entries(t)) {
228
+ let t = n[e];
229
+ r && typeof r == "object" && t && typeof t == "object" ? n[e] = m(t, r) : n[e] = r;
230
+ }
231
+ return n;
232
+ }
233
+ //#endregion
234
+ //#region src/catalogs.ts
235
+ var h = Symbol.for("pie-element.i18n.registry");
236
+ function g() {
237
+ let e = globalThis;
238
+ return e[h] || (e[h] = {
239
+ sets: /* @__PURE__ */ new Set(),
240
+ messages: /* @__PURE__ */ new Map()
241
+ }), e[h];
242
+ }
243
+ function _(e) {
244
+ let t = e.loaders ?? {}, n = g(), r = new d({
245
+ source: e.source,
246
+ availableLocales: [a, ...Object.keys(t)],
247
+ loadCatalog: async (e) => (await t[e]()).default
248
+ });
249
+ for (let { locale: t, messages: i } of n.messages.get(e.packageName) ?? []) r.addCustomMessages(t, i);
250
+ let i = /* @__PURE__ */ new Map(), o = /* @__PURE__ */ new Map(), s = /* @__PURE__ */ new Set(), c = (e) => {
251
+ let t = e || "en-US", n = i.get(t);
252
+ return n || (n = r.withLocale(t), i.set(t, n)), n;
253
+ }, l = {
254
+ packageName: e.packageName,
255
+ locales: [a, ...Object.keys(t)],
256
+ forLocale: (e, t) => (t && e && l.needsLoad(e) && l.load(e).then(t), c(e)),
257
+ needsLoad: (e) => !s.has(e) && r.needsLoad(e),
258
+ load: (t) => {
259
+ if (!l.needsLoad(t)) return Promise.resolve();
260
+ let n = o.get(t);
261
+ return n || (n = c(t).setLocale(t).catch((n) => {
262
+ s.add(t), console.warn(`[${e.packageName}]`, n);
263
+ }).finally(() => o.delete(t)), o.set(t, n)), n;
264
+ },
265
+ addCustomMessages: (e, t) => {
266
+ r.addCustomMessages(e, t), i.clear();
267
+ }
268
+ };
269
+ return n.sets.add(l), l;
270
+ }
271
+ function v(e) {
272
+ if (!e) return !0;
273
+ for (let t of g().sets) if (t.needsLoad(e)) return !1;
274
+ return !0;
275
+ }
276
+ async function y(e) {
277
+ e && await Promise.all([...g().sets].map((t) => t.load(e)));
278
+ }
279
+ function b(e, t, n) {
280
+ let r = g(), i = r.messages.get(e) ?? [];
281
+ i.push({
282
+ locale: t,
283
+ messages: n
284
+ }), r.messages.set(e, i);
285
+ for (let i of r.sets) i.packageName === e && i.addCustomMessages(t, n);
286
+ }
287
+ //#endregion
288
+ //#region src/element-language.ts
289
+ function x(e) {
290
+ let n = t(e).split(/[.@]/)[0];
291
+ if (/^[a-z]{2,3}(-|$)/i.test(n)) try {
292
+ return Intl.getCanonicalLocales(n)[0];
293
+ } catch {
294
+ return;
295
+ }
296
+ }
297
+ function S(e) {
298
+ let t = x(e);
299
+ return t ? {
300
+ lang: t,
301
+ dir: u(t)
302
+ } : void 0;
303
+ }
304
+ function C(e, t) {
305
+ let n = S(t);
306
+ n ? (e.setAttribute("lang", n.lang), e.setAttribute("dir", n.dir)) : (e.removeAttribute("lang"), e.removeAttribute("dir"));
307
+ }
308
+ //#endregion
309
+ export { a as DEFAULT_LOCALE, d as SimpleI18n, x as canonicalLanguageTag, _ as defineCatalogs, o as dynamicMessageKey, S as elementLanguageAttributes, i as findBestLanguageMatch, v as isLocaleReady, r as languageTagLookupSequence, n as languageTagsEqual, y as loadLocale, u as localeDirection, e as normalizeLanguageTag, b as registerMessages, C as stampElementLanguage, t as toLanguageTag };
@@ -0,0 +1,31 @@
1
+ /**
2
+ * BCP-47 language tag comparison, ported from pie-players
3
+ * (`players-shared/src/i18n/language-tags.ts`) so both repositories resolve a tag the same way.
4
+ *
5
+ * Content producers disagree on syntax: the authoring surface offers POSIX `es_ES`, the
6
+ * Learnosity transform emits POSIX, QTI carries BCP-47 `xml:lang`. Comparison runs on the
7
+ * normalized form; rendering uses `toLanguageTag`.
8
+ */
9
+ /**
10
+ * Canonical comparison key: POSIX underscores become hyphens, a POSIX charset or modifier
11
+ * suffix (`es_ES.UTF-8`, `es_ES@euro`) is stripped, and the result is lowercased. Empty for
12
+ * nullish or blank input, which callers read as "declares no language".
13
+ */
14
+ export declare function normalizeLanguageTag(tag: string | null | undefined): string;
15
+ /**
16
+ * A tag in BCP-47 syntax with its authored casing kept, so `es_ES` renders as `es-ES`: the
17
+ * form for a `lang` attribute or an `Intl` API. Empty for nullish or blank input.
18
+ */
19
+ export declare function toLanguageTag(tag: string | null | undefined): string;
20
+ /** Whether two tags denote the same locale, ignoring separator style and case. */
21
+ export declare function languageTagsEqual(a: string | null | undefined, b: string | null | undefined): boolean;
22
+ /**
23
+ * The RFC 4647 lookup sequence for a tag, most specific first: `es-MX` yields
24
+ * `["es-mx", "es"]`. Truncation never stops on a singleton subtag (RFC 4647 §3.4).
25
+ */
26
+ export declare function languageTagLookupSequence(tag: string | null | undefined): string[];
27
+ /**
28
+ * Best available tag for a request by RFC 4647 lookup, or `undefined`. Ranked by the
29
+ * request's specificity, so `es-MX` prefers an `es-MX` candidate over an `es` one.
30
+ */
31
+ export declare function findBestLanguageMatch(requested: string | null | undefined, available: readonly (string | null | undefined)[]): string | undefined;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * The provider, ported from pie-players' `SimpleI18n`
3
+ * (`players-shared/src/i18n/provider.ts`): the same resolution order, fallback chain, plural
4
+ * selection and `{placeholder}` syntax, so a string resolves identically on either side of the
5
+ * player/element boundary.
6
+ *
7
+ * Two differences, both from catalog ownership. The `en-US` catalog arrives as `config.source`
8
+ * rather than a static import, because each element package owns its strings. And the browser
9
+ * locale is never consulted at all: pie-players exports a detector a host may opt into; an
10
+ * element has no host to opt in for it.
11
+ */
12
+ import type { DynamicMessageKey, I18nConfig, I18nServiceApi, InterpolationValues, MessageCatalog, NestedKeys, PluralOptions, TextDirection } from './types.js';
13
+ /** The locale every fallback chain terminates at. */
14
+ export declare const DEFAULT_LOCALE = "en-US";
15
+ /**
16
+ * Assert that a runtime-assembled string is a message key, for a key built from an id the
17
+ * catalog cannot enumerate. Unchecked: pair it with `hasKey` where a miss must not reach the
18
+ * screen.
19
+ */
20
+ export declare function dynamicMessageKey(key: string): DynamicMessageKey;
21
+ /** Writing direction of a locale tag. */
22
+ export declare function localeDirection(locale: string): TextDirection;
23
+ /**
24
+ * Catalogs are BCP-47 keyed and resolved through RFC 4647 lookup, and plural categories come
25
+ * from `Intl.PluralRules`. Never writes `lang` or `dir` anywhere: stamping is the component's
26
+ * job, on its own host.
27
+ */
28
+ export declare class SimpleI18n<C extends MessageCatalog = MessageCatalog> implements I18nServiceApi<NestedKeys<C>> {
29
+ /** Shared across instances and views: `Intl.PluralRules` is not cheap. */
30
+ private static readonly pluralRules;
31
+ private locale;
32
+ private readonly fallbackLocale;
33
+ /** Shared by reference with every `withLocale` view. */
34
+ private catalogs;
35
+ private customMessages;
36
+ private loading;
37
+ private readonly listeners;
38
+ private readonly config;
39
+ constructor(config: I18nConfig<C>);
40
+ getLocale(): string;
41
+ /**
42
+ * Resolve the request against the catalogs this provider can serve, load the winner if it is
43
+ * not resident, then notify, in that order, so a re-render reads a catalog already in place.
44
+ */
45
+ setLocale(locale: string): Promise<void>;
46
+ t(key: NestedKeys<C> | DynamicMessageKey, values?: InterpolationValues): string;
47
+ /** A category the catalog does not carry resolves to `other`. */
48
+ plural(key: NestedKeys<C> | DynamicMessageKey, options: PluralOptions): string;
49
+ getDirection(): TextDirection;
50
+ /** Resident catalogs, whatever the loader declares, and any locale with host messages. */
51
+ getAvailableLocales(): string[];
52
+ isLocaleLoaded(locale: string): boolean;
53
+ /**
54
+ * Whether `setLocale(locale)` would load anything: false once the shipped catalog is resident,
55
+ * and for a locale nothing ships, which the fallback chain answers synchronously.
56
+ */
57
+ needsLoad(locale: string): boolean;
58
+ subscribe(listener: () => void): () => void;
59
+ hasKey(key: NestedKeys<C> | DynamicMessageKey): boolean;
60
+ /** Deep-merged, so a deployment overrides a single label without restating a catalog. */
61
+ addCustomMessages(locale: string, messages: MessageCatalog): void;
62
+ /**
63
+ * A view fixed to another locale. Catalogs, in-flight loads and custom messages are shared by
64
+ * reference, so two elements on one page render different locales from one parsed catalog
65
+ * each.
66
+ */
67
+ withLocale(locale: string): SimpleI18n<C>;
68
+ formatNumber(value: number, options?: Intl.NumberFormatOptions): string;
69
+ formatDate(date: Date, options?: Intl.DateTimeFormatOptions): string;
70
+ /** Load a declared locale's catalog once, however many views ask concurrently. */
71
+ private ensureLoaded;
72
+ /**
73
+ * RFC 4647 lookup first, so `es-MX` reaches an `es` catalog; then primary-subtag widening,
74
+ * so a bare `es` or a POSIX `es_ES` reaches a regional catalog.
75
+ */
76
+ private resolveLocaleTag;
77
+ private loadCatalog;
78
+ /** Host overrides for the active locale win, then its catalog, then the fallback pair. */
79
+ private lookupNode;
80
+ private lookup;
81
+ private selectPluralCategory;
82
+ private notify;
83
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Catalog and provider types, kept free of runtime values so a component can import the whole
3
+ * contract with `import type`.
4
+ *
5
+ * Mirrors pie-players' `players-shared/src/i18n/types.ts`, generic over the catalog: there each
6
+ * key derives from the one `en-US` catalog the repository ships, here every element and library
7
+ * owns its own catalog and its keys derive from that.
8
+ */
9
+ /**
10
+ * Plural forms keyed by CLDR plural category. Only `other` is required: `Intl.PluralRules`
11
+ * selects the category for the active locale, and a category the catalog lacks resolves to
12
+ * `other`.
13
+ */
14
+ export interface PluralTranslation {
15
+ zero?: string;
16
+ one?: string;
17
+ two?: string;
18
+ few?: string;
19
+ many?: string;
20
+ other: string;
21
+ }
22
+ /** A catalog node: a leaf string, a plural group, or a namespace. */
23
+ export type MessageNode = string | PluralTranslation | {
24
+ [key: string]: MessageNode;
25
+ };
26
+ /** A whole catalog, as one locale module default-exports it. */
27
+ export type MessageCatalog = {
28
+ [key: string]: MessageNode;
29
+ };
30
+ type PluralCategoryKey = keyof PluralTranslation;
31
+ /**
32
+ * Dot-notation keys of a catalog shape. A plural group terminates the path at the group, so
33
+ * `plural('maxSelections', …)` is typed and `'maxSelections.other'` is not. A group is
34
+ * recognized by every one of its keys being a CLDR category.
35
+ */
36
+ export type NestedKeys<T> = {
37
+ [K in keyof T & string]: T[K] extends string ? K : keyof T[K] extends PluralCategoryKey ? K : T[K] extends object ? K | `${K}.${NestedKeys<T[K]>}` : K;
38
+ }[keyof T & string];
39
+ /**
40
+ * A key assembled at runtime from an id the catalog cannot enumerate. Branded, so the only way
41
+ * to produce one is `dynamicMessageKey` and a mistyped literal stays a compile error. The brand
42
+ * is a named property because a `unique symbol` is not identity-compatible across two emitted
43
+ * copies of this declaration.
44
+ */
45
+ export type DynamicMessageKey = string & {
46
+ readonly __pieDynamicMessageKey: true;
47
+ };
48
+ /** Interpolation values substituted into `{placeholder}` slots. */
49
+ export type InterpolationValues = Record<string, string | number | boolean | null | undefined>;
50
+ /** Options for `plural`; `count` also interpolates. */
51
+ export interface PluralOptions extends InterpolationValues {
52
+ count: number;
53
+ }
54
+ /** Writing direction of a locale. */
55
+ export type TextDirection = 'ltr' | 'rtl';
56
+ /** Loader for one shipped locale's catalog module. */
57
+ export type CatalogLoader = () => Promise<{
58
+ default: MessageCatalog;
59
+ }>;
60
+ /** Provider configuration. */
61
+ export interface I18nConfig<C extends MessageCatalog = MessageCatalog> {
62
+ /** The `en-US` catalog, bundled statically: the fallback for every key and the source of key types. */
63
+ source: C;
64
+ /** Initial locale. Defaults to `en-US`; never detected from the browser. */
65
+ locale?: string;
66
+ /** Locale every missing key falls back to. Defaults to `en-US`. */
67
+ fallbackLocale?: string;
68
+ /** Loader for a shipped locale. Omitted, only `source` and `customMessages` are available. */
69
+ loadCatalog?: (locale: string) => Promise<MessageCatalog>;
70
+ /** Locale tags `loadCatalog` can serve. */
71
+ availableLocales?: readonly string[];
72
+ /**
73
+ * Host-supplied messages keyed by locale tag, consulted ahead of the shipped catalog for the
74
+ * same locale: a host can rename one label without a fork, and supply a locale nobody ships.
75
+ */
76
+ customMessages?: Record<string, MessageCatalog>;
77
+ /** Called once per missing key per lookup. */
78
+ onMissingKey?: (key: string, locale: string) => void;
79
+ }
80
+ /**
81
+ * What a component translates through, typed by its own catalog's keys. Framework-free, so
82
+ * React and Svelte components share it.
83
+ */
84
+ export interface Translator<K extends string = string> {
85
+ /** Active locale tag, canonicalized to the resolved catalog's tag. */
86
+ getLocale(): string;
87
+ /** Translate a key, interpolating `{placeholder}` slots; a miss returns the key. */
88
+ t(key: K | DynamicMessageKey, values?: InterpolationValues): string;
89
+ /** Translate a plural group, selecting the CLDR category for `options.count`. */
90
+ plural(key: K | DynamicMessageKey, options: PluralOptions): string;
91
+ /** Writing direction of the active locale. */
92
+ getDirection(): TextDirection;
93
+ /** Whether a key resolves in the active locale or its fallback chain. */
94
+ hasKey(key: K | DynamicMessageKey): boolean;
95
+ }
96
+ /** The full service surface of the bundled provider. */
97
+ export interface I18nServiceApi<K extends string = string> extends Translator<K> {
98
+ /** Switch locale, loading its catalog first; subscribers are notified once it is in place. */
99
+ setLocale(locale: string): Promise<void>;
100
+ /** Locale tags this provider can serve. */
101
+ getAvailableLocales(): string[];
102
+ /** Whether a locale's catalog is already resident. */
103
+ isLocaleLoaded(locale: string): boolean;
104
+ /** Whether `setLocale(locale)` would load a catalog. */
105
+ needsLoad(locale: string): boolean;
106
+ /** Observe locale and catalog changes. Returns an unsubscribe function. */
107
+ subscribe(listener: () => void): () => void;
108
+ /** Add or override messages for one locale, deep-merged. */
109
+ addCustomMessages(locale: string, messages: MessageCatalog): void;
110
+ /** A view fixed to another locale, sharing catalogs, loads and custom messages. */
111
+ withLocale(locale: string): I18nServiceApi<K>;
112
+ formatNumber(value: number, options?: Intl.NumberFormatOptions): string;
113
+ formatDate(date: Date, options?: Intl.DateTimeFormatOptions): string;
114
+ }
115
+ export {};
package/package.json CHANGED
@@ -1,6 +1,38 @@
1
1
  {
2
2
  "name": "@pie-element/shared-i18n",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1-next.20261016234357",
4
+ "description": "Interface-string i18n engine for PIE elements: typed per-package catalogs, lazy locales, RFC 4647 resolution",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist"
16
+ ],
17
+ "sideEffects": false,
18
+ "scripts": {
19
+ "build": "rm -rf dist tsconfig.tsbuildinfo && vite build && tsc --emitDeclarationOnly",
20
+ "dev": "vite build --watch",
21
+ "test": "vitest",
22
+ "clean": "rm -rf dist"
23
+ },
24
+ "dependencies": {},
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "license": "MIT",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/pie-framework/pie-elements-ng.git",
32
+ "directory": "packages/shared/i18n"
33
+ },
34
+ "homepage": "https://github.com/pie-framework/pie-elements-ng#readme",
35
+ "bugs": {
36
+ "url": "https://github.com/pie-framework/pie-elements-ng/issues"
37
+ }
38
+ }