@pie-players/pie-players-shared 0.3.72 → 0.3.74

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 (123) hide show
  1. package/README.md +27 -1
  2. package/dist/components/vendor/nds/README.md +7 -5
  3. package/dist/config/profile.d.ts +0 -2
  4. package/dist/config/profile.js +2 -8
  5. package/dist/i18n/index.d.ts +2 -3
  6. package/dist/i18n/index.js +2 -3
  7. package/dist/i18n/messages/en-US.d.ts +10 -0
  8. package/dist/i18n/messages/en-US.js +12 -2
  9. package/dist/i18n/messages/nl-NL.d.ts +6 -0
  10. package/dist/i18n/messages/nl-NL.js +8 -2
  11. package/dist/i18n/provider.d.ts +4 -5
  12. package/dist/i18n/provider.js +4 -5
  13. package/dist/index.d.ts +4 -2
  14. package/dist/index.js +3 -1
  15. package/dist/instrumentation/index.d.ts +5 -4
  16. package/dist/instrumentation/index.js +5 -4
  17. package/dist/instrumentation/new-relic-agent.d.ts +20 -0
  18. package/dist/instrumentation/new-relic-agent.js +24 -0
  19. package/dist/instrumentation/providers/BaseInstrumentationProvider.d.ts +5 -4
  20. package/dist/instrumentation/providers/NewRelicInstrumentationProvider.d.ts +7 -7
  21. package/dist/instrumentation/providers/NewRelicInstrumentationProvider.js +21 -32
  22. package/dist/instrumentation/types.d.ts +7 -3
  23. package/dist/loader-config.d.ts +16 -6
  24. package/dist/loaders/controller-loading.d.ts +14 -0
  25. package/dist/loaders/controller-loading.js +12 -0
  26. package/dist/loaders/defaults.d.ts +10 -0
  27. package/dist/loaders/defaults.js +10 -0
  28. package/dist/loaders/element-loader.d.ts +20 -8
  29. package/dist/loaders/element-loader.js +102 -36
  30. package/dist/loaders/element-package-policy.d.ts +2 -0
  31. package/dist/loaders/element-package-policy.js +2 -1
  32. package/dist/loaders/esm-adapter.d.ts +18 -0
  33. package/dist/loaders/esm-adapter.js +617 -102
  34. package/dist/loaders/iife-adapter.d.ts +0 -5
  35. package/dist/loaders/iife-adapter.js +3 -9
  36. package/dist/loaders/index.d.ts +10 -3
  37. package/dist/loaders/index.js +8 -3
  38. package/dist/loaders/mathjax-version-conflict.d.ts +17 -0
  39. package/dist/loaders/mathjax-version-conflict.js +51 -0
  40. package/dist/loaders/module-shim.d.ts +12 -0
  41. package/dist/loaders/module-shim.js +12 -0
  42. package/dist/loaders/preloaded-alignment.d.ts +24 -0
  43. package/dist/loaders/preloaded-alignment.js +57 -0
  44. package/dist/loaders/preloaded-authored-tags.d.ts +26 -0
  45. package/dist/loaders/preloaded-authored-tags.js +69 -0
  46. package/dist/loaders/preloaded-registration.d.ts +56 -0
  47. package/dist/loaders/preloaded-registration.js +129 -0
  48. package/dist/media/index.d.ts +69 -0
  49. package/dist/media/index.js +194 -0
  50. package/dist/pie/authoring.d.ts +1 -1
  51. package/dist/pie/authoring.js +25 -18
  52. package/dist/pie/element-announcements.d.ts +39 -0
  53. package/dist/pie/element-announcements.js +67 -0
  54. package/dist/pie/element-observer.d.ts +3 -1
  55. package/dist/pie/element-observer.js +1 -0
  56. package/dist/pie/index.d.ts +6 -4
  57. package/dist/pie/index.js +6 -3
  58. package/dist/pie/initialization.js +13 -22
  59. package/dist/pie/initialize-element.d.ts +3 -0
  60. package/dist/pie/initialize-element.js +3 -4
  61. package/dist/pie/instrumentation-event-map.js +2 -9
  62. package/dist/pie/instrumentation-provider-resolution.d.ts +2 -1
  63. package/dist/pie/instrumentation-provider-resolution.js +0 -1
  64. package/dist/pie/item-session-contract.d.ts +47 -0
  65. package/dist/pie/item-session-contract.js +196 -9
  66. package/dist/pie/markup-math.d.ts +20 -0
  67. package/dist/pie/markup-math.js +118 -0
  68. package/dist/pie/math-rendering.d.ts +10 -21
  69. package/dist/pie/math-rendering.js +50 -35
  70. package/dist/pie/registry.d.ts +13 -1
  71. package/dist/pie/registry.js +39 -1
  72. package/dist/pie/resource-monitor.d.ts +6 -33
  73. package/dist/pie/resource-monitor.js +14 -213
  74. package/dist/pie/scoring.d.ts +11 -2
  75. package/dist/pie/scoring.js +18 -16
  76. package/dist/pie/session-commit.d.ts +121 -0
  77. package/dist/pie/session-commit.js +420 -0
  78. package/dist/pie/session-snapshot.d.ts +80 -0
  79. package/dist/pie/session-snapshot.js +166 -0
  80. package/dist/pie/tag-names.d.ts +1 -0
  81. package/dist/pie/tag-names.js +1 -0
  82. package/dist/pie/types.d.ts +4 -5
  83. package/dist/pie/types.js +1 -0
  84. package/dist/pie/updates.d.ts +2 -2
  85. package/dist/pie/updates.js +33 -15
  86. package/dist/pie/versioned-tag.d.ts +4 -0
  87. package/dist/pie/versioned-tag.js +4 -0
  88. package/dist/security/index.d.ts +2 -3
  89. package/dist/security/index.js +2 -3
  90. package/dist/security/sanitize-item-markup.d.ts +0 -2
  91. package/dist/security/sanitize-item-markup.js +0 -4
  92. package/dist/security/sanitize-svg-icon.d.ts +0 -2
  93. package/dist/security/sanitize-svg-icon.js +0 -5
  94. package/dist/security/wrap-overwide.d.ts +5 -0
  95. package/dist/security/wrap-overwide.js +16 -0
  96. package/dist/timed-media/policy.js +5 -16
  97. package/dist/types/index.d.ts +30 -311
  98. package/dist/types/index.js +0 -68
  99. package/dist/ui/attribute-coercion.d.ts +12 -0
  100. package/dist/ui/attribute-coercion.js +33 -0
  101. package/dist/ui/content-styles.d.ts +18 -16
  102. package/dist/ui/content-styles.js +67 -36
  103. package/dist/ui/overlay-containment.d.ts +31 -0
  104. package/dist/ui/overlay-containment.js +63 -0
  105. package/dist/ui/overlay-placement.d.ts +110 -0
  106. package/dist/ui/overlay-placement.js +222 -0
  107. package/dist/ui/pointer-gesture.d.ts +32 -0
  108. package/dist/ui/pointer-gesture.js +56 -0
  109. package/dist/ui/pointer-rotate.d.ts +29 -0
  110. package/dist/ui/pointer-rotate.js +39 -0
  111. package/package.json +21 -17
  112. package/dist/pie/component-context.d.ts +0 -21
  113. package/dist/pie/component-context.js +0 -29
  114. package/dist/pie/player-initializer.d.ts +0 -54
  115. package/dist/pie/player-initializer.js +0 -122
  116. package/dist/security/wrap-model-rich-content.d.ts +0 -1
  117. package/dist/security/wrap-model-rich-content.js +0 -40
  118. package/dist/server/npm-auth-env.d.ts +0 -13
  119. package/dist/server/npm-auth-env.js +0 -64
  120. package/dist/server/npm-registry.d.ts +0 -7
  121. package/dist/server/npm-registry.js +0 -59
  122. package/dist/ui/debug-panel-persistence.d.ts +0 -17
  123. package/dist/ui/debug-panel-persistence.js +0 -37
