@pie-players/pie-players-shared 0.3.69 → 0.3.71

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 (41) hide show
  1. package/dist/i18n/messages/en-US.d.ts +2 -0
  2. package/dist/i18n/messages/en-US.js +2 -0
  3. package/dist/i18n/messages/nl-NL.d.ts +2 -0
  4. package/dist/i18n/messages/nl-NL.js +2 -0
  5. package/dist/index.d.ts +1 -0
  6. package/dist/index.js +1 -0
  7. package/dist/loaders/iife-adapter.js +7 -3
  8. package/dist/pie/element-observer.d.ts +59 -0
  9. package/dist/pie/element-observer.js +131 -0
  10. package/dist/pie/index.d.ts +4 -2
  11. package/dist/pie/index.js +7 -2
  12. package/dist/pie/initialization.d.ts +13 -13
  13. package/dist/pie/initialization.js +97 -147
  14. package/dist/pie/initialize-element.d.ts +23 -0
  15. package/dist/pie/initialize-element.js +78 -0
  16. package/dist/pie/instrumentation-event-map.d.ts +1 -0
  17. package/dist/pie/instrumentation-event-map.js +18 -0
  18. package/dist/pie/math-rendering.js +6 -2
  19. package/dist/pie/types.d.ts +10 -0
  20. package/dist/pie/utils.d.ts +27 -0
  21. package/dist/pie/utils.js +56 -1
  22. package/dist/security/index.d.ts +3 -2
  23. package/dist/security/index.js +3 -2
  24. package/dist/security/sanitize-forbidden-lists.js +9 -0
  25. package/dist/security/sanitize-item-markup.js +4 -0
  26. package/dist/security/sanitize-style-attribute.d.ts +48 -0
  27. package/dist/security/sanitize-style-attribute.js +129 -0
  28. package/dist/security/sanitize-svg-icon.js +2 -0
  29. package/dist/security/validate-style-url.d.ts +13 -0
  30. package/dist/security/validate-style-url.js +36 -2
  31. package/dist/security/wrap-overwide-images.d.ts +7 -0
  32. package/dist/security/wrap-overwide-images.js +10 -1
  33. package/dist/security/wrap-overwide-tables.d.ts +7 -0
  34. package/dist/security/wrap-overwide-tables.js +10 -1
  35. package/dist/security/wrap-overwide.d.ts +16 -0
  36. package/dist/security/wrap-overwide.js +52 -0
  37. package/dist/ui/overlay-containment.d.ts +48 -0
  38. package/dist/ui/overlay-containment.js +65 -0
  39. package/package.json +6 -10
  40. package/dist/ui/zoom-compensation.d.ts +0 -44
  41. package/dist/ui/zoom-compensation.js +0 -43
@@ -5,84 +5,33 @@
5
5
  * This is the core of the PIE player system.
6
6
  */
7
7
  import { BUILDER_BUNDLE_URL } from "../config/profile.js";
8
+ import { DEFAULT_IIFE_BUNDLE_RETRY_CONFIG } from "../loader-config.js";
8
9
  import { mergeObjectsIgnoringNullUndefined } from "../object/index.js";
9
- import { wrapModelRichContent } from "../security/wrap-model-rich-content.js";
10
10
  import { editorPostFix } from "../types/index.js";
11
+ import { initializePieElement } from "./initialize-element.js";
11
12
  import { createPieLogger, isGlobalDebugEnabled } from "./logger.js";
12
13
  import { initializeMathRendering } from "./math-rendering.js";
13
14
  import { pieRegistry } from "./registry.js";
14
- import { findPieController } from "./scoring.js";
15
15
  import { defineCustomElementSafely } from "./custom-element-define.js";
16
16
  import { validateCustomElementTag } from "./tag-names.js";
17
17
  import { BundleType, isCustomElementConstructor, isPieAvailable, Status, } from "./types.js";
18
18
  import { updatePieElement } from "./updates.js";
19
- import { findOrAddSession, getPackageWithoutVersion, getPieElementBundlesUrl, } from "./utils.js";
19
+ import { getPackageWithoutVersion, getPieElementBundlesUrl, } from "./utils.js";
20
20
  // Create module-level logger (respects global debug flag - pass function for dynamic checking)
21
21
  const logger = createPieLogger("pie-initialization", () => isGlobalDebugEnabled());
22
+ /**
23
+ * Deadline for `loadPieModule`'s bundle `<script>` load when the caller
24
+ * sets no `loadTimeoutMs`. Shared with the `ElementLoader` primitive's
25
+ * `DEFAULT_LOAD_TIMEOUT_MS` so a bundle gets the same budget whichever
26
+ * path a host loads it through.
27
+ */
28
+ const DEFAULT_LOAD_TIMEOUT_MS = DEFAULT_IIFE_BUNDLE_RETRY_CONFIG.timeoutMs;
22
29
  // Default options for loading PIE elements
