@pie-players/pie-assessment-toolkit 0.3.75 → 1.0.0-beta.1

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 (142) hide show
  1. package/README.md +451 -541
  2. package/custom-elements.json +1315 -0
  3. package/dist/attempt/TestSession.d.ts +0 -1
  4. package/dist/attempt/TestSession.js +10 -5
  5. package/dist/components/ItemScope.custom-element.js +1 -1
  6. package/dist/components/ItemToolBar.custom-element.js +1 -1
  7. package/dist/components/PieAssessmentToolkit.custom-element.js +8 -8
  8. package/dist/components/SectionToolBar.custom-element.js +1 -1
  9. package/dist/components/chunks/{ItemToolBar-7tt7v2xp.js → ItemToolBar-3zrc4p3d.js} +4 -4
  10. package/dist/components/chunks/{ItemToolBar-jx38w99s.js → ItemToolBar-66hcwmfw.js} +2 -2
  11. package/dist/components/chunks/ItemToolBar-asenkvx6.js +40 -0
  12. package/dist/components/chunks/ItemToolBar-hzc5wyjp.js +2 -0
  13. package/dist/components/chunks/{ItemToolBar-97hx60z4.js → ItemToolBar-kkj69ptr.js} +1 -1
  14. package/dist/components/chunks/sre-engine-bj997w4b.js +1 -0
  15. package/dist/context/assessment-toolkit-context.d.ts +6 -0
  16. package/dist/index.d.ts +7 -7
  17. package/dist/index.js +4 -10
  18. package/dist/policy/core/PolicySource.d.ts +1 -1
  19. package/dist/policy/core/ToolPolicyEngine.d.ts +46 -7
  20. package/dist/policy/core/ToolPolicyEngine.js +73 -16
  21. package/dist/policy/core/compose-decision.d.ts +3 -3
  22. package/dist/policy/core/compose-decision.js +32 -30
  23. package/dist/policy/core/decision-types.d.ts +51 -34
  24. package/dist/policy/core/feature-decision.d.ts +17 -15
  25. package/dist/policy/core/feature-decision.js +11 -13
  26. package/dist/policy/core/pnp-policy-inputs.d.ts +7 -3
  27. package/dist/policy/core/pnp-policy-inputs.js +17 -33
  28. package/dist/policy/core/provenance.d.ts +2 -2
  29. package/dist/policy/core/tool-parameters.d.ts +12 -0
  30. package/dist/policy/core/tool-parameters.js +27 -0
  31. package/dist/policy/engine.d.ts +1 -1
  32. package/dist/policy/sources/PnpPolicySource.d.ts +18 -24
  33. package/dist/policy/sources/PnpPolicySource.js +57 -29
  34. package/dist/runtime/SectionControllerBinding.d.ts +6 -2
  35. package/dist/runtime/SectionControllerBinding.js +21 -15
  36. package/dist/runtime/adapter/subscriber-fanout.d.ts +0 -1
  37. package/dist/runtime/adapter/subscriber-fanout.js +1 -4
  38. package/dist/runtime/composition-emit-scheduler.d.ts +3 -3
  39. package/dist/runtime/composition-emit-scheduler.js +1 -1
  40. package/dist/runtime/content-region.d.ts +0 -4
  41. package/dist/runtime/content-region.js +2 -2
  42. package/dist/runtime/core/SectionEngineCore.d.ts +0 -2
  43. package/dist/runtime/core/SectionEngineCore.js +0 -4
  44. package/dist/runtime/core/cohort.d.ts +0 -5
  45. package/dist/runtime/core/cohort.js +0 -9
  46. package/dist/runtime/core/engine-output.d.ts +3 -2
  47. package/dist/runtime/core/engine-transition.js +3 -2
  48. package/dist/runtime/registration-events.d.ts +11 -14
  49. package/dist/runtime/runtime-id.d.ts +1 -1
  50. package/dist/runtime/runtime-id.js +1 -1
  51. package/dist/runtime/shell-scope.d.ts +4 -4
  52. package/dist/runtime/shell-scope.js +15 -9
  53. package/dist/runtime/tool-host-contract.d.ts +0 -4
  54. package/dist/runtime/tool-host-contract.js +1 -1
  55. package/dist/services/AccessibilityCatalogResolver.d.ts +21 -31
  56. package/dist/services/AccessibilityCatalogResolver.js +29 -42
  57. package/dist/services/ElementToolStateStore.d.ts +32 -61
  58. package/dist/services/ElementToolStateStore.js +37 -67
  59. package/dist/services/HighlightCoordinator.d.ts +9 -21
  60. package/dist/services/HighlightCoordinator.js +52 -80
  61. package/dist/services/RangeSerializer.d.ts +4 -35
  62. package/dist/services/RangeSerializer.js +4 -48
  63. package/dist/services/SSMLExtractor.d.ts +8 -31
  64. package/dist/services/SSMLExtractor.js +18 -40
  65. package/dist/services/TTSService.d.ts +51 -57
  66. package/dist/services/TTSService.js +323 -284
  67. package/dist/services/ToolCoordinator.d.ts +6 -49
  68. package/dist/services/ToolCoordinator.js +25 -61
  69. package/dist/services/ToolRegistry.d.ts +83 -80
  70. package/dist/services/ToolRegistry.js +91 -67
  71. package/dist/services/ToolkitCoordinator.d.ts +83 -81
  72. package/dist/services/ToolkitCoordinator.js +186 -174
  73. package/dist/services/framework-error.d.ts +30 -7
  74. package/dist/services/framework-error.js +10 -13
  75. package/dist/services/interfaces.d.ts +51 -71
  76. package/dist/services/section-controller-types.d.ts +7 -7
  77. package/dist/services/section-session-assignment.js +6 -8
  78. package/dist/services/spoken-audio-cards.d.ts +2 -4
  79. package/dist/services/spoken-audio-cards.js +2 -4
  80. package/dist/services/tool-config-validation.d.ts +5 -7
  81. package/dist/services/tool-config-validation.js +11 -26
  82. package/dist/services/tool-context.d.ts +39 -24
  83. package/dist/services/tool-context.js +158 -92
  84. package/dist/services/tool-failure.d.ts +30 -0
  85. package/dist/services/tool-failure.js +11 -0
  86. package/dist/services/tool-icons.d.ts +1 -2
  87. package/dist/services/tool-icons.js +2 -2
  88. package/dist/services/tool-instance-id.d.ts +8 -6
  89. package/dist/services/tool-instance-id.js +12 -18
  90. package/dist/services/tool-providers/TTSToolProvider.d.ts +6 -43
  91. package/dist/services/tool-providers/TTSToolProvider.js +7 -63
  92. package/dist/services/tool-providers/ToolProviderApi.d.ts +0 -7
  93. package/dist/services/tool-providers/ToolProviderRegistry.d.ts +7 -42
  94. package/dist/services/tool-providers/ToolProviderRegistry.js +21 -63
  95. package/dist/services/tool-request.d.ts +4 -0
  96. package/dist/services/tool-request.js +7 -2
  97. package/dist/services/toolbar-decided-tools.d.ts +2 -0
  98. package/dist/services/toolbar-decided-tools.js +2 -0
  99. package/dist/services/toolbar-items.js +2 -5
  100. package/dist/services/tools-config-normalizer.d.ts +18 -9
  101. package/dist/services/tools-config-normalizer.js +25 -19
  102. package/dist/services/tts/browser-provider.d.ts +2 -5
  103. package/dist/services/tts/browser-provider.js +13 -22
  104. package/dist/services/tts/catalog-span-alignment.js +14 -7
  105. package/dist/services/tts/flat-tree.d.ts +0 -7
  106. package/dist/services/tts/flat-tree.js +2 -2
  107. package/dist/services/tts/generated-speech/dom/to-chunk-inputs.d.ts +2 -2
  108. package/dist/services/tts/generated-speech/index.d.ts +1 -1
  109. package/dist/services/tts/generated-speech/index.js +1 -1
  110. package/dist/services/tts/generated-speech/types.d.ts +1 -1
  111. package/dist/services/tts/generated-speech/types.js +1 -1
  112. package/dist/services/tts/highlight-pipeline/create-highlight-plan.js +10 -12
  113. package/dist/services/tts/highlight-target-resolver.d.ts +12 -0
  114. package/dist/services/tts/math-aware-text-processing.js +10 -29
  115. package/dist/services/tts/math-speech.d.ts +1 -1
  116. package/dist/services/tts/math-speech.js +3 -0
  117. package/dist/services/tts/mathml-sanitization.d.ts +7 -0
  118. package/dist/services/tts/mathml-sanitization.js +9 -1
  119. package/dist/services/tts/provider-options.d.ts +12 -1
  120. package/dist/services/tts/ssml/spoken-text.js +1 -1
  121. package/dist/services/tts/start-failure.d.ts +0 -7
  122. package/dist/services/tts/start-failure.js +1 -1
  123. package/dist/services/tts/text-segmentation.d.ts +5 -0
  124. package/dist/services/tts/text-segmentation.js +13 -10
  125. package/dist/services/tts-runtime-config.d.ts +32 -12
  126. package/dist/services/tts-runtime-config.js +14 -20
  127. package/dist/tools/content-capability-resolution.d.ts +2 -2
  128. package/dist/tools/registration.d.ts +7 -6
  129. package/dist/tools/registration.js +10 -9
  130. package/dist/tools/registrations/toolbar-registration-helpers.d.ts +6 -0
  131. package/dist/tools/registrations/toolbar-registration-helpers.js +6 -0
  132. package/dist/tools/tool-surface-host.js +1 -8
  133. package/package.json +8 -6
  134. package/dist/components/chunks/ItemToolBar-bxwyhr5p.js +0 -2
  135. package/dist/components/chunks/ItemToolBar-rd2bb4p6.js +0 -40
  136. package/dist/components/chunks/sre-engine-jd8cc08x.js +0 -1
  137. package/dist/services/ContextVariableStore.d.ts +0 -113
  138. package/dist/services/ContextVariableStore.js +0 -206
  139. package/dist/services/tts-control-events.d.ts +0 -13
  140. package/dist/services/tts-control-events.js +0 -7
  141. package/dist/utils/logger.d.ts +0 -6
  142. package/dist/utils/logger.js +0 -10