@@ -11,7 +11,7 @@ import { editorPostFix } from "../types/index.js";
11
11
  import { initializePieElement } from "./initialize-element.js";
12
12
  import { createPieLogger, isGlobalDebugEnabled } from "./logger.js";
13
13
  import { initializeMathRendering } from "./math-rendering.js";
14
- import { pieRegistry } from "./registry.js";
14
+ import { pieRegistry, writeRegistryEntry } from "./registry.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";
@@ -38,6 +38,7 @@ const updateRegisteredElement = (elementTagName, config, session, options, omitE
38
38
  config,
39
39
  session,
40
40
  ...(omitEnv ? {} : { env: options.env }),
41
+ bundleType: options.bundleType,
41
42
  container: options.container,
42
43
  ...(options.eventListeners?.[elementTagName] && {
43
44
  eventListeners: options.eventListeners[elementTagName],
@@ -119,30 +120,21 @@ const registerPieElementsFromBundle = (elementModule, config, session, registry,
119
120
  : [],
120
121
  bundleType: options.bundleType,
121
122
  });
122
- // Validate controller presence based on bundle type
123
- if (!elementData.controller) {
124
- if (options.bundleType === BundleType.clientPlayer) {
125
- logger.error(`[registerPieElementsFromBundle] ❌ CRITICAL: No controller found for ${pkgStripped}!`);
126
- logger.error(`[registerPieElementsFromBundle] Bundle type: ${options.bundleType} (controllers required)`);
127
- throw new Error(`No controller found for ${pkgStripped}. client-player.js bundles MUST include controllers!`);
128
- }
129
- else {
130
- logger.debug(`[registerPieElementsFromBundle] ℹ️ No controller found for ${pkgStripped} - using server-processed models (player.js bundle)`);
131
- }
132
- }
133
123
  {
134
- // Register the element in our registry
124
+ // Register the element in our registry. A package without a
125
+ // controller, such as a legacy `@pie-element/protractor`, renders
126
+ // the model it is given under client-player.js too.
135
127
  logger.debug(`[registerPieElementsFromBundle] Registering ${elName} in registry${elementData.controller
136
128
  ? " with controller"
137
- : " (no controller - server-processed models)"}`);
138
- registry[elementTagName] = {
129
+ : " (no controller; its model is used as given)"}`);
130
+ writeRegistryEntry({
139
131
  package: pkg,
140
132
  status: Status.loading,
141
133
  tagName: elementTagName,
142
134
  controller: elementData.controller || null,
143
135
  config: elementData.config,
144
136
  bundleType: options.bundleType,
145
- };
137
+ });
146
138
  if (isCustomElementConstructor(elementData.Element)) {
147
139
  defineCustomElementSafely(elementTagName, elementData.Element, `element tag in config.elements for ${String(pkg)}`);
148
140
  // Initialize existing elements
@@ -155,13 +147,13 @@ const registerPieElementsFromBundle = (elementModule, config, session, registry,
155
147
  session,
156
148
  env: options.env,
157
149
  eventListeners: options.eventListeners?.[elementTagName],
150
+ bundleType: options.bundleType,
158
151
  });
159
152
  });
160
- // Update registry status
161
- registry[elementTagName] = {
153
+ writeRegistryEntry({
162
154
  ...registry[elementTagName],
163
155
  status: Status.loaded,
164
- };
156
+ });
165
157
  promises.push(customElements.whenDefined(elementTagName).then(() => {
166
158
  logger.debug("[registerPieElementsFromBundle] defined custom PIE element: %s", elementTagName);
167
159
  }));
@@ -297,9 +289,8 @@ export const loadPieModule = async (config, session, opts = {}) => {
297
289
  }
298
290
  catch (error) {
299
291
  // `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.
292
+ // package missing from the bundle. Inside a DOM event handler
293
+ // that throw reaches the window instead of the caller.
303
294
  fail(error instanceof Error ? error : new Error(String(error)));
304
295
  }
305
296
  });
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import type { ConfigEntity, Env } from "../types/index.js";
8
8
  import type { EventListeners, PieElement } from "./types.js";
