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

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 (35) hide show
  1. package/dist/index.d.ts +1 -0
  2. package/dist/index.js +1 -0
  3. package/dist/loaders/iife-adapter.js +7 -3
  4. package/dist/pie/element-observer.d.ts +59 -0
  5. package/dist/pie/element-observer.js +131 -0
  6. package/dist/pie/index.d.ts +4 -2
  7. package/dist/pie/index.js +7 -2
  8. package/dist/pie/initialization.d.ts +13 -13
  9. package/dist/pie/initialization.js +97 -147
  10. package/dist/pie/initialize-element.d.ts +23 -0
  11. package/dist/pie/initialize-element.js +78 -0
  12. package/dist/pie/instrumentation-event-map.d.ts +1 -0
  13. package/dist/pie/instrumentation-event-map.js +18 -0
  14. package/dist/pie/math-rendering.js +6 -2
  15. package/dist/pie/types.d.ts +10 -0
  16. package/dist/pie/utils.d.ts +27 -0
  17. package/dist/pie/utils.js +56 -1
  18. package/dist/security/index.d.ts +3 -2
  19. package/dist/security/index.js +3 -2
  20. package/dist/security/sanitize-forbidden-lists.js +9 -0
  21. package/dist/security/sanitize-item-markup.js +4 -0
  22. package/dist/security/sanitize-style-attribute.d.ts +48 -0
  23. package/dist/security/sanitize-style-attribute.js +129 -0
  24. package/dist/security/sanitize-svg-icon.js +2 -0
  25. package/dist/security/validate-style-url.d.ts +13 -0
  26. package/dist/security/validate-style-url.js +36 -2
  27. package/dist/security/wrap-overwide-images.d.ts +7 -0
  28. package/dist/security/wrap-overwide-images.js +10 -1
  29. package/dist/security/wrap-overwide-tables.d.ts +7 -0
  30. package/dist/security/wrap-overwide-tables.js +10 -1
  31. package/dist/security/wrap-overwide.d.ts +16 -0
  32. package/dist/security/wrap-overwide.js +52 -0
  33. package/dist/ui/overlay-containment.d.ts +48 -0
  34. package/dist/ui/overlay-containment.js +65 -0
  35. package/package.json +6 -6
