@pie-players/pie-players-shared 0.3.67 → 0.3.68

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 (87) hide show
  1. package/README.md +59 -0
  2. package/dist/formative/index.d.ts +16 -0
  3. package/dist/formative/index.js +5 -0
  4. package/dist/formative/mastery.d.ts +16 -0
  5. package/dist/formative/mastery.js +43 -0
  6. package/dist/formative/outcome.d.ts +19 -0
  7. package/dist/formative/outcome.js +90 -0
  8. package/dist/formative/policy.d.ts +37 -0
  9. package/dist/formative/policy.js +86 -0
  10. package/dist/formative/session.d.ts +19 -0
  11. package/dist/formative/session.js +113 -0
  12. package/dist/formative/state.d.ts +60 -0
  13. package/dist/formative/state.js +140 -0
  14. package/dist/formative/types.d.ts +164 -0
  15. package/dist/formative/types.js +13 -0
  16. package/dist/i18n/catalogs.d.ts +38 -0
  17. package/dist/i18n/catalogs.js +47 -0
  18. package/dist/i18n/index.d.ts +28 -4
  19. package/dist/i18n/index.js +34 -3
  20. package/dist/i18n/language-tags.d.ts +51 -0
  21. package/dist/i18n/language-tags.js +84 -0
  22. package/dist/i18n/messages/en-US.d.ts +601 -0
  23. package/dist/i18n/messages/en-US.js +617 -0
  24. package/dist/i18n/messages/nl-NL.d.ts +516 -0
  25. package/dist/i18n/messages/nl-NL.js +516 -0
  26. package/dist/i18n/provider.d.ts +168 -0
  27. package/dist/i18n/provider.js +472 -0
  28. package/dist/i18n/types.d.ts +177 -28
  29. package/dist/i18n/types.js +6 -2
  30. package/dist/index.d.ts +3 -1
  31. package/dist/index.js +2 -0
  32. package/dist/pie/custom-element-define.d.ts +24 -0
  33. package/dist/pie/custom-element-define.js +36 -6
  34. package/dist/pie/index.d.ts +1 -1
  35. package/dist/pie/index.js +1 -1
  36. package/dist/pie/versioned-tag.js +3 -2
  37. package/dist/security/sanitize-forbidden-lists.d.ts +8 -0
  38. package/dist/security/sanitize-forbidden-lists.js +41 -0
  39. package/dist/security/sanitize-item-markup.js +3 -36
  40. package/dist/security/sanitize-svg-icon.js +3 -25
  41. package/dist/security/wrap-overwide-images.d.ts +5 -10
  42. package/dist/security/wrap-overwide-images.js +13 -62
  43. package/dist/security/wrap-overwide-tables.d.ts +5 -10
  44. package/dist/security/wrap-overwide-tables.js +13 -62
  45. package/dist/security/wrap-overwide.d.ts +51 -0
  46. package/dist/security/wrap-overwide.js +91 -0
  47. package/dist/server/npm-auth-env.d.ts +13 -0
  48. package/dist/server/npm-auth-env.js +64 -0
  49. package/dist/timed-media/index.d.ts +17 -0
  50. package/dist/timed-media/index.js +16 -0
  51. package/dist/timed-media/media-element-source.d.ts +36 -0
  52. package/dist/timed-media/media-element-source.js +121 -0
  53. package/dist/timed-media/policy.d.ts +36 -0
  54. package/dist/timed-media/policy.js +297 -0
  55. package/dist/timed-media/session.d.ts +21 -0
  56. package/dist/timed-media/session.js +85 -0
  57. package/dist/timed-media/state.d.ts +66 -0
  58. package/dist/timed-media/state.js +357 -0
  59. package/dist/timed-media/types.d.ts +343 -0
  60. package/dist/timed-media/types.js +12 -0
  61. package/dist/tools/term-lookup.d.ts +189 -0
  62. package/dist/tools/term-lookup.js +284 -0
  63. package/dist/types/index.d.ts +105 -1
  64. package/dist/ui/attribute-coercion.d.ts +1 -0
  65. package/dist/ui/attribute-coercion.js +19 -0
  66. package/dist/ui/first-focusable.d.ts +22 -0
  67. package/dist/ui/first-focusable.js +47 -0
  68. package/dist/ui/focus-trap.js +7 -2
  69. package/dist/ui/pointer-drag.d.ts +30 -0
  70. package/dist/ui/pointer-drag.js +34 -0
  71. package/package.json +35 -3
  72. package/dist/i18n/loader.d.ts +0 -35
  73. package/dist/i18n/loader.js +0 -132
  74. package/dist/i18n/simple-i18n.d.ts +0 -68
  75. package/dist/i18n/simple-i18n.js +0 -198
  76. package/dist/i18n/translations/ar/common.json +0 -36
  77. package/dist/i18n/translations/ar/toolkit.json +0 -48
  78. package/dist/i18n/translations/ar/tools.json +0 -103
  79. package/dist/i18n/translations/en/common.json +0 -36
  80. package/dist/i18n/translations/en/toolkit.json +0 -48
  81. package/dist/i18n/translations/en/tools.json +0 -103
  82. package/dist/i18n/translations/es/common.json +0 -36
  83. package/dist/i18n/translations/es/toolkit.json +0 -48
  84. package/dist/i18n/translations/es/tools.json +0 -103
  85. package/dist/i18n/translations/zh/common.json +0 -36
  86. package/dist/i18n/translations/zh/toolkit.json +0 -48
  87. package/dist/i18n/translations/zh/tools.json +0 -103