@@ -11,8 +11,6 @@
11
11
  * - Configurable colors and styles
12
12
  * - Annotation persistence via RangeSerializer
13
13
  *
14
- * Part of PIE Assessment Toolkit.
15
- *
16
14
  * Browser Support:
17
15
  * - Chrome/Edge 105+
18
16
  * - Safari 17.2+
@@ -383,31 +381,55 @@ export class HighlightCoordinator {
383
381
  this.applyAdaptiveTTSStyle();
384
382
  this.setupThemeObservation();
385
383
  }
384
+ /**
385
+ * Re-adapt the TTS colors when the page's theme moves: on the document
386
+ * element, on a `<pie-theme>` host, and when a `<pie-theme>` mounts, which
387
+ * themes its target as it connects.
388
+ */
386
389
  setupThemeObservation() {
387
390
  if (typeof document === "undefined")
388
391
  return;
389
392
  if (typeof MutationObserver === "undefined")
390
393
  return;
391
- const refresh = () => this.applyAdaptiveTTSStyle();
392
- this.themeObserver = new MutationObserver(refresh);
393
- this.themeObserver.observe(document.documentElement, {
394
+ const themeHosts = document.getElementsByTagName("pie-theme");
395
+ const observedHosts = new WeakSet();
396
+ const observeNewHosts = () => {
397
+ let found = false;
398
+ for (const host of themeHosts) {
399
+ if (observedHosts.has(host))
400
+ continue;
401
+ observedHosts.add(host);
402
+ observer.observe(host, {
403
+ attributes: true,
404
+ attributeFilter: [
405
+ "theme",
406
+ "scheme",
407
+ "provider",
408
+ "variables",
409
+ "style",
410
+ "data-theme",
411
+ "data-color-scheme",
412
+ ],
413
+ });
414
+ found = true;
415
+ }
416
+ return found;
417
+ };
418
+ const observer = new MutationObserver((records) => {
419
+ const mounted = observeNewHosts();
420
+ if (mounted || records.some((record) => record.type === "attributes")) {
421
+ this.applyAdaptiveTTSStyle();
422
+ }
423
+ });
424
+ this.themeObserver = observer;
425
+ observer.observe(document.documentElement, {
394
426
  attributes: true,
395
427
  attributeFilter: ["style", "data-theme", "data-color-scheme", "class"],
396
428
  });
397
- for (const host of document.querySelectorAll("pie-theme")) {
398
- this.themeObserver.observe(host, {
399
- attributes: true,
400
- attributeFilter: [
401
- "theme",
402
- "scheme",
403
- "provider",
404
- "variables",
405
- "style",
406
- "data-theme",
407
- "data-color-scheme",
408
- ],
409
- });
410
- }
429
+ // Insertions, so a `<pie-theme>` mounted later is found. An insertion
430
+ // costs a walk of the live host list, which holds a handful at most.
431
+ observer.observe(document, { childList: true, subtree: true });
432
+ observeNewHosts();
411
433
  }
412
434
  parseColor(input) {
413
435
  if (!input)
@@ -511,6 +533,8 @@ export class HighlightCoordinator {
511
533
  : [0.8, 0.72, 0.68, 0.62, 0.56, 0.5];
512
534
  let selectedOpacity = opacityCandidates[opacityCandidates.length - 1];
513
535
  let bestScore = -Infinity;
536
+ // The first opacity whose blend clears 1.25:1 against the background and
537
+ // 2.4:1 against the text wins; otherwise the best weighted score.
514
538
  for (const opacity of opacityCandidates) {
515
539
  const blended = this.blend(accent, background, opacity);
516
540
  const backgroundDelta = this.contrastRatio(blended, background);
@@ -593,7 +617,6 @@ export class HighlightCoordinator {
593
617
  return;
594
618
  if (typeof document === "undefined")
595
619
  return; // SSR guard
596
- // Check if styles already exist
597
620
  if (document.getElementById("pie-highlight-styles"))
598
621
  return;
599
622
  const style = document.createElement("style");
@@ -633,12 +656,10 @@ export class HighlightCoordinator {
633
656
  * lives in a font-driven pseudo-element a CSS range cannot paint) and
634
657
  * whole-expression fallbacks.
635
658
  *
636
- * The supplied element is painted exactly; we deliberately do NOT walk up to
637
- * a containing `<math>` / `<mjx-container>`. That escalation is what made
638
- * MathJax-rendered math highlight as a full block while native MathML tracked
639
- * per-token: a resolved single token must paint as a token, and only a
640
- * genuine expression fallback (where the element already is the container)
641
- * should paint the whole expression.
659
+ * The supplied element is painted exactly, with no walk up to a containing
660
+ * `<math>` / `<mjx-container>`: a resolved token paints as a token, and only
661
+ * an expression fallback, where the element already is the container, paints
662
+ * the whole expression.
642
663
  */
643
664
  highlightTTSWordElement(element) {
644
665
  if (!this.supported)
@@ -660,7 +681,6 @@ export class HighlightCoordinator {
660
681
  if (!this.supported)
661
682
  return;
662
683
  this.applyAdaptiveTTSStyle(startElementOf(ranges));
663
- // Clear previous sentence highlight
664
684
  this.clearTTSSentence();
665
685
  const highlight = sharedHighlight(TTS_SENTENCE_HIGHLIGHT);
666
686
  for (const range of ranges) {
@@ -689,12 +709,11 @@ export class HighlightCoordinator {
689
709
  // The read-along (sentence) band legitimately covers whole equations and
690
710
  // replaced elements, so it escalates to them.
691
711
  static SENTENCE_FALLBACK_SELECTOR = "math, mjx-container, svg, img, canvas, [role='img']";
692
- // The word layer must NOT escalate a multi-node range to the enclosing
693
- // `<math>` / `<mjx-container>`: that is what made an equation flash as a
694
- // whole block whenever a spoken word's visible range crossed several math
695
- // glyph nodes. Math is tracked per token (or held / region) by the highlight
696
- // pipeline instead. Replaced elements (svg/img/canvas) still need the element
697
- // fallback because a CSS range cannot paint them.
712
+ // The word layer falls back to the element only for replaced elements
713
+ // (svg/img/canvas), which a CSS range cannot paint. Escalating to the
714
+ // enclosing `<math>` / `<mjx-container>` flashed the whole equation whenever
715
+ // a word crossed several glyph nodes, so math is tracked per token, held or
716
+ // by region in the highlight pipeline.
698
717
  static WORD_FALLBACK_SELECTOR = "svg, img, canvas, [role='img']";
699
718
  highlightTTSElementFallbacks(ranges) {
700
719
  if (typeof Element === "undefined")
@@ -796,10 +815,8 @@ export class HighlightCoordinator {
796
815
  */
797
816
  addAnnotation(range, color = HighlightColor.YELLOW) {
798
817
  const id = `annotation-${this.nextAnnotationId++}`;
799
- // Clone the range to store
800
818
  const clonedRange = range.cloneRange();
801
819
  adoptHighlightStylesFor(clonedRange.startContainer);
802
- // Store annotation data
803
820
  const annotation = {
804
821
  id,
805
822
  range: clonedRange,
@@ -913,44 +930,6 @@ export class HighlightCoordinator {
913
930
  // ============================================================================
914
931
  // HighlightCoordinatorApi interface implementation
915
932
  // ============================================================================
916
- /**
917
- * Highlight a range as the given type: the TTS types paint as
918
- * {@link highlightTTSWord} and {@link highlightTTSSentence} do.
919
- */
920
- highlightRange(range, type, color = HighlightColor.YELLOW) {
921
- if (!this.supported)
922
- return;
923
- switch (type) {
924
- case HighlightType.TTS_WORD:
925
- this.highlightTTSWord([range]);
926
- break;
927
- case HighlightType.TTS_SENTENCE:
928
- this.highlightTTSSentence([range]);
929
- break;
930
- case HighlightType.ANNOTATION:
931
- // For annotations, use the existing method
932
- this.addAnnotation(range, color);
933
- break;
934
- }
935
- }
936
- /**
937
- * Clear highlights of a specific type (interface method)
938
- */
939
- clearHighlights(type) {
940
- if (!this.supported)
941
- return;
942
- switch (type) {
943
- case HighlightType.TTS_WORD:
944
- this.clearTTSWord();
945
- break;
946
- case HighlightType.TTS_SENTENCE:
947
- this.clearTTSSentence();
948
- break;
949
- case HighlightType.ANNOTATION:
950
- this.clearAnnotations();
951
- break;
952
- }
953
- }
954
933
  /**
955
934
  * Clear all highlights (interface method)
956
935
  */
@@ -960,12 +939,6 @@ export class HighlightCoordinator {
960
939
  this.clearTTS();
961
940
  this.clearAnnotations();
962
941
  }
963
- /**
964
- * Check if CSS Highlight API is supported (interface method)
965
- */
966
- isSupported() {
967
- return this.supported;
968
- }
969
942
  /**
970
943
  * Update TTS highlight style dynamically
971
944
  *
@@ -980,7 +953,6 @@ export class HighlightCoordinator {
980
953
  const styleEl = document.getElementById("pie-highlight-styles");
981
954
  if (!styleEl)
982
955
  return;
983
- // Convert hex to rgba
984
956
  const hexToRgb = (hex) => {
985
957
  const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
986
958
  return result
@@ -7,7 +7,8 @@
7
7
  * Uses CSS selector paths and node indices for robust serialization that
8
8
  * survives content changes when possible. A path into an open shadow root names
9
9
  * its host, then ` >>> `, then the path inside the root, so ranges in content
10
- * that renders into shadow roots round-trip; paths without one read as before.
10
+ * that renders into shadow roots round-trip; a path with no ` >>> ` is a plain
11
+ * selector path.
11
12
  */
12
13
  /**
13
14
  * Serialized form of a Range suitable for storage.
@@ -79,19 +80,9 @@ export declare class RangeSerializer {
79
80
  * Uses a hybrid approach:
80
81
  * - For element nodes: CSS selector path
81
82
  * - For text nodes: parent selector + text node index
82
- *
83
- * @param node - Node to get path for
84
- * @param root - Root element
85
- * @returns Path string
86
83
  */
87
84
  private getNodePath;
88
- /**
89
- * Get CSS selector path to an element.
90
- *
91
- * @param element - Element to get path for
92
- * @param root - Root element
93
- * @returns CSS selector path
94
- */
85
+ /** Get CSS selector path to an element. */
95
86
  private getElementPath;
96
87
  /** The path to `shadowRoot` itself: its host's path and an empty step into it. */
97
88
  private getShadowRootPath;
@@ -100,28 +91,6 @@ export declare class RangeSerializer {
100
91
  * {@link SHADOW_STEP} continues in the shadow root of what the step before named.
101
92
  */
102
93
  private resolveElementPath;
103
- /**
104
- * Find a node by its path from root.
105
- *
106
- * @param path - Path string from getNodePath
107
- * @param root - Root element
108
- * @returns Node, or null if not found
109
- */
94
+ /** The node a getNodePath path names, or null if not found. */
110
95
  private findNodeByPath;
111
- /**
112
- * Batch serialize multiple ranges.
113
- *
114
- * @param ranges - Ranges to serialize
115
- * @param root - Root element
116
- * @returns Array of serialized ranges
117
- */
118
- serializeMany(ranges: Range[], root: Element): SerializedRange[];
119
- /**
120
- * Batch deserialize multiple ranges.
121
- *
122
- * @param data - Array of serialized ranges
123
- * @param root - Root element
124
- * @returns Array of ranges (nulls for invalid ranges)
125
- */
126
- deserializeMany(data: SerializedRange[], root: Element): (Range | null)[];
127
96
  }
@@ -7,7 +7,8 @@
7
7
  * Uses CSS selector paths and node indices for robust serialization that
8
8
  * survives content changes when possible. A path into an open shadow root names
9
9
  * its host, then ` >>> `, then the path inside the root, so ranges in content
10
- * that renders into shadow roots round-trip; paths without one read as before.
10
+ * that renders into shadow roots round-trip; a path with no ` >>> ` is a plain
11
+ * selector path.
11
12
  */
12
13
  import { isShadowRootNode } from "./tts/flat-tree.js";
13
14
  /** Separates a shadow host's path from the path inside its shadow root. */
@@ -69,11 +70,9 @@ export class RangeSerializer {
69
70
  const range = new Range();
70
71
  range.setStart(startNode, data.startOffset);
71
72
  range.setEnd(endNode, data.endOffset);
72
- // Validate text hasn't changed
73
73
  if (range.toString() === data.text) {
74
74
  return range;
75
75
  }
76
- // Text changed, range is invalid
77
76
  return null;
78
77
  }
79
78
  catch (error) {
@@ -88,22 +87,16 @@ export class RangeSerializer {
88
87
  * Uses a hybrid approach:
89
88
  * - For element nodes: CSS selector path
90
89
  * - For text nodes: parent selector + text node index
91
- *
92
- * @param node - Node to get path for
93
- * @param root - Root element
94
- * @returns Path string
95
90
  */
96
91
  getNodePath(node, root) {
97
92
  if (node === root) {
98
93
  return "";
99
94
  }
100
- // Handle text nodes
101
95
  if (node.nodeType === Node.TEXT_NODE) {
102
96
  const parent = node.parentNode;
103
97
  if (!parent) {
104
98
  throw new Error("Text node has no parent");
105
99
  }
106
- // Get index of this text node among its siblings
107
100
  const textNodes = Array.from(parent.childNodes).filter((n) => n.nodeType === Node.TEXT_NODE);
108
101
  const index = textNodes.indexOf(node);
109
102
  const parentPath = isShadowRootNode(parent)
@@ -111,7 +104,6 @@ export class RangeSerializer {
111
104
  : this.getElementPath(parent, root);
112
105
  return `${parentPath}::text[${index}]`;
113
106
  }
114
- // Handle element nodes
115
107
  if (node.nodeType === Node.ELEMENT_NODE) {
116
108
  return this.getElementPath(node, root);
117
109
  }
@@ -120,13 +112,7 @@ export class RangeSerializer {
120
112
  }
121
113
  throw new Error(`Unsupported node type: ${node.nodeType}`);
122
114
  }
123
- /**
124
- * Get CSS selector path to an element.
125
- *
126
- * @param element - Element to get path for
127
- * @param root - Root element
128
- * @returns CSS selector path
129
- */
115
+ /** Get CSS selector path to an element. */
130
116
  getElementPath(element, root) {
131
117
  if (element === root) {
132
118
  return "";
@@ -207,24 +193,16 @@ export class RangeSerializer {
207
193
  }
208
194
  return scope;
209
195
  }
210
- /**
211
- * Find a node by its path from root.
212
- *
213
- * @param path - Path string from getNodePath
214
- * @param root - Root element
215
- * @returns Node, or null if not found
216
- */
196
+ /** The node a getNodePath path names, or null if not found. */
217
197
  findNodeByPath(path, root) {
218
198
  if (path === "") {
219
199
  return root;
220
200
  }
221
- // Handle text node paths
222
201
  if (path.includes("::text[")) {
223
202
  const marker = path.lastIndexOf("::text[");
224
203
  const elementPath = path.slice(0, marker);
225
204
  const textPart = path.slice(marker + "::text[".length);
226
205
  const textIndex = Number.parseInt(textPart.replace("]", ""), 10);
227
- // Find parent element
228
206
  let parent;
229
207
  try {
230
208
  parent = this.resolveElementPath(elementPath, root);
@@ -235,11 +213,9 @@ export class RangeSerializer {
235
213
  }
236
214
  if (!parent)
237
215
  return null;
238
- // Find text node by index
239
216
  const textNodes = Array.from(parent.childNodes).filter((n) => n.nodeType === Node.TEXT_NODE);
240
217
  return textNodes[textIndex] || null;
241
218
  }
242
- // Handle element paths
243
219
  try {
244
220
  return this.resolveElementPath(path, root);
245
221
  }
@@ -248,24 +224,4 @@ export class RangeSerializer {
248
224
  return null;
249
225
  }
250
226
  }
251
- /**
252
- * Batch serialize multiple ranges.
253
- *
254
- * @param ranges - Ranges to serialize
255
- * @param root - Root element
256
- * @returns Array of serialized ranges
257
- */
258
- serializeMany(ranges, root) {
259
- return ranges.map((range) => this.serialize(range, root));
260
- }
261
- /**
262
- * Batch deserialize multiple ranges.
263
- *
264
- * @param data - Array of serialized ranges
265
- * @param root - Root element
266
- * @returns Array of ranges (nulls for invalid ranges)
267
- */
268
- deserializeMany(data, root) {
269
- return data.map((item) => this.deserialize(item, root));
270
- }
271
227
  }
@@ -13,8 +13,6 @@
13
13
  *
14
14
  * This enables proper pronunciation, emphasis, and pacing for TTS without
15
15
  * requiring authors to maintain separate catalog files.
16
- *
17
- * Part of PIE Assessment Toolkit.
18
16
  */
19
17
  import type { AccessibilityCatalog, ConfigEntity } from "@pie-players/pie-players-shared/types";
20
18
  /**
@@ -25,19 +23,11 @@ export interface ExtractionResult {
25
23
  cleanedConfig: ConfigEntity;
26
24
  }
27
25
  /**
28
- * Sanitize an SSML subtree in place: remove disallowed attributes and
29
- * replace disallowed elements with their text content (so phrasing order
30
- * is preserved but no scripts / iframes / unknown tags survive).
31
- */
32
- export declare function sanitizeSsmlElement(element: Element): void;
33
- /**
34
- * Serialize a sanitized `<speak>` element back to a minimal SSML string.
35
- * Uses a manual walker instead of `outerHTML` so that any namespace
36
- * quirks (e.g. `xmlns` attributes added by the HTML parser) are
37
- * normalised and disallowed descendants cannot reappear through quirks
38
- * in the serializer.
26
+ * SSML cut down to the allow-listed tags and attributes. Input with no markup,
27
+ * or with no DOMParser available, comes back unchanged. Otherwise the first
28
+ * <speak> element comes back sanitized, or, when there is none, the sanitized
29
+ * fragment with no <speak> wrapper.
39
30
  */
40
- export declare function serializeSsmlElement(element: Element): string;
41
31
  export declare function sanitizeSsmlString(input: string): string;
42
32
  /**
43
33
  * SSMLExtractor Service
@@ -46,26 +36,13 @@ export declare function sanitizeSsmlString(input: string): string;
46
36
  */
47
37
  export declare class SSMLExtractor {
48
38
  private catalogCounter;
49
- /**
50
- * Extract SSML from entire item config (markup + models)
51
- *
52
- * @param config Item configuration with potential embedded SSML
53
- * @returns Extracted catalogs and cleaned config
54
- */
39
+ /** Extract SSML from the item's markup, prompts and choice labels. */
55
40
  extractFromItemConfig(config: ConfigEntity): ExtractionResult;
56
- /**
57
- * Extract SSML from markup string
58
- *
59
- * @param markup HTML markup potentially containing <speak> elements
60
- * @param idPrefix Prefix for generating catalog IDs
61
- * @returns Extracted catalogs and cleaned markup
62
- */
41
+ /** Extract SSML from one markup string. */
63
42
  private extractFromMarkup;
64
43
  /**
65
- * Generate unique catalog ID
66
- *
67
- * @param prefix Context prefix (e.g., 'prompt-q1', 'choice-q1-a')
68
- * @returns Unique catalog ID (e.g., 'auto-prompt-q1-0')
44
+ * A catalog id unique to this extractor until reset(), e.g.
45
+ * `auto-prompt-q1-0`.
69
46
  */
70
47
  private generateCatalogId;
71
48
  /**
@@ -13,8 +13,6 @@
13
13
  *
14
14
  * This enables proper pronunciation, emphasis, and pacing for TTS without
15
15
  * requiring authors to maintain separate catalog files.
16
- *
17
- * Part of PIE Assessment Toolkit.
18
16
  */
19
17
  /**
20
18
  * Allow-listed SSML elements (from the SSML 1.1 spec subset we forward to
@@ -38,11 +36,6 @@ const SSML_ALLOWED_TAGS = new Set([
38
36
  "audio",
39
37
  "lexicon",
40
38
  ]);
41
- /**
42
- * Allow-listed attributes across SSML elements. Attribute allow-listing is
43
- * global (not per-tag) because the union is small and consistent across
44
- * providers.
45
- */
46
39
  /**
47
40
  * Tags whose content must be dropped entirely (rather than unwrapped to
48
41
  * preserve phrasing text). These are dangerous containers whose text
@@ -58,6 +51,10 @@ const SSML_DROP_FULLY_TAGS = new Set([
58
51
  "noscript",
59
52
  "template",
60
53
  ]);
54
+ /**
55
+ * Allow-listed attributes across SSML elements. One list covers every tag
56
+ * because the union is small and consistent across providers.
57
+ */
61
58
  const SSML_ALLOWED_ATTRS = new Set([
62
59
  "alphabet",
63
60
  "ph",
@@ -153,7 +150,7 @@ function isSafeSsmlAudioSrc(raw) {
153
150
  * replace disallowed elements with their text content (so phrasing order
154
151
  * is preserved but no scripts / iframes / unknown tags survive).
155
152
  */
156
- export function sanitizeSsmlElement(element) {
153
+ function sanitizeSsmlElement(element) {
157
154
  // Walk children snapshot first because we may replace nodes as we go.
158
155
  const children = Array.from(element.children);
159
156
  for (const child of children) {
@@ -175,10 +172,8 @@ export function sanitizeSsmlElement(element) {
175
172
  element.removeAttribute(attrName);
176
173
  }
177
174
  }
178
- // Belt-and-braces: even though `src` is allow-listed, SSML `<audio>`
179
- // sends its URL to a server-side TTS provider that will dereference
180
- // it. Refuse non-http(s) schemes and private / cloud-metadata hosts
181
- // at the SSML boundary so we never forward an SSRF primitive.
175
+ // `src` survives the attribute allow-list, and a server-side provider
176
+ // fetches it: keep it only for an http(s) URL off SSML_PRIVATE_AUDIO_HOSTS.
182
177
  if (element.tagName.toLowerCase() === "audio") {
183
178
  const src = element.getAttribute("src");
184
179
  if (src !== null && !isSafeSsmlAudioSrc(src)) {
@@ -193,7 +188,7 @@ export function sanitizeSsmlElement(element) {
193
188
  * normalised and disallowed descendants cannot reappear through quirks
194
189
  * in the serializer.
195
190
  */
196
- export function serializeSsmlElement(element) {
191
+ function serializeSsmlElement(element) {
197
192
  const tagName = element.tagName.toLowerCase();
198
193
  const attrs = Array.from(element.attributes)
199
194
  .filter((attr) => SSML_ALLOWED_ATTRS.has(attr.name.toLowerCase()))
@@ -227,6 +222,12 @@ export function serializeSsmlElement(element) {
227
222
  }
228
223
  return `<${tagName}${attrs}>${childPieces.join("")}</${tagName}>`;
229
224
  }
225
+ /**
226
+ * SSML cut down to the allow-listed tags and attributes. Input with no markup,
227
+ * or with no DOMParser available, comes back unchanged. Otherwise the first
228
+ * <speak> element comes back sanitized, or, when there is none, the sanitized
229
+ * fragment with no <speak> wrapper.
230
+ */
230
231
  export function sanitizeSsmlString(input) {
231
232
  const trimmed = String(input || "").trim();
232
233
  if (!trimmed || !trimmed.includes("<"))
@@ -281,12 +282,7 @@ function escapeXmlAttr(value) {
281
282
  */
282
283
  export class SSMLExtractor {
283
284
  catalogCounter = 0;
284
- /**
285
- * Extract SSML from entire item config (markup + models)
286
- *
287
- * @param config Item configuration with potential embedded SSML
288
- * @returns Extracted catalogs and cleaned config
289
- */
285
+ /** Extract SSML from the item's markup, prompts and choice labels. */
290
286
  extractFromItemConfig(config) {
291
287
  const allCatalogs = [];
292
288
  // Shallow clone of config + a fresh models array. The per-model/choice
@@ -336,18 +332,11 @@ export class SSMLExtractor {
336
332
  cleanedConfig,
337
333
  };
338
334
  }
339
- /**
340
- * Extract SSML from markup string
341
- *
342
- * @param markup HTML markup potentially containing <speak> elements
343
- * @param idPrefix Prefix for generating catalog IDs
344
- * @returns Extracted catalogs and cleaned markup
345
- */
335
+ /** Extract SSML from one markup string. */
346
336
  extractFromMarkup(markup, idPrefix) {
347
337
  if (!markup || markup.trim() === "") {
348
338
  return { catalogs: [], cleanedMarkup: markup };
349
339
  }
350
- // Check if running in browser environment
351
340
  if (typeof window === "undefined" || typeof DOMParser === "undefined") {
352
341
  console.warn("[SSMLExtractor] DOMParser not available (SSR?), skipping extraction");
353
342
  return { catalogs: [], cleanedMarkup: markup };
@@ -356,16 +345,12 @@ export class SSMLExtractor {
356
345
  try {
357
346
  const parser = new DOMParser();
358
347
  const doc = parser.parseFromString(expandSelfClosingVoidTags(markup), "text/html");
359
- // Find all <speak> elements
360
348
  const speakElements = Array.from(doc.querySelectorAll("speak"));
361
349
  if (speakElements.length === 0) {
362
- // No SSML found, return original markup
363
350
  return { catalogs: [], cleanedMarkup: markup };
364
351
  }
365
- // Process each <speak> element
366
352
  for (const speakEl of speakElements) {
367
353
  try {
368
- // Generate unique catalog ID
369
354
  const catalogId = this.generateCatalogId(idPrefix);
370
355
  // Sanitize before serializing so the SSML forwarded to the TTS
371
356
  // provider only contains spec-allowed tags/attributes. This
@@ -373,7 +358,6 @@ export class SSMLExtractor {
373
358
  // otherwise accept arbitrary HTML.
374
359
  sanitizeSsmlElement(speakEl);
375
360
  const ssmlContent = serializeSsmlElement(speakEl);
376
- // Extract language (check both xml:lang and lang attributes)
377
361
  const language = speakEl.getAttribute("xml:lang") ||
378
362
  speakEl.getAttribute("lang") ||
379
363
  "en-US";
@@ -409,7 +393,6 @@ export class SSMLExtractor {
409
393
  else {
410
394
  wrapper.setAttribute("data-catalog-idref", catalogId);
411
395
  }
412
- // Create catalog entry
413
396
  catalogs.push({
414
397
  identifier: catalogId,
415
398
  cards: [
@@ -423,24 +406,19 @@ export class SSMLExtractor {
423
406
  }
424
407
  catch (error) {
425
408
  console.error("[SSMLExtractor] Error processing <speak> element:", error);
426
- // Continue with other elements
427
409
  }
428
410
  }
429
- // Return cleaned markup
430
411
  const cleanedMarkup = doc.body.innerHTML;
431
412
  return { catalogs, cleanedMarkup };
432
413
  }
433
414
  catch (error) {
434
415
  console.error("[SSMLExtractor] Error parsing markup:", error);
435
- // Return original markup if parsing fails
436
416
  return { catalogs: [], cleanedMarkup: markup };
437
417
  }
438
418
  }
439
419
  /**
440
- * Generate unique catalog ID
441
- *
442
- * @param prefix Context prefix (e.g., 'prompt-q1', 'choice-q1-a')
443
- * @returns Unique catalog ID (e.g., 'auto-prompt-q1-0')
420
+ * A catalog id unique to this extractor until reset(), e.g.
421
+ * `auto-prompt-q1-0`.
444
422
  */
445
423
  generateCatalogId(prefix) {
446
424
  const id = `auto-${prefix}-${this.catalogCounter}`;