@@ -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
  /**
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Declaration-level filter for the `style` attribute on authored markup and
3
+ * tool icons.
4
+ *
5
+ * DOMPurify lists `style` among its URI-safe attributes, so it permits the
6
+ * attribute and inspects nothing inside it. Two things in an inline style
7
+ * therefore reach the page unchecked: a URL-fetching function, which makes the
8
+ * browser request an arbitrary origin every time the item renders and reports
9
+ * back which learner saw which item, and `position: fixed`, which leaves the
10
+ * item's box and covers the host page — the item player renders in light DOM
11
+ * (`shadow: "none"`), so nothing else confines it.
12
+ *
13
+ * What stays permitted is the point of filtering declarations rather than
14
+ * dropping the attribute: authored items use inline styles for ordinary
15
+ * per-element presentation, and `position: absolute` in particular is
16
+ * load-bearing for accessibility — MathJax's `mjx-assistive-mml` carries
17
+ * `position: absolute; width: 1px; height: 1px; overflow: hidden` to expose
18
+ * MathML to a screen reader while hiding it visually.
19
+ *
20
+ * `position: absolute` and `position: sticky` are deliberately left alone.
21
+ * Sticky cannot leave its containing block, so it is not an escape. Absolute
22
+ * can, when no ancestor between the node and the viewport is positioned, and
23
+ * closing that means making the player's own container a containing block
24
+ * rather than filtering the declaration — a change that re-anchors every
25
+ * absolutely positioned node a PIE element renders, so it is a separate
26
+ * decision with its own visual review.
27
+ */
28
+ /**
29
+ * Filter one `style` attribute value.
30
+ *
31
+ * Returns the input unchanged when nothing needs removing, so authored markup
32
+ * keeps its own spelling — shorthands stay shorthands — in every case but the
33
+ * one that carries something forbidden.
34
+ */
35
+ export declare function sanitizeStyleAttribute(value: unknown, doc: Document | null | undefined): string;
36
+ /** The subset of a DOMPurify instance this hook needs. */
37
+ export interface StyleAttributeHookTarget {
38
+ addHook?: (entryPoint: string, hook: (node: unknown, data?: unknown) => void) => void;
39
+ }
40
+ /**
41
+ * Install the filter on a DOMPurify instance.
42
+ *
43
+ * `afterSanitizeAttributes` rather than a post-pass over the output string:
44
+ * DOMPurify has already parsed the markup at that point, so the declarations
45
+ * are filtered on the node it is about to serialize instead of costing another
46
+ * DOM round-trip.
47
+ */
48
+ export declare function installStyleAttributeHook(purifier: StyleAttributeHookTarget): void;
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Declaration-level filter for the `style` attribute on authored markup and
3
+ * tool icons.
4
+ *
5
+ * DOMPurify lists `style` among its URI-safe attributes, so it permits the
6
+ * attribute and inspects nothing inside it. Two things in an inline style
7
+ * therefore reach the page unchecked: a URL-fetching function, which makes the
8
+ * browser request an arbitrary origin every time the item renders and reports
9
+ * back which learner saw which item, and `position: fixed`, which leaves the
10
+ * item's box and covers the host page — the item player renders in light DOM
11
+ * (`shadow: "none"`), so nothing else confines it.
12
+ *
13
+ * What stays permitted is the point of filtering declarations rather than
14
+ * dropping the attribute: authored items use inline styles for ordinary
15
+ * per-element presentation, and `position: absolute` in particular is
16
+ * load-bearing for accessibility — MathJax's `mjx-assistive-mml` carries
17
+ * `position: absolute; width: 1px; height: 1px; overflow: hidden` to expose
18
+ * MathML to a screen reader while hiding it visually.
19
+ *
20
+ * `position: absolute` and `position: sticky` are deliberately left alone.
21
+ * Sticky cannot leave its containing block, so it is not an escape. Absolute
22
+ * can, when no ancestor between the node and the viewport is positioned, and
23
+ * closing that means making the player's own container a containing block
24
+ * rather than filtering the declaration — a change that re-anchors every
25
+ * absolutely positioned node a PIE element renders, so it is a separate
26
+ * decision with its own visual review.
27
+ */
28
+ /**
29
+ * CSS functions that make the browser fetch a URL. Serialized CSSOM values are
30
+ * matched against this, so an escaped spelling (`\75 rl(`) is normalized to
31
+ * `url(` before it gets here.
32
+ */
33
+ const URL_FUNCTION_REGEX = /(?:^|[^\w-])(?:url|image-set|-webkit-image-set|src)\s*\(/i;
34
+ /**
35
+ * Cheap gate on the raw attribute so the common case costs one regex and no
36
+ * CSSOM parse. `position` is included because that check needs the parser, and
37
+ * a backslash because it introduces a CSS escape, which is exactly how a
38
+ * forbidden token hides from a raw-string match.
39
+ */
40
+ const NEEDS_INSPECTION_REGEX = /url\s*\(|image-set\s*\(|src\s*\(|position|\\/i;
41
+ function isForbiddenDeclaration(property, value) {
42
+ if (URL_FUNCTION_REGEX.test(value))
43
+ return true;
44
+ // `position` is a shorthand for nothing, so the CSSOM reports it verbatim.
45
+ return property.toLowerCase() === "position" && /\bfixed\b/i.test(value);
46
+ }
47
+ /**
48
+ * Rebuild the attribute from the parsed declarations, dropping the forbidden
49
+ * ones. Anything the engine failed to parse is dropped with them: an unparsed
50
+ * declaration cannot be inspected, and this path only runs on input that
51
+ * already tripped the gate.
52
+ */
53
+ function rebuildFromCssom(value, doc) {
54
+ const probe = doc.createElement("span");
55
+ probe.setAttribute("style", value);
56
+ const declarations = probe.style;
57
+ const kept = [];
58
+ for (let index = 0; index < declarations.length; index += 1) {
59
+ const property = declarations.item(index);
60
+ if (!property)
61
+ continue;
62
+ const propertyValue = declarations.getPropertyValue(property);
63
+ if (isForbiddenDeclaration(property, propertyValue))
64
+ continue;
65
+ const priority = declarations.getPropertyPriority(property);
66
+ kept.push(`${property}: ${propertyValue}${priority ? ` !${priority}` : ""}`);
67
+ }
68
+ return kept.join("; ");
69
+ }
70
+ /**
71
+ * Filter one `style` attribute value.
72
+ *
73
+ * Returns the input unchanged when nothing needs removing, so authored markup
74
+ * keeps its own spelling — shorthands stay shorthands — in every case but the
75
+ * one that carries something forbidden.
76
+ */
77
+ export function sanitizeStyleAttribute(value, doc) {
78
+ if (typeof value !== "string" || value.length === 0)
79
+ return "";
80
+ if (!doc)
81
+ return value;
82
+ if (!NEEDS_INSPECTION_REGEX.test(value))
83
+ return value;
84
+ const probe = doc.createElement("span");
85
+ probe.setAttribute("style", value);
86
+ const declarations = probe.style;
87
+ let hasForbidden = false;
88
+ for (let index = 0; index < declarations.length; index += 1) {
89
+ const property = declarations.item(index);
90
+ if (!property)
91
+ continue;
92
+ if (isForbiddenDeclaration(property, declarations.getPropertyValue(property))) {
93
+ hasForbidden = true;
94
+ break;
95
+ }
96
+ }
97
+ // A raw value carrying a URL function the engine did not parse into a
98
+ // declaration still goes through the rebuild, which drops it.
99
+ if (!hasForbidden && !URL_FUNCTION_REGEX.test(value))
100
+ return value;
101
+ return rebuildFromCssom(value, doc);
102
+ }
103
+ /**
104
+ * Install the filter on a DOMPurify instance.
105
+ *
106
+ * `afterSanitizeAttributes` rather than a post-pass over the output string:
107
+ * DOMPurify has already parsed the markup at that point, so the declarations
108
+ * are filtered on the node it is about to serialize instead of costing another
109
+ * DOM round-trip.
110
+ */
111
+ export function installStyleAttributeHook(purifier) {
112
+ if (typeof purifier.addHook !== "function")
113
+ return;
114
+ purifier.addHook("afterSanitizeAttributes", (node) => {
115
+ const element = node;
116
+ if (!element || typeof element.getAttribute !== "function")
117
+ return;
118
+ const raw = element.getAttribute("style");
119
+ if (!raw)
120
+ return;
121
+ const filtered = sanitizeStyleAttribute(raw, element.ownerDocument);
122
+ if (filtered === raw)
123
+ return;
124
+ if (filtered)
125
+ element.setAttribute("style", filtered);
126
+ else
127
+ element.removeAttribute("style");
128
+ });
129
+ }
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import DOMPurify from "dompurify";
13
13
  import { SANITIZER_FORBIDDEN_ATTRS, SANITIZER_FORBIDDEN_TAGS, } from "./sanitize-forbidden-lists.js";
14
+ import { installStyleAttributeHook } from "./sanitize-style-attribute.js";
14
15
  let svgPurifierInstance = null;
15
16
  function resolveSvgPurifier() {
16
17
  if (svgPurifierInstance)
@@ -22,6 +23,7 @@ function resolveSvgPurifier() {
22
23
  typeof factory === "function"
23
24
  ? factory(window)
24
25
  : DOMPurify;
26
+ installStyleAttributeHook(svgPurifierInstance);
25
27
  return svgPurifierInstance;
26
28
  }
27
29
  const FORBIDDEN_TAGS = SANITIZER_FORBIDDEN_TAGS;
@@ -8,6 +8,13 @@
8
8
  * - When `allowedOrigins` is non-empty, the URL's origin must match one
9
9
  * of the listed origins. This lets hosts restrict style loading to a
10
10
  * known CDN allow-list.
11
+ * - With no allow-list configured, only same-origin URLs pass. The reachable
12
+ * input here is authored — `itemConfig.resources.stylesheets[*].url` — so an
13
+ * open default let an item pull page-wide CSS from any origin it named, and
14
+ * the cross-origin branch in the player is the one that cannot be scoped
15
+ * (CSS the browser applies from a `<link>` rather than text the player
16
+ * fetched and rewrote). Naming an origin in `allowed-style-origins` is a
17
+ * host's opt-in to that.
11
18
  */
12
19
  export type StyleUrlValidationOk = {
13
20
  ok: true;
@@ -20,6 +27,12 @@ export type StyleUrlValidationError = {
20
27
  };
21
28
  export type StyleUrlValidationResult = StyleUrlValidationOk | StyleUrlValidationError;
22
29
  export interface StyleUrlValidationOptions {
30
+ /**
31
+ * Document URL the stylesheet URL resolves against, and the origin a URL is
32
+ * compared to when no `allowedOrigins` are configured. Omitting it while
33
+ * supplying no allow-list leaves no origin to compare against, so
34
+ * cross-origin cannot be ruled out and the URL is rejected.
35
+ */
23
36
  baseUrl?: string;
24
37
  allowedOrigins?: string[];
25
38
  }
@@ -8,6 +8,13 @@
8
8
  * - When `allowedOrigins` is non-empty, the URL's origin must match one
9
9
  * of the listed origins. This lets hosts restrict style loading to a
10
10
  * known CDN allow-list.
11
+ * - With no allow-list configured, only same-origin URLs pass. The reachable
12
+ * input here is authored — `itemConfig.resources.stylesheets[*].url` — so an
13
+ * open default let an item pull page-wide CSS from any origin it named, and
14
+ * the cross-origin branch in the player is the one that cannot be scoped
15
+ * (CSS the browser applies from a `<link>` rather than text the player
16
+ * fetched and rewrote). Naming an origin in `allowed-style-origins` is a
17
+ * host's opt-in to that.
11
18
  */
12
19
  export function validateExternalStyleUrl(url, options = {}) {
13
20
  if (typeof url !== "string" || url.length === 0) {
@@ -38,11 +45,38 @@ export function validateExternalStyleUrl(url, options = {}) {
38
45
  };
39
46
  }
40
47
  const allowed = options.allowedOrigins ?? [];
41
- if (allowed.length > 0 && !allowed.includes(resolvedUrl.origin)) {
48
+ if (allowed.length > 0) {
49
+ if (!allowed.includes(resolvedUrl.origin)) {
50
+ return {
51
+ ok: false,
52
+ reason: "disallowed-origin",
53
+ message: `External stylesheet origin ${resolvedUrl.origin} is not in the configured allow-list.`,
54
+ };
55
+ }
56
+ return { ok: true, resolvedUrl };
57
+ }
58
+ // No allow-list: same-origin only.
59
+ let baseOrigin = null;
60
+ if (options.baseUrl) {
61
+ try {
62
+ baseOrigin = new URL(options.baseUrl).origin;
63
+ }
64
+ catch {
65
+ baseOrigin = null;
66
+ }
67
+ }
68
+ if (baseOrigin === null) {
69
+ return {
70
+ ok: false,
71
+ reason: "disallowed-origin",
72
+ message: "External stylesheet origin cannot be checked: no allow-list is configured and no usable baseUrl was supplied. Pass allowedOrigins to permit a cross-origin stylesheet.",
73
+ };
74
+ }
75
+ if (resolvedUrl.origin !== baseOrigin) {
42
76
  return {
43
77
  ok: false,
44
78
  reason: "disallowed-origin",
45
- message: `External stylesheet origin ${resolvedUrl.origin} is not in the configured allow-list.`,
79
+ message: `External stylesheet origin ${resolvedUrl.origin} is cross-origin and no allow-list is configured. Add it to \`allowed-style-origins\` to permit it.`,
46
80
  };
47
81
  }
48
82
  return { ok: true, resolvedUrl };
@@ -49,3 +49,10 @@ export declare function wrapOverwideImagesInElement(root: Element, options?: Wra
49
49
  * to wrap element-rendered images.
50
50
  */
51
51
  export declare function wrapOverwideImages(markup: string): string;
52
+ /**
53
+ * True when `record` mentions nothing but the output of
54
+ * {@link wrapOverwideImagesInElement} — a `pie-image-scroll` wrapper, or an
55
+ * `<img>` moving inside one. An observer-driven caller ignores such a record so
56
+ * its own wrap does not schedule a second pass.
57
+ */
58
+ export declare function isOverwideImageWrapMutation(record: MutationRecord): boolean;
@@ -26,7 +26,7 @@
26
26
  * The wrapping itself lives in `./wrap-overwide.js`, shared with the table
27
27
  * wrapper: only the four values below and the accessible name differ.
28
28
  */
29
- import { wrapOverwideInElement, wrapOverwideMarkup, } from "./wrap-overwide.js";
29
+ import { isOverwideWrapMutation, wrapOverwideInElement, wrapOverwideMarkup, } from "./wrap-overwide.js";
30
30
  function buildAriaLabel(image) {
31
31
  const alt = image.getAttribute("alt");
32
32
  const trimmed = alt ? alt.trim() : "";
@@ -64,3 +64,12 @@ export function wrapOverwideImagesInElement(root, options = {}) {
64
64
  export function wrapOverwideImages(markup) {
65
65
  return wrapOverwideMarkup(markup, IMAGE_SPEC);
66
66
  }
67
+ /**
68
+ * True when `record` mentions nothing but the output of
69
+ * {@link wrapOverwideImagesInElement} — a `pie-image-scroll` wrapper, or an
70
+ * `<img>` moving inside one. An observer-driven caller ignores such a record so
71
+ * its own wrap does not schedule a second pass.
72
+ */
73
+ export function isOverwideImageWrapMutation(record) {
74
+ return isOverwideWrapMutation(record, IMAGE_SPEC);
75
+ }
@@ -35,3 +35,10 @@ export type WrapOverwideTablesInElementOptions = WrapOverwideOptions;
35
35
  */
36
36
  export declare function wrapOverwideTablesInElement(root: Element, options?: WrapOverwideTablesInElementOptions): number;
37
37
  export declare function wrapOverwideTables(markup: string): string;
38
+ /**
39
+ * True when `record` mentions nothing but the output of
40
+ * {@link wrapOverwideTablesInElement} — a `pie-table-scroll` wrapper, or a
41
+ * `<table>` moving inside one. An observer-driven caller ignores such a record
42
+ * so its own wrap does not schedule a second pass.
43
+ */
44
+ export declare function isOverwideTableWrapMutation(record: MutationRecord): boolean;