23
30
  const defaultOptions = {
24
31
  buildServiceBase: BUILDER_BUNDLE_URL,
25
32
  bundleType: BundleType.player, // Default to player.js (no controllers, server-processed models)
26
33
  env: { mode: "gather", role: "student" },
27
34
  };
28
- /**
29
- * Helper function to initialize a PIE element
30
- */
31
- const initializePieElement = (element, options) => {
32
- const { config, session, env, eventListeners } = options;
33
- if (element.__pieInitialized) {
34
- return;
35
- }
36
- const tagName = element.tagName.toLowerCase();
37
- logger.debug(`[initializePieElement] Initializing ${tagName}#${element.id}`);
38
- // Find model for this element
39
- let model = config?.models?.find((m) => m.id === element.id);
40
- if (!model) {
41
- // Only warn if this element is from a client-player.js bundle (where models are expected)
42
- // player.js bundles use server-processed models, so missing models are expected there
43
- const registry = pieRegistry();
44
- const registryEntry = registry[tagName];
45
- if (registryEntry && registryEntry.bundleType === BundleType.clientPlayer) {
46
- logger.warn(`[initializePieElement] Model not found for PIE element ${tagName}#${element.id} (client-player.js bundle)`);
47
- }
48
- return;
49
- }
50
- // Set session (with element property for updateSession callback)
51
- const elementSession = findOrAddSession(session, model.id, model.element);
52
- element.session = elementSession;
53
- element.__pieInitialized = true;
54
- logger.debug(`[initializePieElement] Session set for ${tagName}#${element.id}:`, elementSession);
55
- // Set model - use controller if available (client-player.js), or use server-processed model (player.js)
56
- const controller = findPieController(tagName);
57
- if (!env) {
58
- logger.error(`[initializePieElement] ❌ FATAL: No env provided for ${tagName}`);
59
- throw new Error(`No env provided for ${tagName}. PIE elements require an env object with mode and role.`);
60
- }
61
- if (!controller) {
62
- // No controller available - using server-processed model (player.js bundle)
63
- logger.debug(`[initializePieElement] ℹ️ No controller for ${tagName}, using server-processed model`);
64
- logger.debug(`[initializePieElement] Model already processed by server:`, {
65
- id: model.id,
66
- element: model.element,
67
- hasCorrectResponse: "correctResponse" in model,
68
- mode: env.mode,
69
- role: env.role,
70
- });
71
- // Set model directly - server already processed it
72
- element.model = wrapModelRichContent(model);
73
- }
74
- else {
75
- // Controller available - run client-side processing (client-player.js bundle)
76
- // Note: updatePieElementWithRef handles controller invocation
77
- logger.debug(`[initializePieElement] Controller found for ${tagName}, will invoke model() function`);
78
- }
79
- // Add event listeners
80
- if (eventListeners) {
81
- Object.entries(eventListeners).forEach(([evt, fn]) => {
82
- element.addEventListener(evt, fn);
83
- });
84
- }
85
- };
86
35
  const getEditorElementTagName = (elementTagName, pkg) => validateCustomElementTag(elementTagName + editorPostFix, `editor element tag for ${pkg}`);