@@ -0,0 +1,168 @@
1
+ /**
2
+ * The bundled i18n provider.
3
+ *
4
+ * Deliberately imports only the English catalog. The dynamic loader map for every
5
+ * other locale lives in `./catalogs.js`, which players import and tools do not:
6
+ * every player and tool `vite.config.ts` sets `external: []`, so anything
7
+ * reachable from a tool's entry inlines into that tool's bundle. Keeping the
8
+ * loader map out of this module is what stops a locale chunk from being emitted
9
+ * into eighteen tool bundles that will never call `setLocale`.
10
+ *
11
+ * @module @pie-players/pie-players-shared/i18n/provider
12
+ */
13
+ import type { DynamicMessageKey, I18nConfig, I18nProvider, I18nServiceApi, InterpolationValues, LocaleCode, MessageCatalog, MessageKeyInput, PluralOptions, TextDirection, TranslationBundle } from "./types.js";
14
+ /** The locale every fallback chain terminates at. */
15
+ export declare const DEFAULT_LOCALE = "en-US";
16
+ /**
17
+ * Assert that a runtime-assembled string is a message key.
18
+ *
19
+ * Every other call site passes a literal checked against `MessageKey`, so a
20
+ * typo is a compile error. This is the one escape hatch, for a key built from an
21
+ * id the catalog cannot enumerate — a host-supplied element category, a
22
+ * colour-scheme id. Unchecked, like any assertion: pair it with `hasKey` where a
23
+ * miss must not reach the screen.
24
+ */
25
+ export declare function dynamicMessageKey(key: string): DynamicMessageKey;
26
+ /**
27
+ * Writing direction of a locale tag.
28
+ *
29
+ * Exported because the catalog resolver and the TTS voice selector need it
30
+ * without constructing a provider.
31
+ */
32
+ export declare function localeDirection(locale: string): TextDirection;
33
+ /**
34
+ * The browser's preferred locale, or `DEFAULT_LOCALE` where there is no
35
+ * `navigator` — so this is safe in Node, which `players-shared` must remain.
36
+ *
37
+ * Nothing calls this by default. Under fixed lockstep patch-only versioning a
38
+ * rendered-string change reaches a host's live delivery on their next install
39
+ * with no build signal on their side, so auto-detection would silently switch an
40
+ * English deployment's chrome on a Dutch-configured laptop. A host that wants
41
+ * detection opts into it.
42
+ */
43
+ export declare function detectBrowserLocale(): string;
44
+ /**
45
+ * The bundled provider.
46
+ *
47
+ * Retains the `SimpleI18n` name and the `I18nServiceApi` surface it published
48
+ * before adoption. What changed is behaviour that had no caller: catalogs are
49
+ * BCP-47 keyed and resolved through RFC 4647 lookup rather than string equality,
50
+ * `dir`/`lang` are no longer written to `document.documentElement` (an embedded
51
+ * player has no business writing the host page's root), and plural categories
52
+ * come from `Intl.PluralRules` alone.
53
+ */
54
+ export declare class SimpleI18n implements I18nServiceApi {
55
+ /** Shared across instances and views: `Intl.PluralRules` is not cheap. */
56
+ private static readonly pluralRules;
57
+ private locale;
58
+ private readonly fallbackLocale;
59
+ /** Shared by reference with every `withLocale` view. */
60
+ private catalogs;
61
+ private customMessages;
62
+ private loading;
63
+ private readonly listeners;
64
+ private config;
65
+ constructor(config?: I18nConfig);
66
+ /**
67
+ * Set the locale, loading its catalog first.
68
+ *
69
+ * Convenience over `setLocale` for a host that constructs the provider and
70
+ * configures it in two steps. Never falls back to the browser locale; see
71
+ * {@link detectBrowserLocale}.
72
+ */
73
+ initialize(config: I18nConfig): Promise<void>;
74
+ getLocale(): string;
75
+ /**
76
+ * Switch locale.
77
+ *
78
+ * Resolves the request against the catalogs this provider can serve, loads
79
+ * the winner if it is not resident, then notifies subscribers — in that
80
+ * order, so a component's reactive read re-runs against a catalog that is
81
+ * already in place rather than against the previous locale.
82
+ */
83
+ setLocale(locale: LocaleCode): Promise<void>;
84
+ t(key: MessageKeyInput, values?: InterpolationValues): string;
85
+ /**
86
+ * Translate a plural group.
87
+ *
88
+ * The category comes from `Intl.PluralRules` for the active locale, so a
89
+ * locale with more than two forms resolves correctly: Arabic selects among
90
+ * zero/one/two/few/many/other, Polish among one/few/many/other. A category the
91
+ * catalog does not carry resolves to `other`.
92
+ */
93
+ plural(key: MessageKeyInput, options: PluralOptions): string;
94
+ getDirection(): TextDirection;
95
+ /**
96
+ * Locales this provider can serve: resident catalogs, whatever the loader
97
+ * declares, and any locale the host supplied messages for.
98
+ */
99
+ getAvailableLocales(): string[];
100
+ isLocaleLoaded(locale: LocaleCode): boolean;
101
+ subscribe(listener: () => void): () => void;
102
+ hasKey(key: MessageKeyInput): boolean;
103
+ /**
104
+ * Add or override messages for one locale.
105
+ *
106
+ * Deep-merged, so a deployment can override a single label without restating
107
+ * a catalog.
108
+ */
109
+ addCustomMessages(locale: string, messages: MessageCatalog): void;
110
+ /**
111
+ * A view of this provider fixed to another locale.
112
+ *
113
+ * Catalogs, in-flight loads and custom messages are shared by reference, so
114
+ * `setLocale` through either side is visible to both and no catalog is parsed
115
+ * twice. This is what lets two players on one page render different chrome
116
+ * locales: the locale is per-view, the catalogs are per-provider.
117
+ */
118
+ withLocale(locale: LocaleCode): I18nServiceApi;
119
+ formatNumber(value: number, options?: Intl.NumberFormatOptions): string;
120
+ formatDate(date: Date, options?: Intl.DateTimeFormatOptions): string;
121
+ /** The resolved catalog for a locale, for a host that wants to inspect it. */
122
+ getBundle(locale?: LocaleCode): TranslationBundle | undefined;
123
+ /**
124
+ * Which shipped catalog serves a requested tag.
125
+ *
126
+ * RFC 4647 lookup first, so `nl-BE` reaches `nl-NL` through `nl`; then
127
+ * primary-subtag widening, so a bare `nl` reaches the regional `nl-NL`
128
+ * catalog we actually ship. POSIX `nl_NL` normalizes on the way in, which is
129
+ * the form the Learnosity transform emits.
130
+ */
131
+ private resolveLocaleTag;
132
+ private loadCatalog;
133
+ /**
134
+ * Resolution order for one key. Host overrides for the active locale win, so
135
+ * a deployment can rename one label without forking a catalog.
136
+ */
137
+ private lookupNode;
138
+ private lookup;
139
+ private selectPluralCategory;
140
+ private notify;
141
+ }
142
+ export declare function getDefaultI18n(): I18nServiceApi;
143
+ /**
144
+ * Resolve a component's interface-locale provider from a published context.
145
+ *
146
+ * Returns a **fresh** object on every call, and that is the whole point. A
147
+ * provider is mutable: `setLocale` swaps its catalog in place, so its identity is
148
+ * unchanged before and after a locale load. A reactive `$derived` reading the
149
+ * provider directly therefore never invalidates, and a label rendered while the
150
+ * catalog was still loading stays English forever — the exact silent failure
151
+ * `composition-context.md` records for a context published without a change
152
+ * signal, and it is silent here too because English is a plausible answer.
153
+ *
154
+ * Deriving this instead gives the component a value whose identity tracks the
155
+ * context republish, so the strings built from it re-render. Call it inside
156
+ * `$derived`, never once at setup:
157
+ *
158
+ * ```ts
159
+ * const interfaceI18n = $derived(resolveInterfaceI18n(runtimeContext));
160
+ * ```
161
+ *
162
+ * A context with no provider — no publisher at all — resolves to the English-only
163
+ * default rather than nothing, which is the graceful default the pull pattern
164
+ * requires.
165
+ */
166
+ export declare function resolveInterfaceI18n(source: {
167
+ i18n?: I18nProvider;
168
+ } | null | undefined): I18nProvider;
@@ -0,0 +1,472 @@
1
+ /**
2
+ * The bundled i18n provider.
3
+ *
4
+ * Deliberately imports only the English catalog. The dynamic loader map for every
5
+ * other locale lives in `./catalogs.js`, which players import and tools do not:
6
+ * every player and tool `vite.config.ts` sets `external: []`, so anything
7
+ * reachable from a tool's entry inlines into that tool's bundle. Keeping the
8
+ * loader map out of this module is what stops a locale chunk from being emitted
9
+ * into eighteen tool bundles that will never call `setLocale`.
10
+ *
11
+ * @module @pie-players/pie-players-shared/i18n/provider
12
+ */
13
+ import { findBestLanguageMatch, normalizeLanguageTag, } from "./language-tags.js";
14
+ import enUS from "./messages/en-US.js";
15
+ /** The locale every fallback chain terminates at. */
16
+ export const DEFAULT_LOCALE = "en-US";
17
+ /**
18
+ * Assert that a runtime-assembled string is a message key.
19
+ *
20
+ * Every other call site passes a literal checked against `MessageKey`, so a
21
+ * typo is a compile error. This is the one escape hatch, for a key built from an
22
+ * id the catalog cannot enumerate — a host-supplied element category, a
23
+ * colour-scheme id. Unchecked, like any assertion: pair it with `hasKey` where a
24
+ * miss must not reach the screen.
25
+ */
26
+ export function dynamicMessageKey(key) {
27
+ return key;
28
+ }
29
+ /**
30
+ * Right-to-left primary language subtags.
31
+ *
32
+ * Consulted only where `Intl.Locale.prototype.textInfo` is unavailable — it is
33
+ * the authoritative CLDR answer but shipped late in Safari, and an assessment
34
+ * laid out left-to-right for an Arabic reader is not a graceful degradation.
35
+ * Replaces a four-entry list that omitted every subtag below.
36
+ */
37
+ const RTL_LANGUAGES = new Set([
38
+ "ar", // Arabic
39
+ "ckb", // Central Kurdish
40
+ "dv", // Dhivehi
41
+ "fa", // Persian
42
+ "he",
43
+ "iw", // Hebrew, current and legacy subtags
44
+ "ks", // Kashmiri
45
+ "ku", // Kurdish
46
+ "nqo", // N'Ko
47
+ "ps", // Pashto
48
+ "sd", // Sindhi
49
+ "syr", // Syriac
50
+ "ug", // Uyghur
51
+ "ur", // Urdu
52
+ "yi", // Yiddish
53
+ ]);
54
+ const PLURAL_CATEGORIES = new Set([
55
+ "zero",
56
+ "one",
57
+ "two",
58
+ "few",
59
+ "many",
60
+ "other",
61
+ ]);
62
+ function isPluralGroup(node) {
63
+ if (typeof node !== "object" || node === null)
64
+ return false;
65
+ const keys = Object.keys(node);
66
+ return keys.length > 0 && keys.every((key) => PLURAL_CATEGORIES.has(key));
67
+ }
68
+ /**
69
+ * Writing direction of a locale tag.
70
+ *
71
+ * Exported because the catalog resolver and the TTS voice selector need it
72
+ * without constructing a provider.
73
+ */
74
+ export function localeDirection(locale) {
75
+ try {
76
+ const textInfo = new Intl.Locale(locale).textInfo;
77
+ if (textInfo?.direction === "rtl" || textInfo?.direction === "ltr") {
78
+ return textInfo.direction;
79
+ }
80
+ }
81
+ catch {
82
+ // Not a tag `Intl.Locale` accepts; the subtag check below still answers.
83
+ }
84
+ const language = normalizeLanguageTag(locale).split("-")[0] ?? "";
85
+ return RTL_LANGUAGES.has(language) ? "rtl" : "ltr";
86
+ }
87
+ /**
88
+ * The browser's preferred locale, or `DEFAULT_LOCALE` where there is no
89
+ * `navigator` — so this is safe in Node, which `players-shared` must remain.
90
+ *
91
+ * Nothing calls this by default. Under fixed lockstep patch-only versioning a
92
+ * rendered-string change reaches a host's live delivery on their next install
93
+ * with no build signal on their side, so auto-detection would silently switch an
94
+ * English deployment's chrome on a Dutch-configured laptop. A host that wants
95
+ * detection opts into it.
96
+ */
97
+ export function detectBrowserLocale() {
98
+ if (typeof navigator === "undefined")
99
+ return DEFAULT_LOCALE;
100
+ return navigator.language || navigator.languages?.[0] || DEFAULT_LOCALE;
101
+ }
102
+ /**
103
+ * The bundled provider.
104
+ *
105
+ * Retains the `SimpleI18n` name and the `I18nServiceApi` surface it published
106
+ * before adoption. What changed is behaviour that had no caller: catalogs are
107
+ * BCP-47 keyed and resolved through RFC 4647 lookup rather than string equality,
108
+ * `dir`/`lang` are no longer written to `document.documentElement` (an embedded
109
+ * player has no business writing the host page's root), and plural categories
110
+ * come from `Intl.PluralRules` alone.
111
+ */
112
+ export class SimpleI18n {
113
+ /** Shared across instances and views: `Intl.PluralRules` is not cheap. */
114
+ static pluralRules = new Map();
115
+ locale;
116
+ fallbackLocale;
117
+ /** Shared by reference with every `withLocale` view. */
118
+ catalogs;
119
+ customMessages;
120
+ loading;
121
+ listeners = new Set();
122
+ config;
123
+ constructor(config = {}) {
124
+ this.config = { ...config };
125
+ this.fallbackLocale = normalizeTag(config.fallbackLocale) || DEFAULT_LOCALE;
126
+ this.catalogs = new Map([[DEFAULT_LOCALE, enUS]]);
127
+ this.customMessages = { ...(config.customMessages ?? {}) };
128
+ this.loading = new Map();
129
+ this.locale = this.resolveLocaleTag(config.locale) ?? DEFAULT_LOCALE;
130
+ }
131
+ /**
132
+ * Set the locale, loading its catalog first.
133
+ *
134
+ * Convenience over `setLocale` for a host that constructs the provider and
135
+ * configures it in two steps. Never falls back to the browser locale; see
136
+ * {@link detectBrowserLocale}.
137
+ */
138
+ async initialize(config) {
139
+ this.config = { ...this.config, ...config };
140
+ if (config.customMessages) {
141
+ for (const [locale, messages] of Object.entries(config.customMessages)) {
142
+ this.addCustomMessages(locale, messages);
143
+ }
144
+ }
145
+ await this.setLocale(config.locale ?? this.locale);
146
+ }
147
+ getLocale() {
148
+ return this.locale;
149
+ }
150
+ /**
151
+ * Switch locale.
152
+ *
153
+ * Resolves the request against the catalogs this provider can serve, loads
154
+ * the winner if it is not resident, then notifies subscribers — in that
155
+ * order, so a component's reactive read re-runs against a catalog that is
156
+ * already in place rather than against the previous locale.
157
+ */
158
+ async setLocale(locale) {
159
+ const resolved = this.resolveLocaleTag(locale);
160
+ if (!resolved) {
161
+ // A locale we ship no catalog for is legitimate: the host may have
162
+ // supplied its own messages for it. Honour the tag and let the
163
+ // fallback chain cover whatever those messages omit.
164
+ this.locale = normalizeTag(locale) || DEFAULT_LOCALE;
165
+ this.notify();
166
+ return;
167
+ }
168
+ if (!this.catalogs.has(resolved)) {
169
+ const inFlight = this.loading.get(resolved);
170
+ if (inFlight) {
171
+ await inFlight;
172
+ }
173
+ else {
174
+ const load = this.loadCatalog(resolved);
175
+ this.loading.set(resolved, load);
176
+ try {
177
+ await load;
178
+ }
179
+ finally {
180
+ this.loading.delete(resolved);
181
+ }
182
+ }
183
+ }
184
+ this.locale = resolved;
185
+ this.notify();
186
+ }
187
+ t(key, values) {
188
+ const message = this.lookup(key);
189
+ if (message === undefined) {
190
+ this.config.onMissingKey?.(key, this.locale);
191
+ return key;
192
+ }
193
+ return interpolate(message, values);
194
+ }
195
+ /**
196
+ * Translate a plural group.
197
+ *
198
+ * The category comes from `Intl.PluralRules` for the active locale, so a
199
+ * locale with more than two forms resolves correctly: Arabic selects among
200
+ * zero/one/two/few/many/other, Polish among one/few/many/other. A category the
201
+ * catalog does not carry resolves to `other`.
202
+ */
203
+ plural(key, options) {
204
+ const group = this.lookupNode(key);
205
+ if (group === undefined || !isPluralGroup(group)) {
206
+ return this.t(key, options);
207
+ }
208
+ const category = this.selectPluralCategory(options.count);
209
+ const form = group[category] ?? group.other;
210
+ return interpolate(form, options);
211
+ }
212
+ getDirection() {
213
+ return localeDirection(this.locale);
214
+ }
215
+ /**
216
+ * Locales this provider can serve: resident catalogs, whatever the loader
217
+ * declares, and any locale the host supplied messages for.
218
+ */
219
+ getAvailableLocales() {
220
+ const tags = new Set([
221
+ ...this.catalogs.keys(),
222
+ ...(this.config.availableLocales ?? []),
223
+ ...Object.keys(this.customMessages),
224
+ ]);
225
+ return [...tags];
226
+ }
227
+ isLocaleLoaded(locale) {
228
+ const resolved = this.resolveLocaleTag(locale);
229
+ return resolved !== undefined && this.catalogs.has(resolved);
230
+ }
231
+ subscribe(listener) {
232
+ this.listeners.add(listener);
233
+ return () => {
234
+ this.listeners.delete(listener);
235
+ };
236
+ }
237
+ hasKey(key) {
238
+ return this.lookup(key) !== undefined;
239
+ }
240
+ /**
241
+ * Add or override messages for one locale.
242
+ *
243
+ * Deep-merged, so a deployment can override a single label without restating
244
+ * a catalog.
245
+ */
246
+ addCustomMessages(locale, messages) {
247
+ const tag = normalizeTag(locale);
248
+ this.customMessages[tag] = deepMerge(this.customMessages[tag] ?? {}, messages);
249
+ this.notify();
250
+ }
251
+ /**
252
+ * A view of this provider fixed to another locale.
253
+ *
254
+ * Catalogs, in-flight loads and custom messages are shared by reference, so
255
+ * `setLocale` through either side is visible to both and no catalog is parsed
256
+ * twice. This is what lets two players on one page render different chrome
257
+ * locales: the locale is per-view, the catalogs are per-provider.
258
+ */
259
+ withLocale(locale) {
260
+ const resolved = this.resolveLocaleTag(locale) ?? normalizeTag(locale);
261
+ if (resolved === this.locale)
262
+ return this;
263
+ const view = new SimpleI18n({ ...this.config, locale: resolved });
264
+ view.locale = resolved || DEFAULT_LOCALE;
265
+ view.catalogs = this.catalogs;
266
+ view.customMessages = this.customMessages;
267
+ view.loading = this.loading;
268
+ return view;
269
+ }
270
+ formatNumber(value, options) {
271
+ try {
272
+ return new Intl.NumberFormat(this.locale, options).format(value);
273
+ }
274
+ catch {
275
+ return new Intl.NumberFormat(DEFAULT_LOCALE, options).format(value);
276
+ }
277
+ }
278
+ formatDate(date, options) {
279
+ try {
280
+ return new Intl.DateTimeFormat(this.locale, options).format(date);
281
+ }
282
+ catch {
283
+ return new Intl.DateTimeFormat(DEFAULT_LOCALE, options).format(date);
284
+ }
285
+ }
286
+ /** The resolved catalog for a locale, for a host that wants to inspect it. */
287
+ getBundle(locale = this.locale) {
288
+ const tag = this.resolveLocaleTag(locale);
289
+ if (!tag)
290
+ return undefined;
291
+ const translations = this.catalogs.get(tag);
292
+ if (!translations)
293
+ return undefined;
294
+ return { locale: tag, translations, direction: localeDirection(tag) };
295
+ }
296
+ /**
297
+ * Which shipped catalog serves a requested tag.
298
+ *
299
+ * RFC 4647 lookup first, so `nl-BE` reaches `nl-NL` through `nl`; then
300
+ * primary-subtag widening, so a bare `nl` reaches the regional `nl-NL`
301
+ * catalog we actually ship. POSIX `nl_NL` normalizes on the way in, which is
302
+ * the form the Learnosity transform emits.
303
+ */
304
+ resolveLocaleTag(locale) {
305
+ if (!locale)
306
+ return undefined;
307
+ const available = this.getAvailableLocales();
308
+ const exactOrPrefix = findBestLanguageMatch(locale, available);
309
+ if (exactOrPrefix)
310
+ return exactOrPrefix;
311
+ const requestedLanguage = normalizeLanguageTag(locale).split("-")[0];
312
+ if (!requestedLanguage)
313
+ return undefined;
314
+ return available.find((candidate) => normalizeLanguageTag(candidate).split("-")[0] === requestedLanguage);
315
+ }
316
+ async loadCatalog(locale) {
317
+ const load = this.config.loadCatalog;
318
+ if (!load)
319
+ return;
320
+ try {
321
+ this.catalogs.set(locale, await load(locale));
322
+ }
323
+ catch (error) {
324
+ // A failed load is not fatal: the fallback chain still resolves every
325
+ // key to English. Rethrowing here would take down a player over a
326
+ // missing chunk.
327
+ throw new Error(`Failed to load i18n catalog for locale: ${locale}`, {
328
+ cause: error,
329
+ });
330
+ }
331
+ }
332
+ /**
333
+ * Resolution order for one key. Host overrides for the active locale win, so
334
+ * a deployment can rename one label without forking a catalog.
335
+ */
336
+ lookupNode(key) {
337
+ const sources = [
338
+ this.customMessages[this.locale],
339
+ this.catalogs.get(this.locale),
340
+ this.customMessages[this.fallbackLocale],
341
+ this.catalogs.get(this.fallbackLocale),
342
+ ];
343
+ for (const source of sources) {
344
+ const hit = readPath(source, key);
345
+ if (hit !== undefined)
346
+ return hit;
347
+ }
348
+ return undefined;
349
+ }
350
+ lookup(key) {
351
+ const node = this.lookupNode(key);
352
+ if (typeof node === "string")
353
+ return node;
354
+ // A key landing on a namespace (`t("common")`) or on a plural group is a
355
+ // miss, not a hit: returning the object would put one where the caller
356
+ // expects a string, and interpolating it throws.
357
+ if (node !== undefined && isPluralGroup(node))
358
+ return node.other;
359
+ return undefined;
360
+ }
361
+ selectPluralCategory(count) {
362
+ let rules = SimpleI18n.pluralRules.get(this.locale);
363
+ if (!rules) {
364
+ try {
365
+ rules = new Intl.PluralRules(this.locale);
366
+ }
367
+ catch {
368
+ return count === 1 ? "one" : "other";
369
+ }
370
+ SimpleI18n.pluralRules.set(this.locale, rules);
371
+ }
372
+ return rules.select(count);
373
+ }
374
+ notify() {
375
+ for (const listener of this.listeners)
376
+ listener();
377
+ }
378
+ }
379
+ function normalizeTag(locale) {
380
+ return locale ? locale.trim().replace(/_/g, "-") : "";
381
+ }
382
+ function readPath(source, key) {
383
+ if (!source)
384
+ return undefined;
385
+ let current = source;
386
+ for (const part of key.split(".")) {
387
+ if (typeof current !== "object" || current === null)
388
+ return undefined;
389
+ current = current[part];
390
+ if (current === undefined)
391
+ return undefined;
392
+ }
393
+ return current;
394
+ }
395
+ function interpolate(text, values) {
396
+ if (!values)
397
+ return text;
398
+ return text.replace(/\{(\w+)\}/g, (match, name) => {
399
+ const value = values[name];
400
+ return value === undefined || value === null ? match : String(value);
401
+ });
402
+ }
403
+ function deepMerge(target, source) {
404
+ const result = { ...target };
405
+ for (const [key, value] of Object.entries(source)) {
406
+ const existing = result[key];
407
+ if (value &&
408
+ typeof value === "object" &&
409
+ existing &&
410
+ typeof existing === "object") {
411
+ result[key] = deepMerge(existing, value);
412
+ }
413
+ else {
414
+ result[key] = value;
415
+ }
416
+ }
417
+ return result;
418
+ }
419
+ /**
420
+ * The graceful default: an English-only provider, shared process-wide.
421
+ *
422
+ * `composition-context.md` requires a resolver to work with no publisher
423
+ * present, and this is that state. A tool mounted bare — in `print-player`, in
424
+ * Studio preview, in an authoring harness — resolves this and renders English
425
+ * rather than leaking raw message keys onto the screen. It carries no locale
426
+ * loader, so it pulls no catalog beyond the 5 KB English one already in the
427
+ * bundle.
428
+ */
429
+ let defaultProvider;
430
+ export function getDefaultI18n() {
431
+ if (!defaultProvider)
432
+ defaultProvider = new SimpleI18n();
433
+ return defaultProvider;
434
+ }
435
+ /**
436
+ * Resolve a component's interface-locale provider from a published context.
437
+ *
438
+ * Returns a **fresh** object on every call, and that is the whole point. A
439
+ * provider is mutable: `setLocale` swaps its catalog in place, so its identity is
440
+ * unchanged before and after a locale load. A reactive `$derived` reading the
441
+ * provider directly therefore never invalidates, and a label rendered while the
442
+ * catalog was still loading stays English forever — the exact silent failure
443
+ * `composition-context.md` records for a context published without a change
444
+ * signal, and it is silent here too because English is a plausible answer.
445
+ *
446
+ * Deriving this instead gives the component a value whose identity tracks the
447
+ * context republish, so the strings built from it re-render. Call it inside
448
+ * `$derived`, never once at setup:
449
+ *
450
+ * ```ts
451
+ * const interfaceI18n = $derived(resolveInterfaceI18n(runtimeContext));
452
+ * ```
453
+ *
454
+ * A context with no provider — no publisher at all — resolves to the English-only
455
+ * default rather than nothing, which is the graceful default the pull pattern
456
+ * requires.
457
+ */
458
+ export function resolveInterfaceI18n(source) {
459
+ const provider = source?.i18n ?? getDefaultI18n();
460
+ // A thin facade rather than the provider itself: cheap to allocate (a handful
461
+ // of times per session, once per republish) and it carries no state of its own,
462
+ // so it cannot drift from the provider it delegates to.
463
+ return {
464
+ getLocale: () => provider.getLocale(),
465
+ setLocale: (locale) => provider.setLocale(locale),
466
+ t: (key, values) => provider.t(key, values),
467
+ plural: (key, options) => provider.plural?.(key, options) ?? provider.t(key, options),
468
+ getDirection: () => provider.getDirection?.() ?? "ltr",
469
+ hasKey: (key) => provider.hasKey?.(key) ?? false,
470
+ subscribe: (listener) => provider.subscribe?.(listener) ?? (() => { }),
471
+ };
472
+ }