@pie-players/pie-assessment-toolkit 1.0.0-beta.6 → 1.0.0-beta.7

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.
@@ -73,6 +73,14 @@ export interface AssessmentToolkitRuntimeContext {
73
73
  * an interface in another.
74
74
  */
75
75
  contentLanguage?: string;
76
+ /**
77
+ * The content language the item and passage players stamp on content that
78
+ * declares none: {@link contentLanguage} when the host names one, else
79
+ * `auto` (`lang=""` with `dir="auto"`) when the host sets an interface
80
+ * locale, else unset, which leaves undeclared content inheriting the page's
81
+ * language and direction. See `defaultContentLanguage`.
82
+ */
83
+ contentLanguageDefault?: string;
76
84
  /**
77
85
  * Reports a failure that keeps the section's content from loading as the
78
86
  * section controller's `section-error`, with source `section-runtime`.
@@ -1,10 +1,21 @@
1
1
  /** The content language when neither the markup nor the host names one. */
2
2
  export declare const DEFAULT_CONTENT_LANGUAGE = "en-US";
3
3
  /**
4
- * The nearest non-empty `lang` attribute at or above `node`, climbing through
5
- * shadow hosts, and ending after `boundary` when one is given.
4
+ * The nearest `lang` attribute at or above `node`, climbing through shadow
5
+ * hosts, and ending after `boundary` when one is given. An empty `lang` names
6
+ * the language unknown and ends the climb with no language, as it ends the
7
+ * browser's own inheritance: the players stamp it on content that declares
8
+ * none, so the interface's `lang` above never reads as the content's.
6
9
  */
7
10
  export declare const findLangAttribute: (node: Node | null | undefined, boundary?: Element | null) => string | undefined;
11
+ /**
12
+ * The language the content `root` holds is declared in: the `lang` its
13
+ * outermost `lang`-carrying descendants share, or undefined when they disagree,
14
+ * one is empty, or there are none. The players stamp `lang` on the content root
15
+ * they render, below the shell a whole-item read targets, so this is how such
16
+ * a read finds the language an item declared through `env.locale`.
17
+ */
18
+ export declare const findHeldContentLanguage: (root: Element) => string | undefined;
8
19
  export interface ContentLanguageOptions {
9
20
  /**
10
21
  * Where the `lang` climb ends, inclusive. Defaults to the nearest shell scope
@@ -3,8 +3,11 @@ import { findShellScopeHost } from "./content-region.js";
3
3
  /** The content language when neither the markup nor the host names one. */
4
4
  export const DEFAULT_CONTENT_LANGUAGE = "en-US";
5
5
  /**
6
- * The nearest non-empty `lang` attribute at or above `node`, climbing through
7
- * shadow hosts, and ending after `boundary` when one is given.
6
+ * The nearest `lang` attribute at or above `node`, climbing through shadow
7
+ * hosts, and ending after `boundary` when one is given. An empty `lang` names
8
+ * the language unknown and ends the climb with no language, as it ends the
9
+ * browser's own inheritance: the players stamp it on content that declares
10
+ * none, so the interface's `lang` above never reads as the content's.
8
11
  */
9
12
  export const findLangAttribute = (node, boundary) => {
10
13
  let current = !node
@@ -13,15 +16,38 @@ export const findLangAttribute = (node, boundary) => {
13
16
  ? node
14
17
  : composedParentElement(node);
15
18
  while (current) {
16
- const lang = current.getAttribute("lang")?.trim();
17
- if (lang)
18
- return lang;
19
+ const lang = current.getAttribute("lang");
20
+ if (lang !== null)
21
+ return lang.trim() || undefined;
19
22
  if (boundary && current === boundary)
20
23
  return undefined;
21
24
  current = composedParentElement(current);
22
25
  }
23
26
  return undefined;
24
27
  };
28
+ /**
29
+ * The language the content `root` holds is declared in: the `lang` its
30
+ * outermost `lang`-carrying descendants share, or undefined when they disagree,
31
+ * one is empty, or there are none. The players stamp `lang` on the content root
32
+ * they render, below the shell a whole-item read targets, so this is how such
33
+ * a read finds the language an item declared through `env.locale`.
34
+ */
35
+ export const findHeldContentLanguage = (root) => {
36
+ let language;
37
+ for (const element of root.querySelectorAll("[lang]")) {
38
+ const outer = element.parentElement?.closest("[lang]");
39
+ if (outer && outer !== root && root.contains(outer))
40
+ continue;
41
+ const lang = element.getAttribute("lang")?.trim();
42
+ if (!lang)
43
+ return undefined;
44
+ if (language === undefined)
45
+ language = lang;
46
+ else if (language.toLowerCase() !== lang.toLowerCase())
47
+ return undefined;
48
+ }
49
+ return language;
50
+ };
25
51
  /**
26
52
  * The language the content at `target` is named as being in: the nearest `lang`
27
53
  * between it and its shell scope host, else the host's `content-language` input.
@@ -1,4 +1,4 @@
1
- import { languageTagLookupSequence, normalizeLanguageTag, } from "@pie-players/pie-players-shared/i18n/language-tags";
1
+ import { languageTagScriptLookupSequence, languageTagScriptsMatch, normalizeLanguageTag, } from "@pie-players/pie-players-shared/i18n/language-tags";
2
2
  import { catalogOwnerContextFor, collectOwnerCatalogRegistrations, } from "./catalog-owner.js";
3
3
  import { sanitizeSsmlString } from "./SSMLExtractor.js";
4
4
  /**
@@ -538,14 +538,18 @@ export class AccessibilityCatalogResolver {
538
538
  // Language rungs, most specific first: the requested language, then the
539
539
  // default language, then any.
540
540
  //
541
- // Each requested tag expands into its RFC 4647 lookup sequence over
541
+ // Each requested tag expands into its script-aware lookup sequence over
542
542
  // normalized tags, so `es-MX` tries `es-mx` and then `es` before falling
543
543
  // through to the default, and a POSIX `es_ES` card (what the Learnosity
544
- // transform emits) matches an `es-ES` request on its own rung.
544
+ // transform emits) matches an `es-ES` request on its own rung. A shorter
545
+ // card serves only in the request's script: `zh-TW` tries `zh-hant`, and
546
+ // passes a bare `zh` card by, which is Simplified.
545
547
  const languageRungs = [];
546
548
  const pushLookupRungs = (tag) => {
547
- for (const step of languageTagLookupSequence(tag)) {
548
- languageRungs.push((card) => normalizeLanguageTag(card.language) === step);
549
+ const exact = normalizeLanguageTag(tag);
550
+ for (const step of languageTagScriptLookupSequence(tag)) {
551
+ languageRungs.push((card) => normalizeLanguageTag(card.language) === step &&
552
+ (step === exact || languageTagScriptsMatch(tag, card.language)));
549
553
  }
550
554
  };
551
555
  if (language) {
@@ -199,9 +199,10 @@ export declare class TTSService {
199
199
  private getTextProcessingOptions;
200
200
  /**
201
201
  * The language a read of `node` is in. Its content language is the nearest
202
- * `lang` between it and its shell, else the language the speak names, else a
203
- * pinned `lang_id`. Its locale is that content language, else the host's
204
- * configured locale, else en-US.
202
+ * `lang` between it and its shell, else for an element the `lang` the content
203
+ * roots it holds share, else the language the speak names, else a pinned
204
+ * `lang_id`. Its locale is that content language, else the host's configured
205
+ * locale, else en-US.
205
206
  */
206
207
  private resolveReadLanguage;
207
208
  /**
@@ -20,7 +20,7 @@
20
20
  import { applyMediaFragment, enforceMediaFragment, } from "@pie-players/pie-players-shared/media";
21
21
  import { createPieLogger, isTtsDebugEnabled, } from "@pie-players/pie-players-shared/pie";
22
22
  import { resolveSpokenAudioMedia, } from "./spoken-audio-cards.js";
23
- import { DEFAULT_CONTENT_LANGUAGE, findContentLanguage, findLangAttribute, } from "../runtime/content-language.js";
23
+ import { DEFAULT_CONTENT_LANGUAGE, findContentLanguage, findHeldContentLanguage, findLangAttribute, } from "../runtime/content-language.js";
24
24
  import { isSsmlDocument } from "./tts/ssml/spoken-text.js";
25
25
  import { composedContains, composedParentElement, flatQuerySelectorAll, flatTextContent, flatTreeChildNodes, flatTreeParentElement, flatTreeTextNodes, rangeHoldsTextPosition, rangeIntersectsComposedNode, } from "./tts/flat-tree.js";
26
26
  import { collectRangeTextForSpeech, collectVisibleTextAndMap, isElementHiddenForTTS, isNodeExcludedFromSpeech, isNodeSuppressedForTTS, normalizeTextForSpeech, rangeHoldsSpeakableElement, textInRange, } from "./tts/text-processing.js";
@@ -688,12 +688,15 @@ export class TTSService {
688
688
  }
689
689
  /**
690
690
  * The language a read of `node` is in. Its content language is the nearest
691
- * `lang` between it and its shell, else the language the speak names, else a
692
- * pinned `lang_id`. Its locale is that content language, else the host's
693
- * configured locale, else en-US.
691
+ * `lang` between it and its shell, else for an element the `lang` the content
692
+ * roots it holds share, else the language the speak names, else a pinned
693
+ * `lang_id`. Its locale is that content language, else the host's configured
694
+ * locale, else en-US.
694
695
  */
695
696
  resolveReadLanguage(node, named) {
696
- const contentLanguage = findContentLanguage(node, { contentLanguage: named }) ??
697
+ const contentLanguage = findContentLanguage(node) ??
698
+ (node.nodeType === 1 ? findHeldContentLanguage(node) : undefined) ??
699
+ (named?.trim() || undefined) ??
697
700
  optionalString(this.providerOptions().lang_id);
698
701
  return {
699
702
  contentLanguage,
@@ -16,6 +16,8 @@ export interface MathControlNamesOptions {
16
16
  * that speech in the name of the control around it, among the control's other
17
17
  * text. English reads ClearSpeak, other languages MathSpeak, in the language of
18
18
  * the content. The label replaces the elements' own; it stays when SRE cannot
19
- * speak the math. Returns the function that stops observing.
19
+ * speak the math. Math in a control named by `aria-labelledby` from elements
20
+ * that do not hold it gets no label, as the elements' renderer gives it none.
21
+ * Returns the function that stops observing.
20
22
  */
21
23
  export declare function observeMathControlNames(root: Element, options?: MathControlNamesOptions): () => void;
@@ -44,6 +44,41 @@ const fractionStyle = (math) => Array.from(math.getElementsByTagName("mfrac")).e
44
44
  ? "Fraction_Over"
45
45
  : "Fraction_General";
46
46
  const isEnglish = (language) => !language || language.toLowerCase().split("-")[0] === "en";
47
+ /** The element a control names: the control a `label` labels, or the control itself. */
48
+ const namedElement = (control) => {
49
+ if (control.localName !== "label")
50
+ return control;
51
+ const label = control;
52
+ if (label.control)
53
+ return label.control;
54
+ const forId = label.getAttribute("for");
55
+ const root = label.getRootNode();
56
+ return ((forId ? root.getElementById?.(forId) : null) ??
57
+ label.querySelector("input, select, textarea, button"));
58
+ };
59
+ /**
60
+ * Whether the control's name leaves `container` out: the control is named by
61
+ * `aria-labelledby` from elements that do not hold the math. multiple-choice
62
+ * names a choice holding math that way, from its key and a note ("A. Pick the
63
+ * answer below"), so the screen reader reads the MathML in the content, where
64
+ * the student can explore it and a braille display shows it as math. A label
65
+ * there would be read in browse mode instead. Ids that resolve to nothing say
66
+ * nothing about the name, so they leave the math labelled.
67
+ */
68
+ const isNamedFromElsewhere = (control, container) => {
69
+ const named = namedElement(control);
70
+ const ids = named
71
+ ?.getAttribute("aria-labelledby")
72
+ ?.split(/\s+/)
73
+ .filter(Boolean);
74
+ if (!named || !ids?.length)
75
+ return false;
76
+ const root = named.getRootNode();
77
+ const labels = ids
78
+ .map((id) => root.getElementById?.(id) ?? null)
79
+ .filter((element) => element !== null);
80
+ return (labels.length > 0 && !labels.some((element) => element.contains(container)));
81
+ };
47
82
  /**
48
83
  * Labels each typeset expression inside a control under `root`, open shadow
49
84
  * roots included, with SRE's speech, as MathJax adds it and again whenever
@@ -51,7 +86,9 @@ const isEnglish = (language) => !language || language.toLowerCase().split("-")[0
51
86
  * that speech in the name of the control around it, among the control's other
52
87
  * text. English reads ClearSpeak, other languages MathSpeak, in the language of
53
88
  * the content. The label replaces the elements' own; it stays when SRE cannot
54
- * speak the math. Returns the function that stops observing.
89
+ * speak the math. Math in a control named by `aria-labelledby` from elements
90
+ * that do not hold it gets no label, as the elements' renderer gives it none.
91
+ * Returns the function that stops observing.
55
92
  */
56
93
  export function observeMathControlNames(root, options = {}) {
57
94
  if (typeof MutationObserver === "undefined")
@@ -70,7 +107,8 @@ export function observeMathControlNames(root, options = {}) {
70
107
  return result.usedMathSpeech ? result.speechText : null;
71
108
  };
72
109
  const name = (container) => {
73
- if (!flatTreeClosest(container, CONTROLS))
110
+ const control = flatTreeClosest(container, CONTROLS);
111
+ if (!control || isNamedFromElsewhere(control, container))
74
112
  return;
75
113
  const math = flatQuerySelector(container, "math");
76
114
  const mathml = math && canonicalizeMathML(math.outerHTML);
@@ -88,6 +126,8 @@ export function observeMathControlNames(root, options = {}) {
88
126
  void pending.then((label) => {
89
127
  if (stopped || !label || !container.isConnected)
90
128
  return;
129
+ if (isNamedFromElsewhere(control, container))
130
+ return;
91
131
  if (container.getAttribute("aria-label") !== label) {
92
132
  container.setAttribute("aria-label", label);
93
133
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pie-players/pie-assessment-toolkit",
3
- "version": "1.0.0-beta.6",
3
+ "version": "1.0.0-beta.7",
4
4
  "type": "module",
5
5
  "description": "PIE assessment toolkit: composable services + reference implementation for assessment players and tool coordination",
6
6
  "license": "MIT",
@@ -69,16 +69,16 @@
69
69
  "test": "bun test"
70
70
  },
71
71
  "dependencies": {
72
- "@pie-players/pie-calculator": "1.0.0-beta.6",
73
- "@pie-players/pie-context": "1.0.0-beta.6",
74
- "@pie-players/pie-players-shared": "1.0.0-beta.6",
75
- "@pie-players/pie-tts": "1.0.0-beta.6",
72
+ "@pie-players/pie-calculator": "1.0.0-beta.7",
73
+ "@pie-players/pie-context": "1.0.0-beta.7",
74
+ "@pie-players/pie-players-shared": "1.0.0-beta.7",
75
+ "@pie-players/pie-tts": "1.0.0-beta.7",
76
76
  "speech-rule-engine": "5.0.0-rc.4"
77
77
  },
78
78
  "devDependencies": {
79
- "@biomejs/biome": "^2.5.15",
80
- "@happy-dom/global-registrator": "^20.14.5",
81
- "@pie-players/tts-client-server": "1.0.0-beta.6",
79
+ "@biomejs/biome": "^2.6.2",
80
+ "@happy-dom/global-registrator": "^21.0.4",
81
+ "@pie-players/tts-client-server": "1.0.0-beta.7",
82
82
  "svelte": "^5.57.2",
83
83
  "svelte-check": "^4.7.6",
84
84
  "typescript": "^5.9.3"