@pie-players/pie-players-shared 1.0.0-beta.3 → 1.0.0-beta.5

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.
@@ -57,6 +57,8 @@ export declare class SimpleI18n implements I18nServiceApi {
57
57
  private catalogs;
58
58
  private customMessages;
59
59
  private loading;
60
+ /** Counts `setLocale` calls, so a slow load cannot overwrite a later request. */
61
+ private requests;
60
62
  private readonly listeners;
61
63
  private config;
62
64
  constructor(config?: I18nConfig);
@@ -75,7 +77,9 @@ export declare class SimpleI18n implements I18nServiceApi {
75
77
  * Resolves the request against the catalogs this provider can serve, loads
76
78
  * the winner if it is not resident, then notifies subscribers — in that
77
79
  * order, so a component's reactive read re-runs against a catalog that is
78
- * already in place rather than against the previous locale.
80
+ * already in place rather than against the previous locale. The last call
81
+ * wins: a call whose load finishes after a later call's leaves the locale
82
+ * to that later call.
79
83
  */
80
84
  setLocale(locale: LocaleCode): Promise<void>;
81
85
  t(key: MessageKeyInput, values?: InterpolationValues): string;
@@ -169,6 +173,25 @@ export declare function getDefaultI18n(): I18nServiceApi;
169
173
  * default rather than nothing, which is the graceful default the pull pattern
170
174
  * requires.
171
175
  */
176
+ /** The rendering surface `resolveInterfaceI18n` promises. */
177
+ export interface ResolvedInterfaceI18n extends I18nProvider {
178
+ /** Locale-aware number formatting, at the resolved locale. */
179
+ formatNumber(value: number, options?: Intl.NumberFormatOptions): string;
180
+ /** Locale-aware date formatting, at the resolved locale. */
181
+ formatDate(date: Date, options?: Intl.DateTimeFormatOptions): string;
182
+ }
172
183
  export declare function resolveInterfaceI18n(source: {
173
184
  i18n?: I18nProvider;
174
- } | null | undefined): I18nProvider;
185
+ } | null | undefined): ResolvedInterfaceI18n;
186
+ /**
187
+ * Stamp the interface locale's `lang` and `dir` on a chrome element.
188
+ *
189
+ * The imperative form of the `lang={…} dir={…}` pair a template puts on its
190
+ * root, for the elements a template cannot reach: a shadow host whose chrome
191
+ * is slotted light DOM, a shell built with `document.createElement`, a vanilla
192
+ * element stamping itself. Call it from whatever already re-runs on a locale
193
+ * change and on a catalog load: `getLocale()` moves only once the requested
194
+ * catalog is resident, so a stamp taken at setup alone names the locale that
195
+ * rendered before the load.
196
+ */
197
+ export declare function stampInterfaceLocale(target: Element, provider: Pick<I18nProvider, "getLocale" | "getDirection">): void;
@@ -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, findBestLanguageMatch, 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";
@@ -114,11 +114,14 @@ export class SimpleI18n {
114
114
  catalogs;
115
115
  customMessages;
116
116
  loading;
117
+ /** Counts `setLocale` calls, so a slow load cannot overwrite a later request. */
118
+ requests = 0;
117
119
  listeners = new Set();
118
120
  config;
119
121
  constructor(config = {}) {
120
122
  this.config = { ...config };
121
- this.fallbackLocale = toLanguageTag(config.fallbackLocale) || DEFAULT_LOCALE;
123
+ this.fallbackLocale =
124
+ toLanguageTag(config.fallbackLocale) || DEFAULT_LOCALE;
122
125
  this.catalogs = new Map([[DEFAULT_LOCALE, enUS]]);
123
126
  this.customMessages = { ...(config.customMessages ?? {}) };
124
127
  this.loading = new Map();
@@ -149,9 +152,12 @@ export class SimpleI18n {
149
152
  * Resolves the request against the catalogs this provider can serve, loads
150
153
  * the winner if it is not resident, then notifies subscribers — in that
151
154
  * order, so a component's reactive read re-runs against a catalog that is
152
- * already in place rather than against the previous locale.
155
+ * already in place rather than against the previous locale. The last call
156
+ * wins: a call whose load finishes after a later call's leaves the locale
157
+ * to that later call.
153
158
  */
154
159
  async setLocale(locale) {
160
+ const request = ++this.requests;
155
161
  const resolved = this.resolveLocaleTag(locale);
156
162
  if (!resolved) {
157
163
  // A locale we ship no catalog for is legitimate: the host may have
@@ -181,6 +187,8 @@ export class SimpleI18n {
181
187
  }
182
188
  }
183
189
  }
190
+ if (request !== this.requests)
191
+ return;
184
192
  this.locale = resolved;
185
193
  this.notify();
186
194
  }
@@ -190,7 +198,7 @@ export class SimpleI18n {
190
198
  this.config.onMissingKey?.(key, this.locale);
191
199
  return key;
192
200
  }
193
- return interpolate(message, values);
201
+ return interpolate(message, values, this.locale);
194
202
  }
195
203
  /**
196
204
  * Translate a plural group.
@@ -207,7 +215,7 @@ export class SimpleI18n {
207
215
  }
208
216
  const category = this.selectPluralCategory(options.count);
209
217
  const form = group[category] ?? group.other;
210
- return interpolate(form, options);
218
+ return interpolate(form, options, this.locale);
211
219
  }
212
220
  getDirection() {
213
221
  return localeDirection(this.locale);
@@ -389,12 +397,41 @@ function readPath(source, key) {
389
397
  }
390
398
  return current;
391
399
  }
392
- function interpolate(text, values) {
400
+ /**
401
+ * Substitute `{name}` placeholders.
402
+ *
403
+ * A finite numeric value renders through `Intl.NumberFormat` for the active
404
+ * locale, so digits and grouping follow CLDR locale defaults: `ar` renders
405
+ * Arabic-Indic digits, `fr` groups with narrow no-break spaces, `en-US` with
406
+ * commas. Catalog text that spells digits out is untouched.
407
+ */
408
+ const numberFormats = new Map();
409
+ function formatNumber(value, locale) {
410
+ let formatter = numberFormats.get(locale);
411
+ if (!formatter) {
412
+ try {
413
+ formatter = new Intl.NumberFormat(locale);
414
+ }
415
+ catch {
416
+ formatter = new Intl.NumberFormat(DEFAULT_LOCALE);
417
+ }
418
+ numberFormats.set(locale, formatter);
419
+ }
420
+ return formatter.format(value);
421
+ }
422
+ function interpolate(text, values, locale) {
393
423
  if (!values)
394
424
  return text;
395
425
  return text.replace(/\{(\w+)\}/g, (match, name) => {
396
426
  const value = values[name];
397
- return value === undefined || value === null ? match : String(value);
427
+ if (value === undefined || value === null)
428
+ return match;
429
+ if (locale !== undefined &&
430
+ typeof value === "number" &&
431
+ Number.isFinite(value)) {
432
+ return formatNumber(value, locale);
433
+ }
434
+ return String(value);
398
435
  });
399
436
  }
400
437
  function deepMerge(target, source) {
@@ -428,41 +465,51 @@ export function getDefaultI18n() {
428
465
  defaultProvider = new SimpleI18n();
429
466
  return defaultProvider;
430
467
  }
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
468
  export function resolveInterfaceI18n(source) {
455
469
  const provider = source?.i18n ?? getDefaultI18n();
456
470
  // A thin facade rather than the provider itself: cheap to allocate (a handful
457
471
  // 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.
472
+ // so it cannot drift from the provider it delegates to. The locale formatters
473
+ // are part of the promised surface, so a provider that does not implement
474
+ // them falls through to `Intl` at the locale the provider resolves — the same
475
+ // thing the bundled provider does, and the same graceful default every other
476
+ // optional member here applies.
459
477
  return {
460
- getLocale: () => provider.getLocale(),
478
+ // Canonical, because components read this for `lang` attributes; the
479
+ // provider's own `getLocale()` keeps the tag as resolved for lookups.
480
+ getLocale: () => {
481
+ const locale = provider.getLocale();
482
+ return canonicalLanguageTag(locale) ?? locale;
483
+ },
461
484
  setLocale: (locale) => provider.setLocale(locale),
462
485
  t: (key, values) => provider.t(key, values),
463
486
  plural: (key, options) => provider.plural?.(key, options) ?? provider.t(key, options),
464
487
  getDirection: () => provider.getDirection?.() ?? "ltr",
465
488
  hasKey: (key) => provider.hasKey?.(key) ?? false,
466
489
  subscribe: (listener) => provider.subscribe?.(listener) ?? (() => { }),
490
+ formatNumber: (value, options) => provider.formatNumber?.(value, options) ??
491
+ new Intl.NumberFormat(provider.getLocale(), options).format(value),
492
+ formatDate: (value, options) => provider.formatDate?.(value, options) ??
493
+ new Intl.DateTimeFormat(provider.getLocale(), options).format(value),
467
494
  };
468
495
  }
496
+ /**
497
+ * Stamp the interface locale's `lang` and `dir` on a chrome element.
498
+ *
499
+ * The imperative form of the `lang={…} dir={…}` pair a template puts on its
500
+ * root, for the elements a template cannot reach: a shadow host whose chrome
501
+ * is slotted light DOM, a shell built with `document.createElement`, a vanilla
502
+ * element stamping itself. Call it from whatever already re-runs on a locale
503
+ * change and on a catalog load: `getLocale()` moves only once the requested
504
+ * catalog is resident, so a stamp taken at setup alone names the locale that
505
+ * rendered before the load.
506
+ */
507
+ export function stampInterfaceLocale(target, provider) {
508
+ // `lang` takes the canonical form: a provider honours an unshipped tag as
509
+ // the host spelled it, and `NL-nl` is a valid request but not a valid
510
+ // attribute value. Every `lang` this repository writes goes through the
511
+ // same rule, so chrome and content stamps match in shape.
512
+ const locale = provider.getLocale();
513
+ target.setAttribute("lang", canonicalLanguageTag(locale) ?? locale);
514
+ target.setAttribute("dir", provider.getDirection?.() ?? "ltr");
515
+ }
@@ -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;
@@ -0,0 +1,106 @@
1
+ /** Expanded Latin pseudo-locale: text grows ~40% and carries accents and brackets. */
2
+ export const PSEUDO_LOCALE_EXPANDED = "en-XA";
3
+ /**
4
+ * Right-to-left pseudo-locale: the same transformed English under an `ar` primary
5
+ * subtag, so `localeDirection` resolves RTL and chrome lays out mirrored without a
6
+ * word of Arabic on screen.
7
+ */
8
+ export const PSEUDO_LOCALE_RTL = "ar-XB";
9
+ const ACCENTS = {
10
+ a: "á",
11
+ A: "Á",
12
+ b: "ƀ",
13
+ B: "Ƀ",
14
+ c: "ç",
15
+ C: "Ç",
16
+ d: "ð",
17
+ D: "Ð",
18
+ e: "é",
19
+ E: "É",
20
+ f: "ƒ",
21
+ F: "Ƒ",
22
+ g: "ğ",
23
+ G: "Ğ",
24
+ h: "ĥ",
25
+ H: "Ĥ",
26
+ i: "í",
27
+ I: "Í",
28
+ j: "ĵ",
29
+ J: "Ĵ",
30
+ k: "ķ",
31
+ K: "Ķ",
32
+ l: "ĺ",
33
+ L: "Ĺ",
34
+ m: "ḿ",
35
+ M: "Ḿ",
36
+ n: "ñ",
37
+ N: "Ñ",
38
+ o: "ó",
39
+ O: "Ó",
40
+ p: "þ",
41
+ P: "Þ",
42
+ q: "ǫ",
43
+ Q: "Ǫ",
44
+ r: "ŕ",
45
+ R: "Ŕ",
46
+ s: "š",
47
+ S: "Š",
48
+ t: "ŧ",
49
+ T: "Ŧ",
50
+ u: "ú",
51
+ U: "Ú",
52
+ v: "ṽ",
53
+ V: "Ṽ",
54
+ w: "ŵ",
55
+ W: "Ŵ",
56
+ x: "ẋ",
57
+ X: "Ẋ",
58
+ y: "ý",
59
+ Y: "Ý",
60
+ z: "ž",
61
+ Z: "Ž",
62
+ };
63
+ const PLACEHOLDER = /(\{\w+\})/g;
64
+ function pseudoText(text, { expansion, accents, brackets }) {
65
+ const segments = text.split(PLACEHOLDER);
66
+ const transformed = segments.map((segment, index) => {
67
+ // A capturing split leaves each placeholder at an odd index, verbatim.
68
+ if (index % 2 === 1 || segment === "")
69
+ return segment;
70
+ let out = segment;
71
+ if (accents) {
72
+ out = out.replace(/[a-zA-Z]/g, (letter) => ACCENTS[letter] ?? letter);
73
+ }
74
+ if (expansion <= 0)
75
+ return out;
76
+ const padding = Math.max(1, Math.ceil(segment.length * expansion));
77
+ return `${out} ${"~".repeat(padding)}`;
78
+ });
79
+ const joined = transformed.join("");
80
+ return brackets ? `[${joined}]` : joined;
81
+ }
82
+ /**
83
+ * Produce the pseudo-localized twin of a catalog: same keys, same plural
84
+ * categories, same placeholder sets, longer accented bracketed strings.
85
+ */
86
+ export function pseudoLocalize(catalog, options) {
87
+ const settings = {
88
+ expansion: options?.expansion ?? 0.4,
89
+ brackets: options?.brackets ?? true,
90
+ accents: options?.accents ?? true,
91
+ };
92
+ const result = {};
93
+ for (const [key, node] of Object.entries(catalog)) {
94
+ result[key] = pseudoNode(node, settings);
95
+ }
96
+ return result;
97
+ }
98
+ function pseudoNode(node, settings) {
99
+ if (typeof node === "string")
100
+ return pseudoText(node, settings);
101
+ const branch = {};
102
+ for (const [key, child] of Object.entries(node)) {
103
+ branch[key] = pseudoNode(child, settings);
104
+ }
105
+ return branch;
106
+ }
@@ -85,4 +85,14 @@ export declare function resolveEsmAssetRoot(options: {
85
85
  cdnBaseUrl: string;
86
86
  cdnProvider?: EsmCdnProviderOption;
87
87
  }): string | undefined;
88
+ /**
89
+ * URL of a raw npm package file, `file` being `<package>@<version>/<path>`, on
90
+ * the ESM CDN a player's `loaderOptions` name, so a page asset the players add,
91
+ * such as a stylesheet, comes from where the elements do. Reads untyped options
92
+ * as a toolkit runtime context carries them; jsDelivr when they set no base.
93
+ */
94
+ export declare function resolveEsmPackageFileUrl(loaderOptions: {
95
+ esmCdnUrl?: unknown;
96
+ esmCdnProvider?: unknown;
97
+ } | null | undefined, file: string): string;
88
98
  export declare function pickElementClass(module: any, view: string): unknown;
@@ -19,6 +19,7 @@ import { writeRegistryEntry } from "../pie/registry.js";
19
19
  import { validateCustomElementTag } from "../pie/tag-names.js";
20
20
  import { BundleType, isCustomElementConstructor, Status, } from "../pie/types.js";
21
21
  import { parsePackageName } from "../pie/utils.js";
22
+ import { DEFAULT_ESM_CDN_URL } from "./defaults.js";
22
23
  import { AdapterFailure, } from "./element-loader-types.js";
23
24
  import { isExactSemver } from "./element-package-policy.js";
24
25
  import { invalidPackageSpecMessage, isScopedPackageSpec, } from "./element-package-spec.js";
@@ -141,8 +142,11 @@ export function createEsmBackend(config) {
141
142
  : undefined;
142
143
  const imports = withoutMappedSpecifiers({ ...importMapResult.imports, ...runtimePlan?.imports }, context.doc);
143
144
  let injectedMap;
145
+ let map;
144
146
  if (Object.keys(imports).length > 0) {
145
- const json = JSON.stringify({ imports }, null, 2);
147
+ const scopes = withoutMappedScopes(jsDelivrRootPathScopes(cdnProvider, cdnBaseUrl, context.doc), context.doc);
148
+ map = scopes ? { imports, scopes } : { imports };
149
+ const json = JSON.stringify(map, null, 2);
146
150
  // URL resolution maps the shared dependencies and the editor
147
151
  // runtime, so it needs import maps too.
148
152
  assertImportMapSupported();
@@ -162,8 +166,8 @@ export function createEsmBackend(config) {
162
166
  importMappedPackageVersions.set(parsePackageName(pkg).name, pkg);
163
167
  }
164
168
  }
165
- if (injectedMap) {
166
- await applyImportMap(imports, injectedMap, context.doc);
169
+ if (injectedMap && map) {
170
+ await applyImportMap(map, injectedMap, context.doc);
167
171
  }
168
172
  }
169
173
  await Promise.all(Object.entries(loadable).map(async ([tag, packageVersion]) => {
@@ -339,13 +343,13 @@ export function createEsmBackend(config) {
339
343
  * document that already uses it, and otherwise the browser has either
340
344
  * applied it or rejected it.
341
345
  */
342
- async function applyImportMap(imports, script, doc) {
346
+ async function applyImportMap(map, script, doc) {
343
347
  let state = "applied";
344
348
  try {
345
349
  if (script.type === SHIM_IMPORT_MAP_TYPE) {
346
- (await importShim(doc)).addImportMap({ imports });
350
+ (await importShim(doc)).addImportMap(map);
347
351
  }
348
- else if (await importMapRejected(imports)) {
352
+ else if (await importMapRejected(map.imports)) {
349
353
  state = "rejected";
350
354
  await importShim(doc);
351
355
  }
@@ -384,7 +388,7 @@ export function createEsmBackend(config) {
384
388
  */
385
389
  function importShim(doc) {
386
390
  importShimRequest ??= importShimLoader().then((shim) => {
387
- shim.addImportMap({ imports: documentImports(doc) });
391
+ shim.addImportMap(documentImportMap(doc));
388
392
  return shim;
389
393
  }, (err) => {
390
394
  importShimRequest = undefined;
@@ -661,6 +665,24 @@ export function resolveEsmAssetRoot(options) {
661
665
  ? packageJsonUrl.slice(0, -suffix.length) || undefined
662
666
  : undefined;
663
667
  }
668
+ /**
669
+ * URL of a raw npm package file, `file` being `<package>@<version>/<path>`, on
670
+ * the ESM CDN a player's `loaderOptions` name, so a page asset the players add,
671
+ * such as a stylesheet, comes from where the elements do. Reads untyped options
672
+ * as a toolkit runtime context carries them; jsDelivr when they set no base.
673
+ */
674
+ export function resolveEsmPackageFileUrl(loaderOptions, file) {
675
+ const configured = loaderOptions?.esmCdnUrl;
676
+ const cdnBaseUrl = (typeof configured === "string" && configured.trim()
677
+ ? configured.trim()
678
+ : DEFAULT_ESM_CDN_URL).replace(/\/+$/, "");
679
+ const provider = loaderOptions?.esmCdnProvider;
680
+ const cdnProvider = typeof provider === "string" || isEsmCdnProvider(provider)
681
+ ? provider
682
+ : undefined;
683
+ const root = resolveEsmAssetRoot({ cdnBaseUrl, cdnProvider }) ?? cdnBaseUrl;
684
+ return `${root}/${file.replace(/^\/+/, "")}`;
685
+ }
664
686
  function resolveCdnProvider(provider, cdnBaseUrl) {
665
687
  if (isEsmCdnProvider(provider))
666
688
  return provider;
@@ -681,7 +703,7 @@ function isEsmCdnProvider(value) {
681
703
  typeof candidate.sharedDependencyUrl === "function");
682
704
  }
683
705
  function createJsDelivrProvider(cdnBaseUrl, name = "jsdelivr") {
684
- return {
706
+ const provider = {
685
707
  name,
686
708
  packageJsonUrl: (packageVersion) => `${cdnBaseUrl}/${packageVersion}/package.json`,
687
709
  browserViewUrl: (packageVersion, view) => `${cdnBaseUrl}/${packageVersion}/dist/browser/${view}/index.js`,
@@ -692,7 +714,31 @@ function createJsDelivrProvider(cdnBaseUrl, name = "jsdelivr") {
692
714
  return `${cdnBaseUrl}/${dependencyName}@${version}${suffix}`;
693
715
  },
694
716
  };
717
+ jsDelivrShapedProviders.add(provider);
718
+ return provider;
695
719
  }
720
+ /**
721
+ * jsDelivr's `+esm` builds import their dependencies by root path
722
+ * (`/npm/react@18.3.1/+esm`), which resolves against the origin that served
723
+ * them. A base mounted anywhere but `/npm` gets a scope mapping those paths
724
+ * back under it, so they load from the configured CDN and resolve to the URLs
725
+ * the shared-dependency entries map, keeping one React on the page.
726
+ */
727
+ function jsDelivrRootPathScopes(cdnProvider, cdnBaseUrl, doc) {
728
+ if (!jsDelivrShapedProviders.has(cdnProvider))
729
+ return undefined;
730
+ let base;
731
+ try {
732
+ base = new URL(`${cdnBaseUrl}/`, doc.baseURI || undefined);
733
+ }
734
+ catch {
735
+ return undefined;
736
+ }
737
+ const root = new URL("/npm/", base).href;
738
+ return root === base.href ? undefined : { [base.href]: { [root]: base.href } };
739
+ }
740
+ /** Providers built by {@link createJsDelivrProvider}, whatever their name. */
741
+ const jsDelivrShapedProviders = new WeakSet();
696
742
  function createEsmShProvider(cdnBaseUrl) {
697
743
  const esmBaseUrl = cdnBaseUrl.replace(/\/+$/, "");
698
744
  const rawBaseUrl = toRawEsmShBaseUrl(esmBaseUrl);
@@ -1050,23 +1096,50 @@ async function importMapsSettled(doc) {
1050
1096
  * for a specifier as it does in the browser.
1051
1097
  */
1052
1098
  function documentImports(doc) {
1099
+ return documentImportMap(doc).imports;
1100
+ }
1101
+ /** {@link documentImports}, with the scopes the maps define. */
1102
+ function documentImportMap(doc) {
1053
1103
  const imports = Object.create(null);
1104
+ const scopes = Object.create(null);
1054
1105
  for (const script of importMapScripts(doc)) {
1055
1106
  try {
1056
- const mapped = JSON.parse(script.textContent || "{}")?.imports;
1057
- if (!mapped || typeof mapped !== "object")
1107
+ const map = JSON.parse(script.textContent || "{}");
1108
+ addFirstRules(imports, map?.imports);
1109
+ if (!map?.scopes || typeof map.scopes !== "object")
1058
1110
  continue;
1059
- for (const [specifier, url] of Object.entries(mapped)) {
1060
- if (typeof url === "string" && !(specifier in imports)) {
1061
- imports[specifier] = url;
1062
- }
1111
+ for (const [scope, rules] of Object.entries(map.scopes)) {
1112
+ scopes[scope] ??= Object.create(null);
1113
+ addFirstRules(scopes[scope], rules);
1063
1114
  }
1064
1115
  }
1065
1116
  catch {
1066
1117
  // The browser rejects a map it cannot parse, so it maps nothing.
1067
1118
  }
1068
1119
  }
1069
- return imports;
1120
+ return { imports, scopes };
1121
+ }
1122
+ function addFirstRules(target, rules) {
1123
+ if (!rules || typeof rules !== "object")
1124
+ return;
1125
+ for (const [specifier, url] of Object.entries(rules)) {
1126
+ if (typeof url === "string" && !(specifier in target)) {
1127
+ target[specifier] = url;
1128
+ }
1129
+ }
1130
+ }
1131
+ /** {@link withoutMappedSpecifiers} for scope rules; `undefined` when none remain. */
1132
+ function withoutMappedScopes(scopes, doc) {
1133
+ if (!scopes)
1134
+ return undefined;
1135
+ const mapped = documentImportMap(doc).scopes;
1136
+ const remaining = {};
1137
+ for (const [scope, rules] of Object.entries(scopes)) {
1138
+ const kept = Object.entries(rules).filter(([specifier]) => !(specifier in (mapped[scope] ?? {})));
1139
+ if (kept.length > 0)
1140
+ remaining[scope] = Object.fromEntries(kept);
1141
+ }
1142
+ return Object.keys(remaining).length > 0 ? remaining : undefined;
1070
1143
  }
1071
1144
  /**
1072
1145
  * es-module-shims in shim mode: it resolves through the import maps this
@@ -38,7 +38,7 @@ export { isScopedPackageSpec } from "./element-package-spec.js";
38
38
  export type { BackendContext, BackendOption, ElementLoaderBackend, ElementTag, EnsureRegisteredOptions, EsmBackendConfig, IifeBackendConfig, RegistrationFailureReason, } from "./element-loader.js";
39
39
  export { AdapterFailure, ElementAssertionError, ElementLoaderError, assertRegistered, describeRegistrationFailures, ensureRegistered, } from "./element-loader.js";
40
40
  export type { BuiltInEsmCdnProviderName, EsmBackend, EsmCdnProvider, EsmCdnProviderName, EsmCdnProviderOption, ViewConfig, } from "./esm-adapter.js";
41
- export { BUILT_IN_VIEWS, createEsmBackend, mapEsmViewElements, resolveEsmAssetRoot, resolveEsmRuntimeSupportUrl, } from "./esm-adapter.js";
41
+ export { BUILT_IN_VIEWS, createEsmBackend, mapEsmViewElements, resolveEsmAssetRoot, resolveEsmPackageFileUrl, resolveEsmRuntimeSupportUrl, } from "./esm-adapter.js";
42
42
  export type { IifeBackend, IifeBundleRetryStatus, } from "./iife-adapter.js";
43
43
  export { forwardMathjaxEvents, MATHJAX_NO_ASSET_ROOT_EVENT, MATHJAX_VERSION_CONFLICT_EVENT, } from "./mathjax-events.js";
44
44
  export { DEFAULT_BUNDLE_HOST, DEFAULT_ESM_CDN_URL } from "./defaults.js";
@@ -34,7 +34,7 @@ export { aggregateElements } from "./ElementLoader.js";
34
34
  export { assertElementPackagesAllowed, ElementPackagePolicyError, } from "./element-package-policy.js";
35
35
  export { isScopedPackageSpec } from "./element-package-spec.js";
36
36
  export { AdapterFailure, ElementAssertionError, ElementLoaderError, assertRegistered, describeRegistrationFailures, ensureRegistered, } from "./element-loader.js";
37
- export { BUILT_IN_VIEWS, createEsmBackend, mapEsmViewElements, resolveEsmAssetRoot, resolveEsmRuntimeSupportUrl, } from "./esm-adapter.js";
37
+ export { BUILT_IN_VIEWS, createEsmBackend, mapEsmViewElements, resolveEsmAssetRoot, resolveEsmPackageFileUrl, resolveEsmRuntimeSupportUrl, } from "./esm-adapter.js";
38
38
  export { forwardMathjaxEvents, MATHJAX_NO_ASSET_ROOT_EVENT, MATHJAX_VERSION_CONFLICT_EVENT, } from "./mathjax-events.js";
39
39
  export { DEFAULT_BUNDLE_HOST, DEFAULT_ESM_CDN_URL } from "./defaults.js";
40
40
  export { createIifeBackend } from "./iife-adapter.js";
@@ -6,7 +6,10 @@
6
6
  */
7
7
  export declare const mergeObjectsIgnoringNullUndefined: <T extends object>(...objects: T[]) => T;
8
8
  /**
9
- * Deep clone an object.
9
+ * Deep clone of arrays and plain objects. Any other object — a `Date`, a bson
10
+ * `ObjectId`, a class instance — is carried over by reference: copying its own
11
+ * keys onto a plain object would drop its prototype, and with it the methods
12
+ * that identify and serialise it.
10
13
  */
11
14
  export declare const cloneDeep: <T>(value: T) => T;
12
15
  /**
@@ -17,7 +17,10 @@ export const mergeObjectsIgnoringNullUndefined = (...objects) => {
17
17
  }, {});
18
18
  };
19
19
  /**
20
- * Deep clone an object.
20
+ * Deep clone of arrays and plain objects. Any other object — a `Date`, a bson
21
+ * `ObjectId`, a class instance — is carried over by reference: copying its own
22
+ * keys onto a plain object would drop its prototype, and with it the methods
23
+ * that identify and serialise it.
21
24
  */
22
25
  export const cloneDeep = (value) => {
23
26
  if (value === null || typeof value !== "object") {
@@ -26,6 +29,10 @@ export const cloneDeep = (value) => {
26
29
  if (Array.isArray(value)) {
27
30
  return value.map(cloneDeep);
28
31
  }
32
+ const prototype = Object.getPrototypeOf(value);
33
+ if (prototype !== Object.prototype && prototype !== null) {
34
+ return value;
35
+ }
29
36
  const copy = {};
30
37
  for (const key in value) {
31
38
  if (Object.hasOwn(value, key)) {
@@ -15,6 +15,7 @@ export type { PieElementContext, PieElementContextSource, } from "./element-obse
15
15
  export { createAuthoringAssetEventManager, initializeAuthoringConfigures, resolveConfigureConfiguration, validateAuthoringModels, } from "./authoring.js";
16
16
  export type { AuthoringMediaHandlers, AuthoringValidationResult, InitializedConfigureModel, } from "./authoring.js";
17
17
  export { initializeMathRendering, setMathRenderer, } from "./math-rendering.js";
18
+ export type { MathRenderingOptions } from "./math-rendering.js";
18
19
  export { attachInstrumentationEventBridge } from "./instrumentation-event-bridge.js";
19
20
  export type { AttachInstrumentationEventBridgeArgs } from "./instrumentation-event-bridge.js";
20
21
  export { resolveInstrumentationProvider } from "./instrumentation-provider-resolution.js";
@@ -13,6 +13,16 @@ export interface MathRenderingAPI {
13
13
  unWrapMath?: (wrapped: string) => string;
14
14
  mmlToLatex?: (mathml: string) => string;
15
15
  }
16
+ /** How the default renderer `initializeMathRendering` installs loads its assets. */
17
+ export interface MathRenderingOptions {
18
+ /**
19
+ * The npm root the renderer's speech rules load from when the page's math
20
+ * options set no `assetRoot`. With neither, jsDelivr. A page that names the
21
+ * rules' directory itself, in `SREfeature.json` or a `text/x-sre-config`
22
+ * block, keeps it.
23
+ */
24
+ assetRoot?: string;
25
+ }
16
26
  /**
17
27
  * The page's renderer: the one `initializeMathRendering` installed, or a
18
28
  * host's. IIFE elements render with it, and so do ESM elements when the page
@@ -27,6 +37,8 @@ export declare const getMathRenderer: () => MathRenderingAPI | null;
27
37
  * - window["@pie-lib/math-rendering"] (standard key)
28
38
  * - window["_dll_pie_lib__math_rendering"] (SystemJS/DLL key for IIFE bundles)
29
39
  *
40
+ * The first call installs the renderer for the page, so its `options` apply.
41
+ *
30
42
  * @example
31
43
  * ```typescript
32
44
  * // Default MathJax
@@ -37,7 +49,7 @@ export declare const getMathRenderer: () => MathRenderingAPI | null;
37
49
  * await initializeMathRendering();
38
50
  * ```
39
51
  */
40
- export declare function initializeMathRendering(): Promise<void>;
52
+ export declare function initializeMathRendering(options?: MathRenderingOptions): Promise<void>;
41
53
  /**
42
54
  * Set custom math renderer programmatically
43
55
  *