@pie-players/pie-players-shared 1.0.0-beta.0 → 1.0.0-beta.10

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 (122) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +18 -17
  3. package/dist/THIRD-PARTY-LICENSES.txt +51 -0
  4. package/dist/components/vendor/nds/README.md +43 -5
  5. package/dist/components/vendor/nds/nds-icon-button.d.ts +2 -2
  6. package/dist/components/vendor/nds/nds-icon-button.js +2 -2
  7. package/dist/i18n/catalogs.d.ts +25 -13
  8. package/dist/i18n/catalogs.js +30 -22
  9. package/dist/i18n/content-language.d.ts +77 -0
  10. package/dist/i18n/content-language.js +129 -0
  11. package/dist/i18n/index.d.ts +7 -2
  12. package/dist/i18n/index.js +5 -2
  13. package/dist/i18n/language-tags.d.ts +3 -55
  14. package/dist/i18n/language-tags.js +3 -90
  15. package/dist/i18n/locale-load-error.d.ts +35 -0
  16. package/dist/i18n/locale-load-error.js +30 -0
  17. package/dist/i18n/messages/en-US.d.ts +151 -7
  18. package/dist/i18n/messages/en-US.js +168 -7
  19. package/dist/i18n/provider.d.ts +122 -18
  20. package/dist/i18n/provider.js +315 -133
  21. package/dist/i18n/pseudo.d.ts +34 -0
  22. package/dist/i18n/pseudo.js +106 -0
  23. package/dist/i18n/types.d.ts +19 -3
  24. package/dist/index.d.ts +16 -20
  25. package/dist/index.js +19 -19
  26. package/dist/instrumentation/providers/CompositeInstrumentationProvider.js +2 -1
  27. package/dist/item-session-control/index.d.ts +16 -0
  28. package/dist/item-session-control/index.js +5 -0
  29. package/dist/{formative → item-session-control}/mastery.d.ts +5 -5
  30. package/dist/{formative → item-session-control}/mastery.js +3 -3
  31. package/dist/{formative → item-session-control}/outcome.d.ts +3 -3
  32. package/dist/{formative → item-session-control}/outcome.js +2 -2
  33. package/dist/{formative → item-session-control}/policy.d.ts +11 -11
  34. package/dist/{formative → item-session-control}/policy.js +12 -12
  35. package/dist/item-session-control/session.d.ts +19 -0
  36. package/dist/{formative → item-session-control}/session.js +12 -12
  37. package/dist/{formative → item-session-control}/state.d.ts +26 -26
  38. package/dist/{formative → item-session-control}/state.js +13 -13
  39. package/dist/{formative → item-session-control}/types.d.ts +38 -40
  40. package/dist/{formative → item-session-control}/types.js +2 -2
  41. package/dist/loaders/ElementLoader.d.ts +1 -1
  42. package/dist/loaders/element-loader.d.ts +4 -1
  43. package/dist/loaders/element-loader.js +74 -21
  44. package/dist/loaders/element-package-spec.d.ts +16 -0
  45. package/dist/loaders/element-package-spec.js +30 -0
  46. package/dist/loaders/esm-adapter.d.ts +12 -1
  47. package/dist/loaders/esm-adapter.js +113 -19
  48. package/dist/loaders/iife-adapter.d.ts +7 -0
  49. package/dist/loaders/iife-adapter.js +1 -1
  50. package/dist/loaders/index.d.ts +2 -1
  51. package/dist/loaders/index.js +2 -1
  52. package/dist/loaders/preloaded-registration.d.ts +18 -6
  53. package/dist/loaders/preloaded-registration.js +44 -9
  54. package/dist/object/index.d.ts +4 -1
  55. package/dist/object/index.js +8 -1
  56. package/dist/pie/config.d.ts +8 -0
  57. package/dist/pie/config.js +32 -1
  58. package/dist/pie/index.d.ts +2 -0
  59. package/dist/pie/index.js +1 -0
  60. package/dist/pie/math-rendering.d.ts +13 -1
  61. package/dist/pie/math-rendering.js +54 -1
  62. package/dist/pie/updates.d.ts +4 -1
  63. package/dist/pie/updates.js +21 -5
  64. package/dist/pie/versioned-tag.d.ts +10 -0
  65. package/dist/pie/versioned-tag.js +27 -0
  66. package/dist/promise/index.d.ts +10 -0
  67. package/dist/promise/index.js +16 -0
  68. package/dist/security/overwide-regions.js +65 -0
  69. package/dist/security/private-hosts.d.ts +27 -0
  70. package/dist/security/private-hosts.js +85 -0
  71. package/dist/security/validate-style-url.js +2 -2
  72. package/dist/security/wrap-overwide-images.d.ts +6 -2
  73. package/dist/security/wrap-overwide-images.js +5 -2
  74. package/dist/security/wrap-overwide-tables.d.ts +6 -2
  75. package/dist/security/wrap-overwide-tables.js +5 -2
  76. package/dist/security/wrap-overwide.d.ts +13 -0
  77. package/dist/security/wrap-overwide.js +68 -3
  78. package/dist/timed-media/index.d.ts +1 -1
  79. package/dist/timed-media/index.js +1 -1
  80. package/dist/timed-media/policy.d.ts +2 -2
  81. package/dist/timed-media/policy.js +2 -2
  82. package/dist/timed-media/session.d.ts +1 -1
  83. package/dist/timed-media/session.js +1 -1
  84. package/dist/timed-media/types.d.ts +11 -11
  85. package/dist/tools/term-lookup.d.ts +15 -0
  86. package/dist/tools/term-lookup.js +16 -0
  87. package/dist/types/index.d.ts +127 -47
  88. package/dist/types/interchange.d.ts +116 -0
  89. package/dist/types/interchange.js +1 -0
  90. package/dist/ui/content-styles-entry.d.ts +1 -0
  91. package/dist/ui/content-styles-entry.js +2 -0
  92. package/dist/ui/content-styles.d.ts +24 -3
  93. package/dist/ui/content-styles.js +114 -3
  94. package/dist/ui/focus-trap.d.ts +12 -0
  95. package/dist/ui/focus-trap.js +41 -1
  96. package/dist/ui/fontawesome.d.ts +51 -0
  97. package/dist/ui/fontawesome.js +122 -0
  98. package/dist/ui/ime.d.ts +6 -0
  99. package/dist/ui/ime.js +10 -0
  100. package/dist/ui/overlay-containment.d.ts +13 -0
  101. package/dist/ui/overlay-containment.js +23 -0
  102. package/dist/ui/overlay-placement.d.ts +28 -0
  103. package/dist/ui/overlay-placement.js +17 -2
  104. package/package.json +72 -36
  105. package/dist/formative/index.d.ts +0 -16
  106. package/dist/formative/index.js +0 -5
  107. package/dist/formative/session.d.ts +0 -19
  108. package/dist/i18n/messages/nl-NL.d.ts +0 -529
  109. package/dist/i18n/messages/nl-NL.js +0 -529
  110. package/dist/instrumentation/new-relic-agent.d.ts +0 -20
  111. package/dist/loaders/module-shim.d.ts +0 -12
  112. package/dist/pie/authoring-tag.d.ts +0 -6
  113. package/dist/pie/configure-initialization.d.ts +0 -21
  114. package/dist/pie/correct-response-env.d.ts +0 -3
  115. package/dist/pie/element-announcements.d.ts +0 -39
  116. package/dist/pie/elements-rendered.d.ts +0 -23
  117. package/dist/pie/initialize-element.d.ts +0 -26
  118. package/dist/pie/markup-math.d.ts +0 -20
  119. package/dist/pie/resource-monitor.d.ts +0 -217
  120. package/dist/security/sanitize-forbidden-lists.d.ts +0 -8
  121. package/dist/ui/pointer-rotate.d.ts +0 -29
  122. package/dist/ui/safe-storage.d.ts +0 -2