87
36
  const updateRegisteredElement = (elementTagName, config, session, options, omitEnv = false) => {
88
37
  updatePieElement(elementTagName, {
@@ -98,7 +47,10 @@ const updateRegisteredElement = (elementTagName, config, session, options, omitE
98
47
  /**
99
48
  * Shared element registration logic
100
49
  * Extracted from initializePiesFromLoadedBundle and loadPieModule to eliminate ~200 lines of duplication
101
- * Also fixes MutationObserver memory leak by storing latest config/session in window context
50
+ *
51
+ * Binds the elements already present in `options.container`. Elements that
52
+ * arrive later are bound by the container owner's observer — see
53
+ * `element-observer.ts`.
102
54
  *
103
55
  * `elementModule` may be `null`. In that case we cannot register *new*
104
56
  * tags (no element constructor source), but we can still update tags
@@ -111,11 +63,6 @@ const updateRegisteredElement = (elementTagName, config, session, options, omitE
111
63
  */
112
64
  const registerPieElementsFromBundle = (elementModule, config, session, registry, options) => {
113
65
  const promises = [];
114
- const isNodeWithinContainer = (node, container) => {
115
- if (!container || container === document)
116
- return true;
117
- return node instanceof Node && container.contains(node);
118
- };
119
66
  if (elementModule) {
120
67
  logger.debug("[registerPieElementsFromBundle] Available packages in bundle:", Object.keys(elementModule));
121
68
  }
@@ -123,15 +70,6 @@ const registerPieElementsFromBundle = (elementModule, config, session, registry,
123
70
  logger.debug("[registerPieElementsFromBundle] No bundle module supplied; will only update tags already registered with customElements.");
124
71
  }
125
72
  logger.debug("[registerPieElementsFromBundle] config.elements:", config.elements);
126
- // Store latest config/session in window so MutationObserver can access current values
127
- if (typeof window !== "undefined") {
128
- window._pieCurrentContext = {
129
- config,
130
- session,
131
- env: options.env,
132
- container: options.container,
133
- };
134
- }
135
73
  Object.entries(config.elements).forEach(([elName, pkg]) => {
136
74
  const elementTagName = validateCustomElementTag(elName, `element tag in config.elements for ${String(pkg)}`);
137
75
  logger.debug(`[registerPieElementsFromBundle] Processing element: ${elementTagName} -> ${pkg}`);
@@ -227,58 +165,6 @@ const registerPieElementsFromBundle = (elementModule, config, session, registry,
227
165
  promises.push(customElements.whenDefined(elementTagName).then(() => {
228
166
  logger.debug("[registerPieElementsFromBundle] defined custom PIE element: %s", elementTagName);
229
167
  }));
230
- // Setup MutationObserver that uses current context (only once)
231
- if (!window._pieElementObserver) {
232
- window._pieElementObserver = new MutationObserver((mutations) => {
233
- // Use current context from window instead of stale closure
234
- const context = window._pieCurrentContext;
235
- if (!context) {
236
- logger.warn("[MutationObserver] No current context available");
237
- return;
238
- }
239
- mutations.forEach((mutation) => {
240
- if (mutation.type === "childList") {
241
- mutation.addedNodes.forEach((node) => {
242
- if (node.nodeType === Node.ELEMENT_NODE) {
243
- if (!isNodeWithinContainer(node, context.container)) {
244
- return;
245
- }
246
- const tagName = node.tagName.toLowerCase();
247
- if (registry[tagName]) {
248
- initializePieElement(node, {
249
- config: context.config,
250
- session: context.session,
251
- env: context.env,
252
- eventListeners: options.eventListeners?.[tagName],
253
- });
254
- }
255
- // Check children of added nodes
256
- node
257
- .querySelectorAll("*")
258
- .forEach((childNode) => {
259
- if (!isNodeWithinContainer(childNode, context.container)) {
260
- return;
261
- }
262
- const childTagName = childNode.tagName.toLowerCase();
263
- if (registry[childTagName]) {
264
- initializePieElement(childNode, {
265
- config: context.config,
266
- session: context.session,
267
- env: context.env,
268
- eventListeners: options.eventListeners?.[childTagName],
269
- });
270
- }
271
- });
272
- }
273
- });
274
- }
275
- });
276
- });
277
- window._pieElementObserver.observe(document.body, {
278
- childList: true,
279
- subtree: true,
280
- });
281
- }
282
168
  // Handle editor elements if needed
283
169
  if (options.bundleType === BundleType.editor) {
284
170
  if (isCustomElementConstructor(elementData.Configure)) {
@@ -327,6 +213,10 @@ const registerPieElementsFromBundle = (elementModule, config, session, registry,
327
213
  * (bundle not loaded by *anyone*) still surface — every unregistered tag
328
214
  * gets its own warning, and `updatePieElements` later reports any tag
329
215
  * that never resolves.
216
+ *
217
+ * Binds the elements present in `opts.container` now. Elements that arrive
218
+ * later are the container owner's concern: a player with a lifecycle calls
219
+ * `observePieElements` and releases it on teardown.
330
220
  */
331
221
  export const initializePiesFromLoadedBundle = (config, session, opts = {}) => {
332
222
  const registry = pieRegistry();
@@ -341,7 +231,14 @@ export const initializePiesFromLoadedBundle = (config, session, opts = {}) => {
341
231
  registerPieElementsFromBundle(null, config, session, registry, options);
342
232
  };
343
233
  /**
344
- * Load a PIE bundle from a URL and initialize elements
234
+ * Load a PIE bundle from a URL and initialize elements.
235
+ *
236
+ * Rejects — rather than hanging or throwing on the window — for every way
237
+ * the load can fail: the `error` event (404, blocked request, CSP refusal),
238
+ * the `loadTimeoutMs` deadline (a stalled request that never fires either
239
+ * event), a bundle whose script ran without populating `window.pie`, and a
240
+ * throw out of registration. Every rejection names the bundle URL and drops
241
+ * the injected `<script>`.
345
242
  */
346
243
  export const loadPieModule = async (config, session, opts = {}) => {
347
244
  if (!session) {
@@ -352,31 +249,84 @@ export const loadPieModule = async (config, session, opts = {}) => {
352
249
  const registry = pieRegistry();
353
250
  const options = mergeObjectsIgnoringNullUndefined(defaultOptions, opts);
354
251
  const url = opts.bundleUrl || getPieElementBundlesUrl(config, options);
252
+ const loadTimeoutMs = options.loadTimeoutMs ?? DEFAULT_LOAD_TIMEOUT_MS;
355
253
  const script = document.createElement("script");
356
254
  script.src = url;
357
255
  script.defer = true;
358
- script.onerror = () => {
359
- throw new Error(`failed to load script: ${url}`);
360
- };
361
- const loadPromise = new Promise((loadResolve) => {
362
- script.addEventListener("load", () => {
363
- logger.debug("[loadPieModule] Script loaded from:", url);
364
- if (isPieAvailable(window)) {
256
+ let timer;
257
+ // Removing a `<script>` does not abort a request already in flight, so a
258
+ // late `load` can still fire after the deadline rejected. Each handler
259
+ // checks this so registration never runs for a load the caller has
260
+ // already been told failed.
261
+ let settled = false;
262
+ try {
263
+ await new Promise((resolve, reject) => {
264
+ const succeed = () => {
265
+ settled = true;
266
+ resolve();
267
+ };
268
+ const fail = (error) => {
269
+ settled = true;
270
+ reject(error);
271
+ };
272
+ script.addEventListener("load", () => {
273
+ if (settled)
274
+ return;
275
+ logger.debug("[loadPieModule] Script loaded from:", url);
276
+ if (!isPieAvailable(window)) {
277
+ // Deliberately a rejection, not a resolve. The script executed
278
+ // and registered nothing, so the URL did not serve a PIE IIFE
279
+ // bundle; resolving would report a successful load to a caller
280
+ // that then waits on elements which never arrive.
281
+ // `initializePiesFromLoadedBundle` tolerates the same missing
282
+ // global because there the host's own loader owns registration.
283
+ // Here this function owns it, so there is no other party to
284
+ // wait for. Matches the IIFE `ElementLoader` adapter, which
285
+ // fails with `cause: "window.pie.default missing after bundle
286
+ // load"`.
287
+ fail(new Error(`PIE bundle loaded but window.pie is absent; is ${url} a proper PIE IIFE module?`));
288
+ return;
289
+ }
365
290
  logger.debug("[loadPieModule] window.pie available");
366
291
  const elementModule = window.pie.default;
367
- // Use shared registration logic (returns array of promises)
368
- const registrationPromises = registerPieElementsFromBundle(elementModule, config, session, registry, options);
369
- // Wait for all element definitions to complete
370
- Promise.all(registrationPromises).then(() => loadResolve());
371
- }
372
- else {
373
- logger.error("[loadPieModule] pie var not found; is %s a proper PIE IIFE module?", url);
374
- loadResolve();
292
+ try {
293
+ // Use shared registration logic (returns array of promises)
294
+ const registrationPromises = registerPieElementsFromBundle(elementModule, config, session, registry, options);
295
+ // Wait for all element definitions to complete
296
+ Promise.all(registrationPromises).then(succeed, fail);
297
+ }
298
+ catch (error) {
299
+ // `registerPieElementsFromBundle` throws synchronously for a
300
+ // package missing from the bundle and for a client-player
301
+ // bundle with no controller. Inside a DOM event handler that
302
+ // throw reaches the window instead of the caller.
303
+ fail(error instanceof Error ? error : new Error(String(error)));
304
+ }
305
+ });
306
+ script.addEventListener("error", () => {
307
+ if (settled)
308
+ return;
309
+ fail(new Error(`failed to load PIE bundle script: ${url}`));
310
+ });
311
+ if (loadTimeoutMs > 0) {
312
+ timer = setTimeout(() => {
313
+ if (settled)
314
+ return;
315
+ fail(new Error(`PIE bundle script load timed out after ${loadTimeoutMs}ms: ${url}`));
316
+ }, loadTimeoutMs);
375
317
  }
318
+ document.head.appendChild(script);
376
319
  });
377
- });
378
- document.head.appendChild(script);
379
- await loadPromise;
320
+ }
321
+ catch (error) {
322
+ // Drop the injected node so a retry starts from a clean head.
323
+ script.remove();
324
+ throw error;
325
+ }
326
+ finally {
327
+ if (timer)
328
+ clearTimeout(timer);
329
+ }
380
330
  return { session };
381
331
  };
382
332
  /**
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Binding a single PIE element to its model and session.
3
+ *
4
+ * Split out of `initialization.ts` so the late-arrival observer in
5
+ * `element-observer.ts` can reuse it without importing the bundle loaders.
6
+ */
7
+ import type { ConfigEntity, Env } from "../types/index.js";
8
+ import type { EventListeners, PieElement } from "./types.js";
9
+ /**
10
+ * Bind `element` to the model in `options.config` whose `id` matches it.
11
+ *
12
+ * Returns whether the element is bound: `true` once a model has been applied
13
+ * (including on a repeat call for an element that is already bound), `false`
14
+ * when the config carries no model for this `id`. A caller holding several
15
+ * configs — an item player registers its item config and its passage config
16
+ * separately — uses that to stop at the config the element belongs to.
17
+ */
18
+ export declare const initializePieElement: (element: PieElement, options: {
19
+ config: ConfigEntity;
20
+ session: any[];
21
+ env?: Env;
22
+ eventListeners?: EventListeners;
23
+ }) => boolean;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Binding a single PIE element to its model and session.
3
+ *
4
+ * Split out of `initialization.ts` so the late-arrival observer in
5
+ * `element-observer.ts` can reuse it without importing the bundle loaders.
6
+ */
7
+ import { wrapModelRichContent } from "../security/wrap-model-rich-content.js";
8
+ import { createPieLogger, isGlobalDebugEnabled } from "./logger.js";
9
+ import { pieRegistry } from "./registry.js";
10
+ import { findPieController } from "./scoring.js";
11
+ import { BundleType } from "./types.js";
12
+ import { findOrAddSession } from "./utils.js";
13
+ const logger = createPieLogger("pie-initialize-element", () => isGlobalDebugEnabled());
14
+ /**
15
+ * Bind `element` to the model in `options.config` whose `id` matches it.
16
+ *
17
+ * Returns whether the element is bound: `true` once a model has been applied
18
+ * (including on a repeat call for an element that is already bound), `false`
19
+ * when the config carries no model for this `id`. A caller holding several
20
+ * configs — an item player registers its item config and its passage config
21
+ * separately — uses that to stop at the config the element belongs to.
22
+ */
23
+ export const initializePieElement = (element, options) => {
24
+ const { config, session, env, eventListeners } = options;
25
+ if (element.__pieInitialized) {
26
+ return true;
27
+ }
28
+ const tagName = element.tagName.toLowerCase();
29
+ logger.debug(`[initializePieElement] Initializing ${tagName}#${element.id}`);
30
+ // Find model for this element
31
+ const model = config?.models?.find((m) => m.id === element.id);
32
+ if (!model) {
33
+ // Only warn if this element is from a client-player.js bundle (where models are expected)
34
+ // player.js bundles use server-processed models, so missing models are expected there
35
+ const registry = pieRegistry();
36
+ const registryEntry = registry[tagName];
37
+ if (registryEntry && registryEntry.bundleType === BundleType.clientPlayer) {
38
+ logger.warn(`[initializePieElement] Model not found for PIE element ${tagName}#${element.id} (client-player.js bundle)`);
39
+ }
40
+ return false;
41
+ }
42
+ // Set session (with element property for updateSession callback)
43
+ const elementSession = findOrAddSession(session, model.id, model.element);
44
+ element.session = elementSession;
45
+ element.__pieInitialized = true;
46
+ logger.debug(`[initializePieElement] Session set for ${tagName}#${element.id}:`, elementSession);
47
+ // Set model - use controller if available (client-player.js), or use server-processed model (player.js)
48
+ const controller = findPieController(tagName);
49
+ if (!env) {
50
+ logger.error(`[initializePieElement] ❌ FATAL: No env provided for ${tagName}`);
51
+ throw new Error(`No env provided for ${tagName}. PIE elements require an env object with mode and role.`);
52
+ }
53
+ if (!controller) {
54
+ // No controller available - using server-processed model (player.js bundle)
55
+ logger.debug(`[initializePieElement] ℹ️ No controller for ${tagName}, using server-processed model`);
56
+ logger.debug(`[initializePieElement] Model already processed by server:`, {
57
+ id: model.id,
58
+ element: model.element,
59
+ hasCorrectResponse: "correctResponse" in model,
60
+ mode: env.mode,
61
+ role: env.role,
62
+ });
63
+ // Set model directly - server already processed it
64
+ element.model = wrapModelRichContent(model);
65
+ }
66
+ else {
67
+ // Controller available - run client-side processing (client-player.js bundle)
68
+ // Note: updatePieElementWithRef handles controller invocation
69
+ logger.debug(`[initializePieElement] Controller found for ${tagName}, will invoke model() function`);
70
+ }
71
+ // Add event listeners
72
+ if (eventListeners) {
73
+ Object.entries(eventListeners).forEach(([evt, fn]) => {
74
+ element.addEventListener(evt, fn);
75
+ });
76
+ }
77
+ return true;
78
+ };
@@ -4,4 +4,5 @@ export type InstrumentationEventMapping = {
4
4
  };
5
5
  export declare const TOOLKIT_INSTRUMENTATION_EVENT_MAP: InstrumentationEventMapping[];
6
6
  export declare const SECTION_INSTRUMENTATION_EVENT_MAP: InstrumentationEventMapping[];
7
+ export declare const ITEM_INSTRUMENTATION_EVENT_MAP: InstrumentationEventMapping[];
7
8
  export declare const ASSESSMENT_INSTRUMENTATION_EVENT_MAP: InstrumentationEventMapping[];
@@ -67,6 +67,24 @@ export const SECTION_INSTRUMENTATION_EVENT_MAP = [
67
67
  instrumentationEventName: "pie-section-element-preload-error",
68
68
  },
69
69
  ];
70
+ export const ITEM_INSTRUMENTATION_EVENT_MAP = [
71
+ // Only the security signal is mapped, deliberately. The item player's other
72
+ // public events (`load-complete`, `player-error`, `model-updated`,
73
+ // `model-loaded`, `session-changed` and the `backend-*` family) stay off the
74
+ // bridge because `session-changed` carries the learner's responses, and
75
+ // forwarding response data to a host's telemetry provider by default is the
76
+ // host's decision to make rather than this package's default. A host that
77
+ // wants them can attach its own bridge with its own map.
78
+ //
79
+ // `correct-responses-populated` firing at all is the signal: population
80
+ // requires a controller with `createCorrectResponseSession` in the browser,
81
+ // which only a `client-player.js` bundle provides, and the attributes that
82
+ // request it (`add-correct-response`, `env`, `mode`) are all client-mutable.
83
+ {
84
+ sourceEventName: "correct-responses-populated",
85
+ instrumentationEventName: "pie-item-correct-responses-populated",
86
+ },
87
+ ];
70
88
  export const ASSESSMENT_INSTRUMENTATION_EVENT_MAP = [
71
89
  {
72
90
  sourceEventName: "assessment-controller-ready",
@@ -72,8 +72,12 @@ export async function initializeMathRendering(customRenderer) {
72
72
  initPromise = (async () => {
73
73
  try {
74
74
  const { _dll_pie_lib__math_rendering } = await import("@pie-lib/math-rendering-module/module");
75
- setWindowRenderer(_dll_pie_lib__math_rendering);
76
- logger.debug("Math rendering module initialized (both globals set)");
75
+ // A host may install its renderer while the default module is in flight.
76
+ // The explicit renderer remains authoritative when that happens.
77
+ if (!getWindowRenderer()) {
78
+ setWindowRenderer(_dll_pie_lib__math_rendering);
79
+ logger.debug("Math rendering module initialized (both globals set)");
80
+ }
77
81
  }
78
82
  catch (error) {
79
83
  logger.error("Failed to initialize math rendering:", error);
@@ -123,6 +123,16 @@ export interface LoadPieElementsOptions {
123
123
  bundleUrl?: string;
124
124
  eventListeners?: EventListenersMap;
125
125
  container?: Element | Document;
126
+ /**
127
+ * Deadline in ms for `loadPieModule`'s bundle `<script>` load.
128
+ *
129
+ * A stalled request fires neither `load` nor `error`, so without a
130
+ * deadline the returned promise never settles. Named and defaulted to
131
+ * match `EnsureRegisteredOptions.loadTimeoutMs` on the `ElementLoader`
132
+ * primitive, so both bundle-loading paths give a host one budget to
133
+ * reason about. `0` or negative disables the deadline.
134
+ */
135
+ loadTimeoutMs?: number;
126
136
  }
127
137
  /**
128
138
  * Type guard: Check if object has window.pie
@@ -5,6 +5,33 @@
5
5
  */
6
6
  import type { ConfigEntity } from "../types/index.js";
7
7
  import type { LoadPieElementsOptions } from "./types.js";
8
+ /**
9
+ * Percent-encode one element package spec for a build-service bundle path.
10
+ *
11
+ * `config.elements` is authored content, so a spec must not be able to
12
+ * restructure the URL. Escaping everything outside
13
+ * {@link BUNDLE_PATH_LITERAL_CHARS} is what stops it: `#` no longer truncates
14
+ * the path at a fragment, `?` no longer turns the remainder into a query
15
+ * string (which in `buildBundleUrl` also swallowed the real `?elements=`
16
+ * parameter), `\` is no longer normalized to `/` by the URL parser, `%` can
17
+ * no longer smuggle an escape the build service decodes back into a
18
+ * separator, and an in-spec `+` becomes `%2B` instead of a phantom package
19
+ * boundary.
20
+ *
21
+ * Both blanket encoders are wrong here, so do not "simplify" to either.
22
+ * `encodeURI` — what this replaced — leaves `/`, `?`, `#` and `%` alone.
23
+ * `encodeURIComponent` escapes `/` and `@`, which this route needs literal;
24
+ * it is correct for the `elements=` query value, which is why both encoders
25
+ * appear in `buildBundleUrl`.
26
+ */
27
+ export declare const encodeElementPackageSpec: (spec: string) => string;
28
+ /**
29
+ * Encode each element package spec and join them with the literal `+` the
30
+ * legacy IIFE bundle route uses as its package separator. Encoding the joined
31
+ * string instead would escape the separator. That same separator is why
32
+ * `element-package-policy` rejects semver build metadata.
33
+ */
34
+ export declare const encodeElementPackageSpecs: (specs: Iterable<string>) => string;
8
35
  /**
9
36
  * Build URL for fetching PIE element bundles from build service
10
37
  */
package/dist/pie/utils.js CHANGED
@@ -3,12 +3,67 @@
3
3
  *
4
4
  * URL building, package name parsing, and session utilities.
5
5
  */
6
+ /**
7
+ * Characters an element package spec may carry into a bundle path unescaped.
8
+ *
9
+ * `@` and `/` are in the set because the build service route is
10
+ * `<host>/<spec>+<spec>.../<bundleType>`, and a scoped spec
11
+ * (`@pie-element/multiple-choice@9.9.1`) spans path segments inside it —
12
+ * escaping either yields a path the service does not match. The rest is the
13
+ * RFC 3986 unreserved set, which covers every npm package name and every
14
+ * semver version. The `u` flag makes a surrogate pair match as one code
15
+ * point, so an astral character encodes as a whole character.
16
+ */
17
+ const BUNDLE_PATH_LITERAL_CHARS = /[^A-Za-z0-9\-._~@/]/gu;
18
+ /** Path segments the URL parser resolves away before a request is sent. */
19
+ const DOT_SEGMENTS = new Set([".", ".."]);
20
+ /**
21
+ * Percent-encode one element package spec for a build-service bundle path.
22
+ *
23
+ * `config.elements` is authored content, so a spec must not be able to
24
+ * restructure the URL. Escaping everything outside
25
+ * {@link BUNDLE_PATH_LITERAL_CHARS} is what stops it: `#` no longer truncates
26
+ * the path at a fragment, `?` no longer turns the remainder into a query
27
+ * string (which in `buildBundleUrl` also swallowed the real `?elements=`
28
+ * parameter), `\` is no longer normalized to `/` by the URL parser, `%` can
29
+ * no longer smuggle an escape the build service decodes back into a
30
+ * separator, and an in-spec `+` becomes `%2B` instead of a phantom package
31
+ * boundary.
32
+ *
33
+ * Both blanket encoders are wrong here, so do not "simplify" to either.
34
+ * `encodeURI` — what this replaced — leaves `/`, `?`, `#` and `%` alone.
35
+ * `encodeURIComponent` escapes `/` and `@`, which this route needs literal;
36
+ * it is correct for the `elements=` query value, which is why both encoders
37
+ * appear in `buildBundleUrl`.
38
+ */
39
+ export const encodeElementPackageSpec = (spec) => {
40
+ const escaped = spec.replace(BUNDLE_PATH_LITERAL_CHARS, (char) => encodeURIComponent(char));
41
+ const segments = escaped.split("/");
42
+ // A literal `/` next to a `.` or `..` segment is the one remaining way
43
+ // authored content rewrites the path: the URL parser resolves dot segments
44
+ // before the request is sent, so `@pie-element/../../x` would leave the
45
+ // bundles route. Percent-encoding the dots does not help — the parser
46
+ // recognizes `%2e` as a dot segment as well. Escaping every `/` in such a
47
+ // spec collapses it into one inert segment that 404s inside the route. No
48
+ // npm package name or subpath is `.` or `..`, so nothing legitimate takes
49
+ // this branch.
50
+ return segments.some((segment) => DOT_SEGMENTS.has(segment))
51
+ ? segments.join("%2F")
52
+ : escaped;
53
+ };
54
+ /**
55
+ * Encode each element package spec and join them with the literal `+` the
56
+ * legacy IIFE bundle route uses as its package separator. Encoding the joined
57
+ * string instead would escape the separator. That same separator is why
58
+ * `element-package-policy` rejects semver build metadata.
59
+ */
60
+ export const encodeElementPackageSpecs = (specs) => Array.from(specs, encodeElementPackageSpec).join("+");
6
61
  /**
7
62
  * Build URL for fetching PIE element bundles from build service
8
63
  */
9
64
  export const getPieElementBundlesUrl = (config, opts) => {
10
65
  const elements = config.elements;
11
- return `${opts.buildServiceBase}/${encodeURI(Object.values(elements).join("+"))}/${opts.bundleType}`;
66
+ return `${opts.buildServiceBase}/${encodeElementPackageSpecs(Object.values(elements))}/${opts.bundleType}`;
12
67
  };
13
68
  /**
14
69
  * Parse a package name string into its components
@@ -1,6 +1,7 @@
1
1
  export { buildAuthoringAllowList, createDefaultItemMarkupSanitizer, resetPurifierForTesting, sanitizeItemMarkup, type ItemMarkupSanitizer, type SanitizeItemMarkupOptions, } from "./sanitize-item-markup.js";
2
2
  export { parseAllowedStyleOrigins, validateExternalStyleUrl, type StyleUrlValidationError, type StyleUrlValidationOk, type StyleUrlValidationOptions, type StyleUrlValidationResult, } from "./validate-style-url.js";
3
3
  export { resetSvgSanitizerForTesting, sanitizeSvgIcon, } from "./sanitize-svg-icon.js";
4
- export { wrapOverwideImages, wrapOverwideImagesInElement, } from "./wrap-overwide-images.js";
4
+ export { sanitizeStyleAttribute } from "./sanitize-style-attribute.js";
5
+ export { isOverwideImageWrapMutation, wrapOverwideImages, wrapOverwideImagesInElement, } from "./wrap-overwide-images.js";
5
6
  export { wrapModelRichContent } from "./wrap-model-rich-content.js";
6
- export { wrapOverwideTables, wrapOverwideTablesInElement, } from "./wrap-overwide-tables.js";
7
+ export { isOverwideTableWrapMutation, wrapOverwideTables, wrapOverwideTablesInElement, } from "./wrap-overwide-tables.js";
@@ -1,6 +1,7 @@
1
1
  export { buildAuthoringAllowList, createDefaultItemMarkupSanitizer, resetPurifierForTesting, sanitizeItemMarkup, } from "./sanitize-item-markup.js";
2
2
  export { parseAllowedStyleOrigins, validateExternalStyleUrl, } from "./validate-style-url.js";
3
3
  export { resetSvgSanitizerForTesting, sanitizeSvgIcon, } from "./sanitize-svg-icon.js";
4
- export { wrapOverwideImages, wrapOverwideImagesInElement, } from "./wrap-overwide-images.js";
4
+ export { sanitizeStyleAttribute } from "./sanitize-style-attribute.js";
5
+ export { isOverwideImageWrapMutation, wrapOverwideImages, wrapOverwideImagesInElement, } from "./wrap-overwide-images.js";
5
6
  export { wrapModelRichContent } from "./wrap-model-rich-content.js";
6
- export { wrapOverwideTables, wrapOverwideTablesInElement, } from "./wrap-overwide-tables.js";
7
+ export { isOverwideTableWrapMutation, wrapOverwideTables, wrapOverwideTablesInElement, } from "./wrap-overwide-tables.js";
@@ -13,6 +13,15 @@ export const SANITIZER_FORBIDDEN_TAGS = [
13
13
  "form",
14
14
  "meta",
15
15
  "link",
16
+ // A <style> element is a document-global stylesheet. The item player renders
17
+ // in light DOM (`shadow: "none"`), so a <style> in authored markup restyles
18
+ // host chrome outside the item. DOMPurify's defaults already drop a
19
+ // top-level HTML <style>; the SVG profile keeps one, whose rules apply
20
+ // document-wide all the same — verified in Chromium 2026-08-30, where
21
+ // `<svg><style>` passed this list and hid an element outside the player.
22
+ // `style` is in DOMPurify's default FORBID_CONTENTS, so the CSS text is
23
+ // dropped with the tag rather than surfacing as item text.
24
+ "style",
16
25
  // <foreignObject> inside an <svg> is a well-known escape hatch back into
17
26
  // HTML context.
18
27
  "foreignobject",
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import DOMPurify from "dompurify";
11
11
  import { SANITIZER_FORBIDDEN_ATTRS, SANITIZER_FORBIDDEN_TAGS, } from "./sanitize-forbidden-lists.js";
12
+ import { installStyleAttributeHook } from "./sanitize-style-attribute.js";
12
13
  import { wrapOverwideImages } from "./wrap-overwide-images.js";
13
14
  import { wrapOverwideTables } from "./wrap-overwide-tables.js";
14
15
  // Attributes every PIE element / wrapper is allowed to carry.
@@ -53,6 +54,9 @@ function resolvePurifier() {
53
54
  typeof factory === "function"
54
55
  ? factory(window)
55
56
  : DOMPurify;
57
+ // `style` is URI-safe to DOMPurify, so nothing inside it is inspected
58
+ // without this. See sanitize-style-attribute.ts.
59
+ installStyleAttributeHook(purifierInstance);
56
60
  return purifierInstance;
57
61
  }
58
62
  /**