9
+ import { BundleType } from "./types.js";
9
10
  /**
10
11
  * Bind `element` to the model in `options.config` whose `id` matches it.
11
12
  *
@@ -20,4 +21,6 @@ export declare const initializePieElement: (element: PieElement, options: {
20
21
  session: any[];
21
22
  env?: Env;
22
23
  eventListeners?: EventListeners;
24
+ /** The delivering player's bundle type, see `findPieController`. */
25
+ bundleType?: BundleType;
23
26
  }) => boolean;
@@ -4,7 +4,6 @@
4
4
  * Split out of `initialization.ts` so the late-arrival observer in
5
5
  * `element-observer.ts` can reuse it without importing the bundle loaders.
6
6
  */
7
- import { wrapModelRichContent } from "../security/wrap-model-rich-content.js";
8
7
  import { createPieLogger, isGlobalDebugEnabled } from "./logger.js";
9
8
  import { pieRegistry } from "./registry.js";
10
9
  import { findPieController } from "./scoring.js";
@@ -21,7 +20,7 @@ const logger = createPieLogger("pie-initialize-element", () => isGlobalDebugEnab
21
20
  * separately — uses that to stop at the config the element belongs to.
22
21
  */
23
22
  export const initializePieElement = (element, options) => {
24
- const { config, session, env, eventListeners } = options;
23
+ const { config, session, env, eventListeners, bundleType } = options;
25
24
  if (element.__pieInitialized) {
26
25
  return true;
27
26
  }
@@ -45,7 +44,7 @@ export const initializePieElement = (element, options) => {
45
44
  element.__pieInitialized = true;
46
45
  logger.debug(`[initializePieElement] Session set for ${tagName}#${element.id}:`, elementSession);
47
46
  // Set model - use controller if available (client-player.js), or use server-processed model (player.js)
48
- const controller = findPieController(tagName);
47
+ const controller = findPieController(tagName, bundleType);
49
48
  if (!env) {
50
49
  logger.error(`[initializePieElement] ❌ FATAL: No env provided for ${tagName}`);
51
50
  throw new Error(`No env provided for ${tagName}. PIE elements require an env object with mode and role.`);
@@ -61,7 +60,7 @@ export const initializePieElement = (element, options) => {
61
60
  role: env.role,
62
61
  });
63
62
  // Set model directly - server already processed it
64
- element.model = wrapModelRichContent(model);
63
+ element.model = model;
65
64
  }
66
65
  else {
67
66
  // Controller available - run client-side processing (client-player.js bundle)
@@ -37,7 +37,8 @@ export const SECTION_INSTRUMENTATION_EVENT_MAP = [
37
37
  // instrumentation bridge forwards the same names to telemetry. The
38
38
  // readiness alias mappings (`readiness-change`, `interaction-ready`,
39
39
  // `ready`) and the `section-controller-ready` mapping were removed
40
- // alongside their DOM-event surfaces.
40
+ // alongside their DOM-event surfaces. `session-changed` stays off the
41
+ // bridge for the reason the item map gives below.
41
42
  {
42
43
  sourceEventName: "pie-stage-change",
43
44
  instrumentationEventName: "pie-section-stage-change",
@@ -46,14 +47,6 @@ export const SECTION_INSTRUMENTATION_EVENT_MAP = [
46
47
  sourceEventName: "pie-loading-complete",
47
48
  instrumentationEventName: "pie-section-loading-complete",
48
49
  },
49
- {
50
- sourceEventName: "session-changed",
51
- instrumentationEventName: "pie-section-session-changed",
52
- },
53
- {
54
- sourceEventName: "composition-changed",
55
- instrumentationEventName: "pie-section-composition-changed",
56
- },
57
50
  {
58
51
  sourceEventName: "framework-error",
59
52
  instrumentationEventName: "pie-section-framework-error",
@@ -1,6 +1,7 @@
1
+ import type { InstrumentationProvider } from "../instrumentation/types.js";
1
2
  export declare function resolveInstrumentationProvider(args: {
2
3
  runtimePlayer?: unknown;
3
4
  player?: unknown;
4
5
  debug?: boolean;
5
6
  component?: string;
6
- }): unknown;
7
+ }): InstrumentationProvider | undefined;
@@ -37,7 +37,6 @@ const logger = createPieLogger("instrumentation-provider-resolution", () => isGl
37
37
  function getDefaultInstrumentationProvider() {
38
38
  if (!defaultProvider) {
39
39
  defaultProvider = new NewRelicInstrumentationProvider();
40
- // New Relic provider sets readiness based on window.newrelic.
41
40
  void defaultProvider.initialize();
42
41
  }
43
42
  return defaultProvider;
@@ -1,3 +1,4 @@
1
+ import type { SessionCommitReason } from "./session-commit.js";
1
2
  export type ItemSessionContainer = {
2
3
  id: string;
3
4
  data: unknown[];
@@ -8,10 +9,30 @@ export type NormalizedItemSessionChange = {
8
9
  session: ItemSessionContainer | null;
9
10
  intent: ItemSessionUpdateIntent;
10
11
  component?: string;
12
+ /**
13
+ * The id of the element whose change this is: the detail's `elementId`, or
14
+ * the `id` of an element session record carried as `session`.
15
+ */
16
+ elementId?: string;
11
17
  complete?: boolean;
18
+ /** The detail's `sessionCommitReason`, when the change is a commit. */
19
+ sessionCommitReason?: SessionCommitReason;
12
20
  };
13
21
  export declare function normalizeItemSessionContainer(input: unknown, fallbackSessionId?: string): ItemSessionContainer;
14
22
  export declare function hasResponseValue(value: unknown): boolean;
23
+ /**
24
+ * Whether an element session holds anything the learner put there.
25
+ *
26
+ * Shape-agnostic by necessity: `value` is one element's answer key, and
27
+ * `response`, `selectedTokens`, `answers`, `answer`, `drawables` are others.
28
+ * Anything outside the non-response key set with content in it counts, so an
29
+ * element this function has never heard of is handled by default.
30
+ *
31
+ * This is the fallback discriminant for the session commit. The primary one is
32
+ * a comparison against the session the player last observed, which needs no
33
+ * schema knowledge at all; see `session-commit.ts`.
34
+ */
35
+ export declare function hasLearnerResponse(session: unknown): boolean;
15
36
  export declare function hasResponseField(value: unknown): boolean;
16
37
  export declare function mergeElementIntoSession(itemId: string, previousItemSession: unknown, entryId: string, entry: Record<string, unknown>): ItemSessionContainer;
17
38
  export declare function normalizeItemSessionChange(args: {
@@ -19,3 +40,29 @@ export declare function normalizeItemSessionChange(args: {
19
40
  sessionDetail: unknown;
20
41
  previousItemSession?: unknown;
21
42
  }): NormalizedItemSessionChange;
43
+ /**
44
+ * Create the bare `{ id }` entry `<pie-player>` created at render time, for each
45
+ * element the item declares. A host that indexes `data[0]` before the learner
46
+ * has answered finds an entry instead of `undefined`.
47
+ *
48
+ * Only the host's container gets these. The authoritative session stays empty
49
+ * until an element reports something, so an untouched item still saves nothing.
50
+ */
51
+ export declare function ensureHostSessionEntries(hostContainer: unknown, entryIds: Iterable<string>): boolean;
52
+ /**
53
+ * Keep the container a host handed in as a live view of the session, the
54
+ * contract `<pie-player>` had: `findOrAddSession` pushed an entry per element
55
+ * into the host's own `data` array and the element mutated that entry in place,
56
+ * so a host read the response off its own object. `ItemController` owns the
57
+ * session here, so this runs one way and never reads the container back.
58
+ *
59
+ * The array and the entry objects keep their identity, for a host holding a
60
+ * reference into `data`. Entries this player did not produce are left alone: in
61
+ * a section the host's container spans every item and an item player sees one.
62
+ *
63
+ * Inside an entry the player's own session wins, and a key it does not carry is
64
+ * removed - a cleared response has to clear here too, and the entry belongs to
65
+ * the element, as it did under `<pie-player>`. A host keeping its own
66
+ * annotations on an entry keeps them beside it, not inside it.
67
+ */
68
+ export declare function projectSessionIntoHostContainer(hostContainer: unknown, liveSession: unknown): boolean;
@@ -2,6 +2,7 @@ const DEFAULT_SESSION_ID = "";
2
2
  const METADATA_ONLY_SESSION_KEYS = new Set([
3
3
  "complete",
4
4
  "component",
5
+ "elementId",
5
6
  "timestamp",
6
7
  "sourceRuntimeId",
7
8
  ]);
@@ -71,6 +72,64 @@ export function hasResponseValue(value) {
71
72
  }
72
73
  return false;
73
74
  }
75
+ /**
76
+ * Keys a delivery element carries on its session that are never learner input:
77
+ * element identity, the dispatch metadata the event contract defines, and
78
+ * presentation state a controller writes at load (choice shuffle order).
79
+ */
80
+ const NON_RESPONSE_SESSION_KEYS = new Set([
81
+ "id",
82
+ "element",
83
+ "complete",
84
+ "component",
85
+ "timestamp",
86
+ "sourceRuntimeId",
87
+ "shuffledValues",
88
+ ]);
89
+ /**
90
+ * `id` inside an array entry is the response - which hotspot, which choice.
91
+ * `id` inside an object property that repeats the property's own name is
92
+ * structure, which is how `explicit-constructed-response` and `ebsr` key their
93
+ * parts. Distinguishing the two by container is what keeps an unanswered
94
+ * `{ value: { partA: { id: "partA" } } }` from reading as a response.
95
+ */
96
+ function carriesContent(value, owningKey) {
97
+ if (value == null)
98
+ return false;
99
+ if (typeof value === "string")
100
+ return value.trim() !== "";
101
+ if (typeof value === "number" || typeof value === "boolean")
102
+ return true;
103
+ if (Array.isArray(value))
104
+ return value.some((entry) => carriesContent(entry));
105
+ if (typeof value !== "object")
106
+ return false;
107
+ return Object.entries(value).some(([key, nested]) => {
108
+ if (key === "id" && owningKey !== undefined && nested === owningKey) {
109
+ return false;
110
+ }
111
+ return carriesContent(nested, key);
112
+ });
113
+ }
114
+ /**
115
+ * Whether an element session holds anything the learner put there.
116
+ *
117
+ * Shape-agnostic by necessity: `value` is one element's answer key, and
118
+ * `response`, `selectedTokens`, `answers`, `answer`, `drawables` are others.
119
+ * Anything outside the non-response key set with content in it counts, so an
120
+ * element this function has never heard of is handled by default.
121
+ *
122
+ * This is the fallback discriminant for the session commit. The primary one is
123
+ * a comparison against the session the player last observed, which needs no
124
+ * schema knowledge at all; see `session-commit.ts`.
125
+ */
126
+ export function hasLearnerResponse(session) {
127
+ if (!session || typeof session !== "object")
128
+ return false;
129
+ if (Array.isArray(session))
130
+ return session.some(hasLearnerResponse);
131
+ return Object.entries(session).some(([key, value]) => !NON_RESPONSE_SESSION_KEYS.has(key) && carriesContent(value, key));
132
+ }
74
133
  export function hasResponseField(value) {
75
134
  if (value == null)
76
135
  return false;
@@ -104,6 +163,18 @@ function getMetadataComplete(sessionDetail, candidate) {
104
163
  return candidate.complete;
105
164
  return undefined;
106
165
  }
166
+ /**
167
+ * `elementRecord` is an element's own session record, whose `id` is the
168
+ * element's; an item session container's `id` is the item session's.
169
+ */
170
+ function withElementId(sessionDetail, elementRecord) {
171
+ const elementId = typeof sessionDetail.elementId === "string" && sessionDetail.elementId
172
+ ? sessionDetail.elementId
173
+ : typeof elementRecord?.id === "string" && elementRecord.id
174
+ ? elementRecord.id
175
+ : undefined;
176
+ return elementId ? { elementId } : {};
177
+ }
107
178
  function isElementIdentityOnlyPayload(candidate) {
108
179
  const candidateKeys = Object.keys(candidate);
109
180
  const hasIdentityKey = "id" in candidate || "element" in candidate;
@@ -153,6 +224,14 @@ export function mergeElementIntoSession(itemId, previousItemSession, entryId, en
153
224
  };
154
225
  }
155
226
  export function normalizeItemSessionChange(args) {
227
+ const change = classifyItemSessionChange(args);
228
+ const reason = args.sessionDetail
229
+ ?.sessionCommitReason;
230
+ return typeof reason === "string" && reason
231
+ ? { ...change, sessionCommitReason: reason }
232
+ : change;
233
+ }
234
+ function classifyItemSessionChange(args) {
156
235
  const sessionDetail = (args.sessionDetail || {});
157
236
  const actualSession = sessionDetail &&
158
237
  typeof sessionDetail === "object" &&
@@ -171,6 +250,7 @@ export function normalizeItemSessionChange(args) {
171
250
  session: null,
172
251
  intent: "metadata-only",
173
252
  component: getMetadataComponent(sessionDetail),
253
+ ...withElementId(sessionDetail),
174
254
  complete: getMetadataComplete(sessionDetail),
175
255
  };
176
256
  }
@@ -183,11 +263,7 @@ export function normalizeItemSessionChange(args) {
183
263
  const previousForMetadataComparison = alignPreviousSessionIdForRendererSnapshot(normalizedCandidate, previousNormalized);
184
264
  const sessionDetailKeys = Object.keys(sessionDetail);
185
265
  const metadataWithSessionOnly = sessionDetailKeys.length > 0 &&
186
- sessionDetailKeys.every((key) => key === "session" ||
187
- key === "complete" ||
188
- key === "component" ||
189
- key === "timestamp" ||
190
- key === "sourceRuntimeId");
266
+ sessionDetailKeys.every((key) => key === "session" || METADATA_ONLY_SESSION_KEYS.has(key));
191
267
  const hasMetadataBeyondSession = sessionDetailKeys.some((key) => key !== "session");
192
268
  if (metadataWithSessionOnly &&
193
269
  hasMetadataBeyondSession &&
@@ -198,6 +274,7 @@ export function normalizeItemSessionChange(args) {
198
274
  session: null,
199
275
  intent: "metadata-only",
200
276
  component: getMetadataComponent(sessionDetail),
277
+ ...withElementId(sessionDetail),
201
278
  complete: getMetadataComplete(sessionDetail),
202
279
  };
203
280
  }
@@ -208,6 +285,7 @@ export function normalizeItemSessionChange(args) {
208
285
  session: null,
209
286
  intent: "metadata-only",
210
287
  component: getMetadataComponent(sessionDetail),
288
+ ...withElementId(sessionDetail),
211
289
  complete: getMetadataComplete(sessionDetail),
212
290
  };
213
291
  }
@@ -216,21 +294,20 @@ export function normalizeItemSessionChange(args) {
216
294
  session: normalizedCandidate,
217
295
  intent: "replace-item-session",
218
296
  component: getMetadataComponent(sessionDetail),
297
+ ...withElementId(sessionDetail),
219
298
  complete: getMetadataComplete(sessionDetail),
220
299
  };
221
300
  }
222
301
  const candidateKeys = Object.keys(candidate);
223
302
  const isMetadataOnlyPayload = candidateKeys.length > 0 &&
224
- candidateKeys.every((key) => key === "complete" ||
225
- key === "component" ||
226
- key === "timestamp" ||
227
- key === "sourceRuntimeId");
303
+ candidateKeys.every((key) => METADATA_ONLY_SESSION_KEYS.has(key));
228
304
  if (isMetadataOnlyPayload) {
229
305
  return {
230
306
  itemId: safeItemId,
231
307
  session: null,
232
308
  intent: "metadata-only",
233
309
  component: getMetadataComponent(sessionDetail, candidate),
310
+ ...withElementId(sessionDetail),
234
311
  complete: getMetadataComplete(sessionDetail, candidate),
235
312
  };
236
313
  }
@@ -240,6 +317,7 @@ export function normalizeItemSessionChange(args) {
240
317
  session: null,
241
318
  intent: "metadata-only",
242
319
  component: getMetadataComponent(sessionDetail, candidate),
320
+ ...withElementId(sessionDetail, candidate),
243
321
  complete: getMetadataComplete(sessionDetail, candidate),
244
322
  };
245
323
  }
@@ -251,6 +329,115 @@ export function normalizeItemSessionChange(args) {
251
329
  session: merged,
252
330
  intent: "merge-element-session",
253
331
  component,
332
+ ...withElementId(sessionDetail, candidate),
254
333
  complete: getMetadataComplete(sessionDetail, candidate),
255
334
  };
256
335
  }
336
+ function hostContainerEntries(hostContainer) {
337
+ if (!isPlainObject(hostContainer)) {
338
+ return null;
339
+ }
340
+ // A frozen container is not a live view of anything, and a shared immutable
341
+ // default (one object handed to every item) must never take one item's
342
+ // entries. Both say the same thing: do not project here.
343
+ if (Object.isFrozen(hostContainer)) {
344
+ return null;
345
+ }
346
+ const existing = hostContainer.data;
347
+ if (Array.isArray(existing)) {
348
+ return Object.isFrozen(existing) ? null : existing;
349
+ }
350
+ if (existing === undefined || existing === null) {
351
+ const created = [];
352
+ hostContainer.data = created;
353
+ return created;
354
+ }
355
+ return null;
356
+ }
357
+ function sessionEntryId(value) {
358
+ if (!isPlainObject(value)) {
359
+ return null;
360
+ }
361
+ const id = value.id;
362
+ return typeof id === "string" && id.length > 0 ? id : null;
363
+ }
364
+ /**
365
+ * Create the bare `{ id }` entry `<pie-player>` created at render time, for each
366
+ * element the item declares. A host that indexes `data[0]` before the learner
367
+ * has answered finds an entry instead of `undefined`.
368
+ *
369
+ * Only the host's container gets these. The authoritative session stays empty
370
+ * until an element reports something, so an untouched item still saves nothing.
371
+ */
372
+ export function ensureHostSessionEntries(hostContainer, entryIds) {
373
+ const entries = hostContainerEntries(hostContainer);
374
+ if (!entries) {
375
+ return false;
376
+ }
377
+ let wrote = false;
378
+ for (const id of entryIds) {
379
+ if (typeof id !== "string" || id.length === 0) {
380
+ continue;
381
+ }
382
+ if (entries.some((entry) => sessionEntryId(entry) === id)) {
383
+ continue;
384
+ }
385
+ entries.push({ id });
386
+ wrote = true;
387
+ }
388
+ return wrote;
389
+ }
390
+ /**
391
+ * Keep the container a host handed in as a live view of the session, the
392
+ * contract `<pie-player>` had: `findOrAddSession` pushed an entry per element
393
+ * into the host's own `data` array and the element mutated that entry in place,
394
+ * so a host read the response off its own object. `ItemController` owns the
395
+ * session here, so this runs one way and never reads the container back.
396
+ *
397
+ * The array and the entry objects keep their identity, for a host holding a
398
+ * reference into `data`. Entries this player did not produce are left alone: in
399
+ * a section the host's container spans every item and an item player sees one.
400
+ *
401
+ * Inside an entry the player's own session wins, and a key it does not carry is
402
+ * removed - a cleared response has to clear here too, and the entry belongs to
403
+ * the element, as it did under `<pie-player>`. A host keeping its own
404
+ * annotations on an entry keeps them beside it, not inside it.
405
+ */
406
+ export function projectSessionIntoHostContainer(hostContainer, liveSession) {
407
+ const entries = hostContainerEntries(hostContainer);
408
+ if (!entries || !isPlainObject(hostContainer)) {
409
+ return false;
410
+ }
411
+ const live = normalizeItemSessionContainer(liveSession);
412
+ let wrote = false;
413
+ // The host owns the container id; fill it only when there is nothing there.
414
+ if (typeof hostContainer.id !== "string") {
415
+ hostContainer.id = live.id;
416
+ wrote = true;
417
+ }
418
+ for (const nextEntry of live.data) {
419
+ const id = sessionEntryId(nextEntry);
420
+ if (!id || !isPlainObject(nextEntry)) {
421
+ continue;
422
+ }
423
+ const target = entries.find((entry) => sessionEntryId(entry) === id);
424
+ if (!isPlainObject(target)) {
425
+ entries.push({ ...nextEntry });
426
+ wrote = true;
427
+ continue;
428
+ }
429
+ for (const key of Object.keys(target)) {
430
+ if (!(key in nextEntry)) {
431
+ delete target[key];
432
+ wrote = true;
433
+ }
434
+ }
435
+ for (const [key, value] of Object.entries(nextEntry)) {
436
+ if (target[key] !== value) {
437
+ target[key] = value;
438
+ wrote = true;
439
+ }
440
+ }
441
+ }
442
+ return wrote;
443
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Math in an item's own markup: what the item and passage markup carry outside
3
+ * every PIE element.
4
+ *
5
+ * Each element typesets its own subtree, so the player hands its renderer only
6
+ * the markup around them. A renderer finds math below the element it is given,
7
+ * so no root holds a PIE element: the markup splits into the elements that hold
8
+ * none, and math sharing a parent with one is wrapped in a span of its own.
9
+ */
10
+ import type { MathRenderer } from "./math-rendering.js";
11
+ /**
12
+ * The parts of `container` holding math and no PIE element, each one a root to
13
+ * typeset. `pieTags` are the tag names of the PIE elements its markup places.
14
+ */
15
+ export declare function collectMarkupMathRoots(container: HTMLElement, pieTags: ReadonlySet<string>): HTMLElement[];
16
+ /**
17
+ * Typesets the math in `containers` outside every PIE element with `render`,
18
+ * one root at a time.
19
+ */
20
+ export declare function typesetMarkupMath(containers: Iterable<HTMLElement>, pieTags: ReadonlySet<string>, render: MathRenderer): Promise<void>;
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Math in an item's own markup: what the item and passage markup carry outside
3
+ * every PIE element.
4
+ *
5
+ * Each element typesets its own subtree, so the player hands its renderer only
6
+ * the markup around them. A renderer finds math below the element it is given,
7
+ * so no root holds a PIE element: the markup splits into the elements that hold
8
+ * none, and math sharing a parent with one is wrapped in a span of its own.
9
+ */
10
+ /** TeX as `@pie-element/shared-math-rendering-mathjax` detects it. */
11
+ const TEX = /\\\(|\\\[|\$\$|\\\$|\\begin\{|\\(?:eq)?ref\{/;
12
+ /** The same, with `$…$` inline math turned on by the legacy opt-in. */
13
+ const TEX_WITH_SINGLE_DOLLAR = /\\\(|\\\[|\$|\\begin\{|\\(?:eq)?ref\{/;
14
+ /** Elements whose content MathJax leaves alone, as both PIE configurations list them. */
15
+ const SKIPPED_ELEMENTS = new Set([
16
+ "script",
17
+ "noscript",
18
+ "style",
19
+ "textarea",
20
+ "pre",
21
+ "code",
22
+ "annotation",
23
+ "annotation-xml",
24
+ "mjx-container",
25
+ "mjx-assistive-mml",
26
+ ]);
27
+ /** The class an author marks content with to keep MathJax out of it. */
28
+ const IGNORED_CLASS = /(?:^|\s)mathjax_ignore(?:\s|$)/;
29
+ const HTML_NAMESPACE = "http://www.w3.org/1999/xhtml";
30
+ function texPattern() {
31
+ const legacy = globalThis["@pie-lib/math-rendering@2"];
32
+ return legacy?.opts?.useSingleDollar ? TEX_WITH_SINGLE_DOLLAR : TEX;
33
+ }
34
+ function isSkipped(element) {
35
+ return (SKIPPED_ELEMENTS.has(element.localName) ||
36
+ IGNORED_CLASS.test(element.getAttribute("class") ?? ""));
37
+ }
38
+ /** Math a renderer would only find on `element` itself, never below it. */
39
+ function holdsOwnMath(element) {
40
+ return (element.localName === "math" ||
41
+ (element.hasAttribute("data-latex") &&
42
+ !element.hasAttribute("data-math-handled")));
43
+ }
44
+ function holdsMath(element, tex) {
45
+ if (holdsOwnMath(element) ||
46
+ element.querySelector("[data-latex]:not([data-math-handled])")) {
47
+ return true;
48
+ }
49
+ for (const math of element.getElementsByTagName("math")) {
50
+ if (!math.closest("mjx-assistive-mml"))
51
+ return true;
52
+ }
53
+ return tex.test(element.textContent ?? "");
54
+ }
55
+ function wrapInSpan(node) {
56
+ const span = (node.ownerDocument ?? document).createElement("span");
57
+ node.replaceWith(span);
58
+ span.append(node);
59
+ return span;
60
+ }
61
+ /**
62
+ * The parts of `container` holding math and no PIE element, each one a root to
63
+ * typeset. `pieTags` are the tag names of the PIE elements its markup places.
64
+ */
65
+ export function collectMarkupMathRoots(container, pieTags) {
66
+ const tex = texPattern();
67
+ const holdsPie = new Set();
68
+ for (const tag of pieTags) {
69
+ for (const element of container.getElementsByTagName(tag)) {
70
+ for (let ancestor = element.parentElement; ancestor && !holdsPie.has(ancestor); ancestor = ancestor.parentElement) {
71
+ holdsPie.add(ancestor);
72
+ if (ancestor === container)
73
+ break;
74
+ }
75
+ }
76
+ }
77
+ if (!holdsPie.has(container)) {
78
+ return holdsMath(container, tex) ? [container] : [];
79
+ }
80
+ const roots = [];
81
+ const visit = (parent) => {
82
+ for (const child of [...parent.childNodes]) {
83
+ if (child.nodeType === Node.TEXT_NODE) {
84
+ if (tex.test(child.data))
85
+ roots.push(wrapInSpan(child));
86
+ continue;
87
+ }
88
+ if (child.nodeType !== Node.ELEMENT_NODE)
89
+ continue;
90
+ const element = child;
91
+ if (pieTags.has(element.localName) || isSkipped(element))
92
+ continue;
93
+ if (holdsPie.has(element)) {
94
+ visit(element);
95
+ }
96
+ else if (holdsMath(element, tex)) {
97
+ if (holdsOwnMath(element)) {
98
+ roots.push(wrapInSpan(element));
99
+ }
100
+ else if (element.namespaceURI === HTML_NAMESPACE) {
101
+ roots.push(element);
102
+ }
103
+ }
104
+ }
105
+ };
106
+ visit(container);
107
+ return roots;
108
+ }
109
+ /**
110
+ * Typesets the math in `containers` outside every PIE element with `render`,
111
+ * one root at a time.
112
+ */
113
+ export async function typesetMarkupMath(containers, pieTags, render) {
114
+ const roots = [...containers].flatMap((container) => collectMarkupMathRoots(container, pieTags));
115
+ for (const root of roots) {
116
+ await render(root);
117
+ }
118
+ }