@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.
- package/dist/i18n/messages/en-US.d.ts +2 -0
- package/dist/i18n/messages/en-US.js +2 -0
- package/dist/i18n/messages/nl-NL.d.ts +2 -0
- package/dist/i18n/messages/nl-NL.js +2 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/loaders/iife-adapter.js +7 -3
- package/dist/pie/element-observer.d.ts +59 -0
- package/dist/pie/element-observer.js +131 -0
- package/dist/pie/index.d.ts +4 -2
- package/dist/pie/index.js +7 -2
- package/dist/pie/initialization.d.ts +13 -13
- package/dist/pie/initialization.js +97 -147
- package/dist/pie/initialize-element.d.ts +23 -0
- package/dist/pie/initialize-element.js +78 -0
- package/dist/pie/instrumentation-event-map.d.ts +1 -0
- package/dist/pie/instrumentation-event-map.js +18 -0
- package/dist/pie/math-rendering.js +6 -2
- package/dist/pie/types.d.ts +10 -0
- package/dist/pie/utils.d.ts +27 -0
- package/dist/pie/utils.js +56 -1
- package/dist/security/index.d.ts +3 -2
- package/dist/security/index.js +3 -2
- package/dist/security/sanitize-forbidden-lists.js +9 -0
- package/dist/security/sanitize-item-markup.js +4 -0
- package/dist/security/sanitize-style-attribute.d.ts +48 -0
- package/dist/security/sanitize-style-attribute.js +129 -0
- package/dist/security/sanitize-svg-icon.js +2 -0
- package/dist/security/validate-style-url.d.ts +13 -0
- package/dist/security/validate-style-url.js +36 -2
- package/dist/security/wrap-overwide-images.d.ts +7 -0
- package/dist/security/wrap-overwide-images.js +10 -1
- package/dist/security/wrap-overwide-tables.d.ts +7 -0
- package/dist/security/wrap-overwide-tables.js +10 -1
- package/dist/security/wrap-overwide.d.ts +16 -0
- package/dist/security/wrap-overwide.js +52 -0
- package/dist/ui/overlay-containment.d.ts +48 -0
- package/dist/ui/overlay-containment.js +65 -0
- package/package.json +6 -10
- package/dist/ui/zoom-compensation.d.ts +0 -44
- 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 {
|
|
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
|
-
*
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
-
|
|
379
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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);
|
package/dist/pie/types.d.ts
CHANGED
|
@@ -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
|
package/dist/pie/utils.d.ts
CHANGED
|
@@ -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}/${
|
|
66
|
+
return `${opts.buildServiceBase}/${encodeElementPackageSpecs(Object.values(elements))}/${opts.bundleType}`;
|
|
12
67
|
};
|
|
13
68
|
/**
|
|
14
69
|
* Parse a package name string into its components
|
package/dist/security/index.d.ts
CHANGED
|
@@ -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 {
|
|
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";
|
package/dist/security/index.js
CHANGED
|
@@ -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 {
|
|
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
|
/**
|