@@ -9,7 +9,7 @@
9
9
  *
10
10
  * @module @pie-players/pie-players-shared/i18n/provider
11
11
  */
12
- import { findBestLanguageMatch, normalizeLanguageTag, toLanguageTag, } from "./language-tags.js";
12
+ import { canonicalLanguageTag, findClosestLanguageMatch, languageTagLookupSequence, normalizeLanguageTag, toLanguageTag, } from "./language-tags.js";
13
13
  import enUS from "./messages/en-US.js";
14
14
  /** The locale every fallback chain terminates at. */
15
15
  export const DEFAULT_LOCALE = "en-US";
@@ -57,6 +57,10 @@ const PLURAL_CATEGORIES = new Set([
57
57
  "many",
58
58
  "other",
59
59
  ]);
60
+ /** A request's tag in canonical form, or its hyphenated spelling where it is not a tag. */
61
+ function requestTag(locale) {
62
+ return canonicalLanguageTag(locale) ?? toLanguageTag(locale);
63
+ }
60
64
  function isPluralGroup(node) {
61
65
  if (typeof node !== "object" || node === null)
62
66
  return false;
@@ -108,21 +112,35 @@ export function detectBrowserLocale() {
108
112
  export class SimpleI18n {
109
113
  /** Shared across instances and views: `Intl.PluralRules` is not cheap. */
110
114
  static pluralRules = new Map();
111
- locale;
115
+ /** The latest request, canonical; what `getRequestedLocale` names. */
116
+ requested;
117
+ /** The request whose strings are served; moves once its catalog is resident. */
118
+ active;
119
+ /** The catalog serving `active`, resolved against catalogs alone. */
120
+ activeCatalog;
112
121
  fallbackLocale;
113
122
  /** Shared by reference with every `withLocale` view. */
114
123
  catalogs;
124
+ /** Keyed by normalized tag, so `ES`, `es_ES` and `es-es` reach one set. */
115
125
  customMessages;
116
126
  loading;
127
+ /** Counts `setLocale` calls, so a slow load cannot overwrite a later request. */
128
+ requests = 0;
117
129
  listeners = new Set();
118
130
  config;
119
131
  constructor(config = {}) {
120
132
  this.config = { ...config };
121
- this.fallbackLocale = toLanguageTag(config.fallbackLocale) || DEFAULT_LOCALE;
133
+ this.fallbackLocale =
134
+ toLanguageTag(config.fallbackLocale) || DEFAULT_LOCALE;
122
135
  this.catalogs = new Map([[DEFAULT_LOCALE, enUS]]);
123
- this.customMessages = { ...(config.customMessages ?? {}) };
136
+ this.customMessages = {};
137
+ for (const [locale, messages] of Object.entries(config.customMessages ?? {})) {
138
+ this.mergeCustomMessages(locale, messages);
139
+ }
124
140
  this.loading = new Map();
125
- this.locale = this.resolveLocaleTag(config.locale) ?? DEFAULT_LOCALE;
141
+ this.requested = requestTag(config.locale) || DEFAULT_LOCALE;
142
+ this.active = this.requested;
143
+ this.activeCatalog = this.resolveCatalogLocale(this.active);
126
144
  }
127
145
  /**
128
146
  * Set the locale, loading its catalog first.
@@ -138,10 +156,32 @@ export class SimpleI18n {
138
156
  this.addCustomMessages(locale, messages);
139
157
  }
140
158
  }
141
- await this.setLocale(config.locale ?? this.locale);
159
+ await this.setLocale(config.locale ?? this.requested);
142
160
  }
161
+ /**
162
+ * The locale the strings are served in: the resolved catalog locale once
163
+ * resident, else the most specific host-message tag on the request's
164
+ * truncation chain, canonical, else the fallback. A request nothing serves
165
+ * names the fallback, so a chrome root stamped from it never names a
166
+ * language its text is not in.
167
+ */
143
168
  getLocale() {
144
- return this.locale;
169
+ if (this.activeCatalog && this.catalogs.has(this.activeCatalog)) {
170
+ return this.activeCatalog;
171
+ }
172
+ const host = this.hostMessageLocale(this.active);
173
+ return host === undefined
174
+ ? this.fallbackLocale
175
+ : (canonicalLanguageTag(host) ?? host);
176
+ }
177
+ /**
178
+ * The latest `setLocale` request (or the configured `locale`), canonical, as
179
+ * soon as it is made and whatever its load does. `getLocale` names what is
180
+ * served instead, so the two differ while a catalog loads, after a load
181
+ * fails, and for a tag nothing ships.
182
+ */
183
+ getRequestedLocale() {
184
+ return this.requested;
145
185
  }
146
186
  /**
147
187
  * Switch locale.
@@ -149,68 +189,77 @@ export class SimpleI18n {
149
189
  * Resolves the request against the catalogs this provider can serve, loads
150
190
  * the winner if it is not resident, then notifies subscribers — in that
151
191
  * order, so a component's reactive read re-runs against a catalog that is
152
- * already in place rather than against the previous locale.
192
+ * already in place rather than against the previous locale. The last call
193
+ * wins: a call whose load finishes after a later call's leaves the locale
194
+ * to that later call.
153
195
  */
154
196
  async setLocale(locale) {
155
- const resolved = this.resolveLocaleTag(locale);
156
- if (!resolved) {
157
- // A locale we ship no catalog for is legitimate: the host may have
158
- // supplied its own messages for it. Honour the tag and let the
159
- // fallback chain cover whatever those messages omit.
160
- this.locale = toLanguageTag(locale) || DEFAULT_LOCALE;
161
- this.notify();
162
- return;
163
- }
164
- // The loader is asked only for tags it declares. A tag that resolved through
165
- // host messages alone has nothing to load, and is honoured like the
166
- // unresolved case above.
167
- if (!this.catalogs.has(resolved) &&
168
- this.config.availableLocales?.includes(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);
197
+ const request = ++this.requests;
198
+ const tag = requestTag(locale) || DEFAULT_LOCALE;
199
+ this.requested = tag;
200
+ // A tag no catalog serves is legitimate: the host may have supplied its own
201
+ // messages for it, and the fallback chain covers whatever those omit.
202
+ const catalog = this.resolveCatalogLocale(tag);
203
+ // The loader is asked only for tags it declares.
204
+ if (catalog &&
205
+ !this.catalogs.has(catalog) &&
206
+ this.config.availableLocales?.includes(catalog)) {
207
+ try {
208
+ let load = this.loading.get(catalog);
209
+ if (!load) {
210
+ load = this.loadCatalog(catalog).finally(() => {
211
+ this.loading.delete(catalog);
212
+ });
213
+ this.loading.set(catalog, load);
181
214
  }
215
+ await load;
216
+ }
217
+ catch (error) {
218
+ // A superseded request stays silent: the later one reports its own
219
+ // outcome. The current one notifies, since `getRequestedLocale` moved
220
+ // although the served strings did not, and rejects.
221
+ if (request !== this.requests)
222
+ return;
223
+ this.notify();
224
+ throw error;
182
225
  }
183
226
  }
184
- this.locale = resolved;
227
+ if (request !== this.requests)
228
+ return;
229
+ this.active = tag;
230
+ this.activeCatalog = catalog;
185
231
  this.notify();
186
232
  }
187
233
  t(key, values) {
188
- const message = this.lookup(key);
234
+ const message = this.resolveMessage(key);
189
235
  if (message === undefined) {
190
- this.config.onMissingKey?.(key, this.locale);
236
+ this.config.onMissingKey?.(key, this.getLocale());
191
237
  return key;
192
238
  }
193
- return interpolate(message, values);
239
+ return interpolate(message, values, this.getLocale());
194
240
  }
195
241
  /**
196
242
  * Translate a plural group.
197
243
  *
198
244
  * The category comes from `Intl.PluralRules` for the active locale, so a
199
245
  * 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`.
246
+ * zero/one/two/few/many/other, Polish among one/few/many/other.
247
+ *
248
+ * Each form resolves on its own, tier by tier (`resolveMessage`): a host
249
+ * overriding one category leaves the others to the catalog, and a locale's
250
+ * own `other` wins over the fallback's `one`. A group no source completes is
251
+ * a miss and returns the key.
202
252
  */
203
253
  plural(key, options) {
204
- const group = this.lookupNode(key);
205
- if (group === undefined || !isPluralGroup(group)) {
206
- return this.t(key, options);
254
+ const message = this.resolveMessage(key, this.selectPluralCategory(options.count));
255
+ if (message === undefined) {
256
+ this.config.onMissingKey?.(key, this.getLocale());
257
+ return key;
207
258
  }
208
- const category = this.selectPluralCategory(options.count);
209
- const form = group[category] ?? group.other;
210
- return interpolate(form, options);
259
+ return interpolate(message, options, this.getLocale());
211
260
  }
212
261
  getDirection() {
213
- return localeDirection(this.locale);
262
+ return localeDirection(this.getLocale());
214
263
  }
215
264
  /**
216
265
  * Locales this provider can serve: resident catalogs, whatever the loader
@@ -220,12 +269,12 @@ export class SimpleI18n {
220
269
  const tags = new Set([
221
270
  ...this.catalogs.keys(),
222
271
  ...(this.config.availableLocales ?? []),
223
- ...Object.keys(this.customMessages),
272
+ ...Object.keys(this.customMessages).map((tag) => canonicalLanguageTag(tag) ?? tag),
224
273
  ]);
225
274
  return [...tags];
226
275
  }
227
276
  isLocaleLoaded(locale) {
228
- const resolved = this.resolveLocaleTag(locale);
277
+ const resolved = this.resolveCatalogLocale(requestTag(locale));
229
278
  return resolved !== undefined && this.catalogs.has(resolved);
230
279
  }
231
280
  subscribe(listener) {
@@ -235,19 +284,25 @@ export class SimpleI18n {
235
284
  };
236
285
  }
237
286
  hasKey(key) {
238
- return this.lookup(key) !== undefined;
287
+ return this.resolveMessage(key) !== undefined;
239
288
  }
240
289
  /**
241
290
  * Add or override messages for one locale.
242
291
  *
243
292
  * Deep-merged, so a deployment can override a single label without restating
244
- * a catalog.
293
+ * a catalog, and keyed by normalized tag, so `es-mx` and `es-MX` are one
294
+ * locale's messages.
245
295
  */
246
296
  addCustomMessages(locale, messages) {
247
- const tag = toLanguageTag(locale);
248
- this.customMessages[tag] = deepMerge(this.customMessages[tag] ?? {}, messages);
297
+ this.mergeCustomMessages(locale, messages);
249
298
  this.notify();
250
299
  }
300
+ mergeCustomMessages(locale, messages) {
301
+ const tag = normalizeLanguageTag(locale);
302
+ if (!tag)
303
+ return;
304
+ this.customMessages[tag] = deepMerge(this.customMessages[tag] ?? {}, messages);
305
+ }
251
306
  /**
252
307
  * A view of this provider fixed to another locale.
253
308
  *
@@ -257,19 +312,21 @@ export class SimpleI18n {
257
312
  * locales: the locale is per-view, the catalogs are per-provider.
258
313
  */
259
314
  withLocale(locale) {
260
- const resolved = this.resolveLocaleTag(locale) ?? toLanguageTag(locale);
261
- if (resolved === this.locale)
315
+ const tag = requestTag(locale) || DEFAULT_LOCALE;
316
+ if (tag === this.active && tag === this.requested)
262
317
  return this;
263
- const view = new SimpleI18n({ ...this.config, locale: resolved });
264
- view.locale = resolved || DEFAULT_LOCALE;
318
+ const view = new SimpleI18n({ ...this.config, customMessages: {} });
265
319
  view.catalogs = this.catalogs;
266
320
  view.customMessages = this.customMessages;
267
321
  view.loading = this.loading;
322
+ view.requested = tag;
323
+ view.active = tag;
324
+ view.activeCatalog = view.resolveCatalogLocale(tag);
268
325
  return view;
269
326
  }
270
327
  formatNumber(value, options) {
271
328
  try {
272
- return new Intl.NumberFormat(this.locale, options).format(value);
329
+ return new Intl.NumberFormat(this.getLocale(), options).format(value);
273
330
  }
274
331
  catch {
275
332
  return new Intl.NumberFormat(DEFAULT_LOCALE, options).format(value);
@@ -277,15 +334,15 @@ export class SimpleI18n {
277
334
  }
278
335
  formatDate(date, options) {
279
336
  try {
280
- return new Intl.DateTimeFormat(this.locale, options).format(date);
337
+ return new Intl.DateTimeFormat(this.getLocale(), options).format(date);
281
338
  }
282
339
  catch {
283
340
  return new Intl.DateTimeFormat(DEFAULT_LOCALE, options).format(date);
284
341
  }
285
342
  }
286
343
  /** 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);
344
+ getBundle(locale = this.getLocale()) {
345
+ const tag = this.resolveCatalogLocale(requestTag(locale));
289
346
  if (!tag)
290
347
  return undefined;
291
348
  const translations = this.catalogs.get(tag);
@@ -294,24 +351,22 @@ export class SimpleI18n {
294
351
  return { locale: tag, translations, direction: localeDirection(tag) };
295
352
  }
296
353
  /**
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.
354
+ * Which catalog serves a requested tag, under `findClosestLanguageMatch`
355
+ * over the resident catalogs and the loader's declared tags. Host messages
356
+ * take no part: they resolve separately (`hostMessageLocale`), so a host
357
+ * override for `es-MX` cannot hide a shipped `es`.
303
358
  */
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)
359
+ resolveCatalogLocale(tag) {
360
+ if (!tag)
313
361
  return undefined;
314
- return available.find((candidate) => normalizeLanguageTag(candidate).split("-")[0] === requestedLanguage);
362
+ return findClosestLanguageMatch(tag, [
363
+ ...this.catalogs.keys(),
364
+ ...(this.config.availableLocales ?? []),
365
+ ]);
366
+ }
367
+ /** The most specific host-message tag on a request's truncation chain, normalized. */
368
+ hostMessageLocale(tag) {
369
+ return languageTagLookupSequence(tag).find((step) => this.customMessages[step] !== undefined);
315
370
  }
316
371
  async loadCatalog(locale) {
317
372
  const load = this.config.loadCatalog;
@@ -322,52 +377,74 @@ export class SimpleI18n {
322
377
  }
323
378
  catch (error) {
324
379
  // Rethrown with the locale named: `setLocale` rejects before switching
325
- // (it assigns `this.locale` only after the load), so the previous locale
326
- // stays active.
380
+ // (it moves the served state only after the load), so the previous
381
+ // locale stays active.
327
382
  throw new Error(`Failed to load i18n catalog for locale: ${locale}`, {
328
383
  cause: error,
329
384
  });
330
385
  }
331
386
  }
332
387
  /**
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.
388
+ * Two tiers in lookup order: host messages along the active request's
389
+ * truncation chain (`es-MX`, then `es`) and the active catalog, then host
390
+ * messages and the catalog for the fallback locale. Host overrides win, so a
391
+ * deployment can rename one label without forking a catalog.
335
392
  */
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),
393
+ sourceTiers() {
394
+ return [
395
+ [
396
+ ...languageTagLookupSequence(this.active).map((step) => this.customMessages[step]),
397
+ this.activeCatalog ? this.catalogs.get(this.activeCatalog) : undefined,
398
+ ],
399
+ [
400
+ this.customMessages[normalizeLanguageTag(this.fallbackLocale)],
401
+ this.catalogs.get(this.fallbackLocale),
402
+ ],
342
403
  ];
343
- for (const source of sources) {
344
- const hit = readPath(source, key);
345
- if (hit !== undefined)
346
- return hit;
347
- }
348
- return undefined;
349
404
  }
350
- lookup(key) {
351
- const node = this.lookupNode(key);
352
- if (typeof node === "string")
353
- return node;
354
- // A plural group resolves to its `other` form. A namespace (`t("common")`)
355
- // is a miss: returning the object would put one where the caller expects
356
- // a string, and interpolating it throws.
357
- if (node !== undefined && isPluralGroup(node))
358
- return node.other;
405
+ /**
406
+ * The text for `key`. The first source that has it decides its shape: a
407
+ * string is the text, and a plural group resolves `category`, else `other`,
408
+ * within the first tier that has a group, so a locale's own `other` wins
409
+ * over the fallback's `one`. A namespace (`t("common")`) is a miss:
410
+ * returning the object would put one where the caller expects a string.
411
+ */
412
+ resolveMessage(key, category) {
413
+ const tiers = [];
414
+ for (const tier of this.sourceTiers()) {
415
+ const groups = [];
416
+ for (const source of tier) {
417
+ const node = readPath(source, key);
418
+ if (typeof node === "string") {
419
+ if (tiers.every((t) => t.length === 0) && groups.length === 0) {
420
+ return node;
421
+ }
422
+ }
423
+ else if (node !== undefined && isPluralGroup(node)) {
424
+ groups.push(node);
425
+ }
426
+ }
427
+ tiers.push(groups);
428
+ }
429
+ for (const groups of tiers) {
430
+ const form = (name) => groups.find((group) => typeof group[name] === "string")?.[name];
431
+ const text = (category === undefined ? undefined : form(category)) ?? form("other");
432
+ if (text !== undefined)
433
+ return text;
434
+ }
359
435
  return undefined;
360
436
  }
361
437
  selectPluralCategory(count) {
362
- let rules = SimpleI18n.pluralRules.get(this.locale);
438
+ const locale = this.getLocale();
439
+ let rules = SimpleI18n.pluralRules.get(locale);
363
440
  if (!rules) {
364
441
  try {
365
- rules = new Intl.PluralRules(this.locale);
442
+ rules = new Intl.PluralRules(locale);
366
443
  }
367
444
  catch {
368
445
  return count === 1 ? "one" : "other";
369
446
  }
370
- SimpleI18n.pluralRules.set(this.locale, rules);
447
+ SimpleI18n.pluralRules.set(locale, rules);
371
448
  }
372
449
  return rules.select(count);
373
450
  }
@@ -389,12 +466,41 @@ function readPath(source, key) {
389
466
  }
390
467
  return current;
391
468
  }
392
- function interpolate(text, values) {
469
+ /**
470
+ * Substitute `{name}` placeholders.
471
+ *
472
+ * A finite numeric value renders through `Intl.NumberFormat` for the active
473
+ * locale, so digits and grouping follow CLDR locale defaults: `ar` renders
474
+ * Arabic-Indic digits, `fr` groups with narrow no-break spaces, `en-US` with
475
+ * commas. Catalog text that spells digits out is untouched.
476
+ */
477
+ const numberFormats = new Map();
478
+ function formatNumber(value, locale) {
479
+ let formatter = numberFormats.get(locale);
480
+ if (!formatter) {
481
+ try {
482
+ formatter = new Intl.NumberFormat(locale);
483
+ }
484
+ catch {
485
+ formatter = new Intl.NumberFormat(DEFAULT_LOCALE);
486
+ }
487
+ numberFormats.set(locale, formatter);
488
+ }
489
+ return formatter.format(value);
490
+ }
491
+ function interpolate(text, values, locale) {
393
492
  if (!values)
394
493
  return text;
395
494
  return text.replace(/\{(\w+)\}/g, (match, name) => {
396
495
  const value = values[name];
397
- return value === undefined || value === null ? match : String(value);
496
+ if (value === undefined || value === null)
497
+ return match;
498
+ if (locale !== undefined &&
499
+ typeof value === "number" &&
500
+ Number.isFinite(value)) {
501
+ return formatNumber(value, locale);
502
+ }
503
+ return String(value);
398
504
  });
399
505
  }
400
506
  function deepMerge(target, source) {
@@ -421,48 +527,124 @@ let defaultProvider;
421
527
  * present, and this is that state. A tool mounted bare — in `print-player` or
422
528
  * an authoring harness — resolves this and renders English rather than
423
529
  * leaking raw message keys onto the screen. It carries no locale loader, so it
424
- * pulls no catalog beyond the 5 KB English one already in the bundle.
530
+ * pulls no catalog beyond the English one already in the bundle.
425
531
  */
426
532
  export function getDefaultI18n() {
427
533
  if (!defaultProvider)
428
534
  defaultProvider = new SimpleI18n();
429
535
  return defaultProvider;
430
536
  }
431
- /**
432
- * Resolve a component's interface-locale provider from a published context.
433
- *
434
- * Returns a **fresh** object on every call. A
435
- * provider is mutable: `setLocale` swaps its catalog in place, so its identity is
436
- * unchanged before and after a locale load. A reactive `$derived` reading the
437
- * provider directly therefore never invalidates, and a label rendered while the
438
- * catalog was still loading stays English forever — the exact silent failure
439
- * `composition-context.md` records for a context published without a change
440
- * signal, and it is silent here too because English is a plausible answer.
441
- *
442
- * Deriving this instead gives the component a value whose identity tracks the
443
- * context republish, so the strings built from it re-render. Call it inside
444
- * `$derived`, never once at setup:
445
- *
446
- * ```ts
447
- * const interfaceI18n = $derived(resolveInterfaceI18n(runtimeContext));
448
- * ```
449
- *
450
- * A context with no provider — no publisher at all — resolves to the English-only
451
- * default rather than nothing, which is the graceful default the pull pattern
452
- * requires.
453
- */
454
537
  export function resolveInterfaceI18n(source) {
455
538
  const provider = source?.i18n ?? getDefaultI18n();
456
539
  // A thin facade rather than the provider itself: cheap to allocate (a handful
457
540
  // of times per session, once per republish) and it carries no state of its own,
458
- // so it cannot drift from the provider it delegates to.
541
+ // so it cannot drift from the provider it delegates to. The locale formatters
542
+ // are part of the promised surface, so a provider that does not implement
543
+ // them falls through to `Intl` at the locale the provider resolves — the same
544
+ // thing the bundled provider does, and the same graceful default every other
545
+ // optional member here applies.
459
546
  return {
460
- getLocale: () => provider.getLocale(),
547
+ // Canonical, because components read this for `lang` attributes; the
548
+ // provider's own `getLocale()` keeps the tag as resolved for lookups.
549
+ getLocale: () => {
550
+ const locale = provider.getLocale();
551
+ return canonicalLanguageTag(locale) ?? locale;
552
+ },
553
+ getRequestedLocale: () => requestedLocaleOf(provider),
461
554
  setLocale: (locale) => provider.setLocale(locale),
462
555
  t: (key, values) => provider.t(key, values),
463
556
  plural: (key, options) => provider.plural?.(key, options) ?? provider.t(key, options),
464
557
  getDirection: () => provider.getDirection?.() ?? "ltr",
465
558
  hasKey: (key) => provider.hasKey?.(key) ?? false,
466
559
  subscribe: (listener) => provider.subscribe?.(listener) ?? (() => { }),
560
+ formatNumber: (value, options) => provider.formatNumber?.(value, options) ??
561
+ new Intl.NumberFormat(provider.getLocale(), options).format(value),
562
+ formatDate: (value, options) => provider.formatDate?.(value, options) ??
563
+ new Intl.DateTimeFormat(provider.getLocale(), options).format(value),
467
564
  };
468
565
  }
566
+ /**
567
+ * Stamp the interface locale's `lang` and `dir` on a chrome element.
568
+ *
569
+ * The imperative form of the `lang={…} dir={…}` pair a template puts on its
570
+ * root, for the elements a template cannot reach: a shadow host whose chrome
571
+ * is slotted light DOM, a shell built with `document.createElement`, a vanilla
572
+ * element stamping itself. Call it from whatever already re-runs on a locale
573
+ * change and on a catalog load: `getLocale()` moves only once the requested
574
+ * catalog is resident, so a stamp taken at setup alone names the locale that
575
+ * rendered before the load.
576
+ */
577
+ export function stampInterfaceLocale(target, provider) {
578
+ // `lang` takes the canonical form: a host's own provider may report a tag
579
+ // as the host spelled it, and `NL-nl` is a valid request but not a valid
580
+ // attribute value. Every `lang` this repository writes goes through the
581
+ // same rule, so chrome and content stamps match in shape.
582
+ const locale = provider.getLocale();
583
+ target.setAttribute("lang", canonicalLanguageTag(locale) ?? locale);
584
+ target.setAttribute("dir", provider.getDirection?.() ?? "ltr");
585
+ }
586
+ /** A provider's requested tag, canonical; `getLocale` for one that does not track requests. */
587
+ function requestedLocaleOf(provider) {
588
+ const locale = provider.getRequestedLocale?.() ?? provider.getLocale();
589
+ return canonicalLanguageTag(locale) ?? locale;
590
+ }
591
+ /**
592
+ * Whether `provider` was already asked for `locale`. An element handed its
593
+ * owner's provider together with the owner's `locale` skips asking again, so a
594
+ * catalog that fails to load is fetched and reported once, by the owner, and
595
+ * not again by every row and player under it.
596
+ */
597
+ export function isLocaleRequested(provider, locale) {
598
+ const requested = provider.getRequestedLocale?.();
599
+ if (!requested)
600
+ return false;
601
+ return ((canonicalLanguageTag(requested) ?? requested) ===
602
+ (canonicalLanguageTag(locale) ?? locale));
603
+ }
604
+ /**
605
+ * Whether a host chose a player's interface locale: it set the player's
606
+ * `locale`, or supplied a provider that was asked for a locale other than
607
+ * `en-US`.
608
+ *
609
+ * A player answers `interfaceLocaleContext` from the first time this holds,
610
+ * and never before it. An element left unanswered labels its chrome in the
611
+ * language its item declares, which is how an item declaring Spanish keeps
612
+ * Spanish labels under a host that sets no locale; a default `en-US` answer
613
+ * would override that declaration. A supplied provider asked for `en-US` is
614
+ * not a choice, because hosts supply one to add catalogs or messages; a host
615
+ * that wants `en-US` chrome over the item's language sets `locale="en-US"`.
616
+ * Once answering, a player keeps answering, so clearing `locale` republishes
617
+ * `en-US` to the elements that subscribed.
618
+ *
619
+ * The toolkit and the item player both decide through this rule. An item
620
+ * player a section mounts receives the toolkit's provider, so it answers only
621
+ * when the toolkit above it would. Callers re-evaluate it on the provider's
622
+ * change signal, since a supplied provider's locale moves without a property
623
+ * change.
624
+ */
625
+ export function hostChoseInterfaceLocale(player) {
626
+ if (typeof player.locale === "string" && player.locale.trim() !== "") {
627
+ return true;
628
+ }
629
+ // The request, not the served locale: a provider asked for a tag nothing
630
+ // ships serves `en-US`, and the host still chose that tag.
631
+ if (!player.i18n)
632
+ return false;
633
+ return requestedLocaleOf(player.i18n) !== DEFAULT_LOCALE;
634
+ }
635
+ /**
636
+ * The tag a player answers `interfaceLocaleContext` with: its `locale`,
637
+ * canonical, from the moment it is set, else its provider's requested tag.
638
+ *
639
+ * The request rather than the players' own catalog match, because elements
640
+ * resolve the tag against their own catalogs: a host asking for `en-GB` or
641
+ * `fr-CA` reaches the elements' catalogs and host messages for that tag
642
+ * whatever the players ship, and neither a players' catalog still loading nor
643
+ * one that failed holds the elements on the previous locale.
644
+ */
645
+ export function requestedInterfaceLocale(player) {
646
+ const locale = typeof player.locale === "string" ? player.locale.trim() : "";
647
+ if (locale)
648
+ return requestTag(locale);
649
+ return requestedLocaleOf(player.i18n);
650
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Pseudo-localization: a transform over the English catalog that keeps every key,
3
+ * plural category and `{placeholder}` intact while making each string accented,
4
+ * bracketed and padded roughly 40% longer.
5
+ *
6
+ * What it is for: the gates check parity, not rendered geometry. A pseudo-locale
7
+ * makes truncation, clipped labels, strings glued together in code and text that
8
+ * was never catalog text visible in any demo or e2e run, before a translator or a
9
+ * reviewer sees a real one. It is the industry's `qps-ploc` (Windows) and
10
+ * `en-XA`/`ar-XB` (Android) practice; PIE uses the Android tags, registered
11
+ * through `customMessages`, never shipped as catalogs.
12
+ */
13
+ import type { MessageCatalog } from "./types.js";
14
+ /** Expanded Latin pseudo-locale: text grows ~40% and carries accents and brackets. */
15
+ export declare const PSEUDO_LOCALE_EXPANDED = "en-XA";
16
+ /**
17
+ * Right-to-left pseudo-locale: the same transformed English under an `ar` primary
18
+ * subtag, so `localeDirection` resolves RTL and chrome lays out mirrored without a
19
+ * word of Arabic on screen.
20
+ */
21
+ export declare const PSEUDO_LOCALE_RTL = "ar-XB";
22
+ export interface PseudoLocalizeOptions {
23
+ /** Extra length as a fraction of the original, on top of what accents add. Default 0.4. */
24
+ expansion?: number;
25
+ /** Wrap every string in `[` and `]` so concatenated fragments stand out. Default true. */
26
+ brackets?: boolean;
27
+ /** Map Latin letters to accented look-alikes. Default true. */
28
+ accents?: boolean;
29
+ }
30
+ /**
31
+ * Produce the pseudo-localized twin of a catalog: same keys, same plural
32
+ * categories, same placeholder sets, longer accented bracketed strings.
33
+ */
34
+ export declare function pseudoLocalize(catalog: MessageCatalog, options?: PseudoLocalizeOptions): MessageCatalog;