@conform-ed/qti-react 0.0.12 → 0.0.14

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 (134) hide show
  1. package/dist/capability.d.ts +17 -0
  2. package/dist/content-model.d.ts +42 -0
  3. package/dist/graphic.d.ts +23 -0
  4. package/dist/index.d.ts +14 -0
  5. package/dist/index.js +4556 -212
  6. package/dist/interactions/associate.d.ts +2 -0
  7. package/dist/interactions/choice.d.ts +2 -0
  8. package/dist/interactions/drawing.d.ts +2 -0
  9. package/dist/interactions/end-attempt.d.ts +2 -0
  10. package/dist/interactions/extended-text.d.ts +2 -0
  11. package/dist/interactions/gap-match.d.ts +2 -0
  12. package/dist/interactions/graphic.d.ts +13 -0
  13. package/dist/interactions/hottext.d.ts +2 -0
  14. package/dist/interactions/index.d.ts +18 -0
  15. package/dist/interactions/inline-choice.d.ts +2 -0
  16. package/dist/interactions/match.d.ts +2 -0
  17. package/dist/interactions/media.d.ts +2 -0
  18. package/dist/interactions/order.d.ts +2 -0
  19. package/dist/interactions/slider.d.ts +2 -0
  20. package/dist/interactions/text-entry.d.ts +2 -0
  21. package/dist/interactions/upload.d.ts +2 -0
  22. package/dist/normalized-item.d.ts +30 -0
  23. package/dist/pci/index.d.ts +6 -0
  24. package/dist/pci/interaction.d.ts +8 -0
  25. package/dist/pci/markup.d.ts +10 -0
  26. package/dist/pci/mount.d.ts +50 -0
  27. package/dist/pci/registry.d.ts +53 -0
  28. package/dist/pci/response.d.ts +11 -0
  29. package/dist/pci/skin.d.ts +12 -0
  30. package/dist/reference-skin/associate.d.ts +8 -0
  31. package/dist/reference-skin/choice.d.ts +8 -0
  32. package/dist/reference-skin/content.d.ts +6 -0
  33. package/dist/reference-skin/drawing.d.ts +9 -0
  34. package/dist/reference-skin/end-attempt.d.ts +7 -0
  35. package/dist/reference-skin/extended-text.d.ts +6 -0
  36. package/dist/reference-skin/gap-match.d.ts +8 -0
  37. package/dist/reference-skin/graphic-associate.d.ts +8 -0
  38. package/dist/reference-skin/graphic-base.d.ts +39 -0
  39. package/dist/reference-skin/graphic-gap-match.d.ts +8 -0
  40. package/dist/reference-skin/graphic-order.d.ts +8 -0
  41. package/dist/reference-skin/hotspot.d.ts +8 -0
  42. package/dist/reference-skin/hottext.d.ts +8 -0
  43. package/dist/reference-skin/index.d.ts +30 -0
  44. package/dist/reference-skin/inline-choice.d.ts +7 -0
  45. package/dist/reference-skin/match.d.ts +8 -0
  46. package/dist/reference-skin/media.d.ts +9 -0
  47. package/dist/reference-skin/order.d.ts +8 -0
  48. package/dist/reference-skin/position-object.d.ts +9 -0
  49. package/dist/reference-skin/select-point.d.ts +8 -0
  50. package/dist/reference-skin/slider.d.ts +8 -0
  51. package/dist/reference-skin/text-entry.d.ts +6 -0
  52. package/dist/reference-skin/upload.d.ts +8 -0
  53. package/dist/response-processing.d.ts +48 -0
  54. package/dist/rp/evaluate.d.ts +35 -0
  55. package/dist/rp/index.d.ts +4 -0
  56. package/dist/rp/interpreter.d.ts +15 -0
  57. package/dist/rp/template-processing.d.ts +49 -0
  58. package/dist/rp/templates.d.ts +8 -0
  59. package/dist/rp/types.d.ts +158 -0
  60. package/dist/rp/values.d.ts +27 -0
  61. package/dist/runtime.d.ts +164 -0
  62. package/dist/store.d.ts +61 -0
  63. package/dist/test/controller.d.ts +11 -0
  64. package/dist/test/index.d.ts +3 -0
  65. package/dist/test/session-store.d.ts +46 -0
  66. package/dist/test/types.d.ts +194 -0
  67. package/dist/types.d.ts +58 -0
  68. package/package.json +8 -6
  69. package/src/capability.ts +24 -0
  70. package/src/content-model.ts +104 -5
  71. package/src/graphic.ts +103 -0
  72. package/src/index.ts +139 -3
  73. package/src/interactions/associate.ts +22 -0
  74. package/src/interactions/choice.ts +2 -2
  75. package/src/interactions/drawing.ts +24 -0
  76. package/src/interactions/end-attempt.ts +19 -0
  77. package/src/interactions/extended-text.ts +21 -0
  78. package/src/interactions/gap-match.ts +22 -0
  79. package/src/interactions/graphic.ts +104 -0
  80. package/src/interactions/hottext.ts +21 -0
  81. package/src/interactions/index.ts +57 -3
  82. package/src/interactions/inline-choice.ts +2 -2
  83. package/src/interactions/match.ts +27 -0
  84. package/src/interactions/media.ts +24 -0
  85. package/src/interactions/order.ts +21 -0
  86. package/src/interactions/slider.ts +24 -0
  87. package/src/interactions/text-entry.ts +2 -2
  88. package/src/interactions/upload.ts +19 -0
  89. package/src/normalized-item.ts +563 -0
  90. package/src/pci/index.ts +22 -0
  91. package/src/pci/interaction.ts +42 -0
  92. package/src/pci/markup.ts +102 -0
  93. package/src/pci/mount.ts +134 -0
  94. package/src/pci/registry.ts +240 -0
  95. package/src/pci/response.ts +138 -0
  96. package/src/pci/skin.ts +86 -0
  97. package/src/reference-skin/associate.ts +98 -0
  98. package/src/reference-skin/choice.ts +44 -0
  99. package/src/reference-skin/content.ts +30 -0
  100. package/src/reference-skin/drawing.ts +160 -0
  101. package/src/reference-skin/end-attempt.ts +27 -0
  102. package/src/reference-skin/extended-text.ts +35 -0
  103. package/src/reference-skin/gap-match.ts +69 -0
  104. package/src/reference-skin/graphic-associate.ts +123 -0
  105. package/src/reference-skin/graphic-base.ts +142 -0
  106. package/src/reference-skin/graphic-gap-match.ts +143 -0
  107. package/src/reference-skin/graphic-order.ts +76 -0
  108. package/src/reference-skin/hotspot.ts +43 -0
  109. package/src/reference-skin/hottext.ts +42 -0
  110. package/src/reference-skin/index.ts +74 -0
  111. package/src/reference-skin/inline-choice.ts +42 -0
  112. package/src/reference-skin/match.ts +80 -0
  113. package/src/reference-skin/media.ts +74 -0
  114. package/src/reference-skin/order.ts +79 -0
  115. package/src/reference-skin/position-object.ts +84 -0
  116. package/src/reference-skin/select-point.ts +87 -0
  117. package/src/reference-skin/slider.ts +41 -0
  118. package/src/reference-skin/text-entry.ts +31 -0
  119. package/src/reference-skin/upload.ts +46 -0
  120. package/src/response-processing.ts +178 -29
  121. package/src/rp/evaluate.ts +827 -0
  122. package/src/rp/index.ts +30 -0
  123. package/src/rp/interpreter.ts +254 -0
  124. package/src/rp/template-processing.ts +290 -0
  125. package/src/rp/templates.ts +190 -0
  126. package/src/rp/types.ts +167 -0
  127. package/src/rp/values.ts +211 -0
  128. package/src/runtime.ts +476 -28
  129. package/src/store.ts +161 -5
  130. package/src/test/controller.ts +809 -0
  131. package/src/test/index.ts +25 -0
  132. package/src/test/session-store.ts +243 -0
  133. package/src/test/types.ts +203 -0
  134. package/src/types.ts +27 -1
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Structural views of QTI 3 `assessmentTest` and the Test Controller's session state
3
+ * (ADR-0005). The controller owns the rules; everything in `TestSessionState` is plain
4
+ * JSON so the consumer owns persistence — store the seed and the state, replay the
5
+ * test.
6
+ */
7
+ import type { CapabilityIssue } from "../capability";
8
+ import type { OutcomeDeclarationView, OutcomeValue, RpExpressionView } from "../rp";
9
+ import type { BodyNode } from "../runtime";
10
+ export interface BranchRuleView {
11
+ /** A target identifier in the same test part, or EXIT_TEST / EXIT_TESTPART / EXIT_SECTION. */
12
+ readonly target: string;
13
+ readonly expression: RpExpressionView;
14
+ }
15
+ /**
16
+ * QTI `itemSessionControl`: per-level overrides cascading testPart → section → itemRef.
17
+ * The controller enforces `maxAttempts` and `allowSkipping`; the rest is surfaced for
18
+ * delivery chrome (review/solution/comment affordances are UI concerns).
19
+ */
20
+ export interface ItemSessionControlView {
21
+ /** Attempts allowed per item; 0 means unlimited. Spec default: 1. */
22
+ readonly maxAttempts?: number;
23
+ readonly showFeedback?: boolean;
24
+ readonly allowReview?: boolean;
25
+ readonly showSolution?: boolean;
26
+ readonly allowComment?: boolean;
27
+ /** When false, the candidate must attempt the item before moving past it. */
28
+ readonly allowSkipping?: boolean;
29
+ readonly validateResponses?: boolean;
30
+ }
31
+ /**
32
+ * QTI `timeLimits` (seconds). The controller is clock-free by design (ADR-0005): these
33
+ * are data for the consumer's timers, which call `next()`/`end()` when time runs out.
34
+ */
35
+ export interface TimeLimitsView {
36
+ readonly minTime?: number;
37
+ readonly maxTime?: number;
38
+ readonly allowLateSubmission?: boolean;
39
+ }
40
+ export interface AssessmentItemRefView {
41
+ readonly kind: "assessmentItemRef";
42
+ readonly identifier: string;
43
+ readonly href?: string;
44
+ readonly categories?: readonly string[];
45
+ readonly fixed?: boolean;
46
+ readonly required?: boolean;
47
+ readonly preConditions?: readonly RpExpressionView[];
48
+ readonly branchRules?: readonly BranchRuleView[];
49
+ readonly itemSessionControl?: ItemSessionControlView;
50
+ readonly timeLimits?: TimeLimitsView;
51
+ /** Named weights for `testVariables`/aggregate weighting (missing names weigh 1). */
52
+ readonly weights?: ReadonlyArray<{
53
+ readonly identifier: string;
54
+ readonly value: number;
55
+ }>;
56
+ }
57
+ export interface AssessmentSectionView {
58
+ readonly kind: "assessmentSection";
59
+ readonly identifier: string;
60
+ readonly title?: string;
61
+ readonly visible?: boolean;
62
+ readonly fixed?: boolean;
63
+ readonly required?: boolean;
64
+ readonly selection?: {
65
+ readonly select: number;
66
+ readonly withReplacement?: boolean;
67
+ };
68
+ readonly ordering?: {
69
+ readonly shuffle?: boolean;
70
+ };
71
+ readonly preConditions?: readonly RpExpressionView[];
72
+ readonly branchRules?: readonly BranchRuleView[];
73
+ readonly itemSessionControl?: ItemSessionControlView;
74
+ readonly timeLimits?: TimeLimitsView;
75
+ readonly children: ReadonlyArray<AssessmentSectionView | AssessmentItemRefView>;
76
+ }
77
+ export interface TestPartView {
78
+ readonly identifier: string;
79
+ readonly navigationMode: "linear" | "nonlinear";
80
+ readonly submissionMode: "individual" | "simultaneous";
81
+ readonly preConditions?: readonly RpExpressionView[];
82
+ readonly branchRules?: readonly BranchRuleView[];
83
+ readonly itemSessionControl?: ItemSessionControlView;
84
+ readonly timeLimits?: TimeLimitsView;
85
+ readonly assessmentSections: readonly AssessmentSectionView[];
86
+ }
87
+ export interface TestFeedbackView {
88
+ readonly access?: "atEnd" | "during";
89
+ readonly outcomeIdentifier: string;
90
+ readonly identifier: string;
91
+ readonly showHide?: "show" | "hide";
92
+ readonly content?: readonly BodyNode[];
93
+ }
94
+ export interface OutcomeConditionBranch {
95
+ readonly expression: RpExpressionView;
96
+ readonly rules: readonly OutcomeRuleView[];
97
+ }
98
+ /** One outcome rule: outcomeCondition, setOutcomeValue, or exitTest. */
99
+ export interface OutcomeRuleView {
100
+ readonly kind: string;
101
+ readonly identifier?: string;
102
+ readonly expression?: RpExpressionView;
103
+ readonly outcomeIf?: OutcomeConditionBranch;
104
+ readonly outcomeElseIfs?: readonly OutcomeConditionBranch[];
105
+ readonly outcomeElse?: {
106
+ readonly rules: readonly OutcomeRuleView[];
107
+ };
108
+ }
109
+ export interface AssessmentTestView {
110
+ readonly identifier: string;
111
+ readonly title?: string;
112
+ readonly outcomeDeclarations?: readonly OutcomeDeclarationView[];
113
+ readonly timeLimits?: TimeLimitsView;
114
+ readonly testParts: readonly TestPartView[];
115
+ readonly outcomeProcessing?: {
116
+ readonly rules: readonly OutcomeRuleView[];
117
+ };
118
+ readonly testFeedbacks?: readonly TestFeedbackView[];
119
+ }
120
+ export interface TestPlanItem {
121
+ /** The item ref identifier — unique within the test, used as the session key. */
122
+ readonly key: string;
123
+ readonly ref: AssessmentItemRefView;
124
+ readonly partIdentifier: string;
125
+ readonly sectionPath: readonly string[];
126
+ /** The item's own preconditions plus its ancestor sections' (all must pass). */
127
+ readonly preConditions: readonly RpExpressionView[];
128
+ /** Effective session control: part → section → itemRef cascade over spec defaults. */
129
+ readonly sessionControl: Required<ItemSessionControlView>;
130
+ /** The item ref's own time limits (part/test limits live on their own levels). */
131
+ readonly timeLimits?: TimeLimitsView;
132
+ }
133
+ export interface TestPlanPart {
134
+ readonly identifier: string;
135
+ readonly navigationMode: "linear" | "nonlinear";
136
+ readonly submissionMode: "individual" | "simultaneous";
137
+ readonly timeLimits?: TimeLimitsView;
138
+ readonly items: readonly TestPlanItem[];
139
+ }
140
+ export interface TestPlan {
141
+ readonly timeLimits?: TimeLimitsView;
142
+ readonly parts: readonly TestPlanPart[];
143
+ }
144
+ export interface TestItemResult {
145
+ readonly outcomes: Readonly<Record<string, OutcomeValue>>;
146
+ /**
147
+ * Whether every scorable response variable matched (feeds numberCorrect /
148
+ * numberIncorrect). Omit when the item has nothing to be correct about.
149
+ */
150
+ readonly correct?: boolean;
151
+ /** The candidate gave at least one non-empty response (feeds numberResponded). */
152
+ readonly responded?: boolean;
153
+ /** Adaptive items manage their own attempt lifecycle, so maxAttempts is ignored (spec). */
154
+ readonly adaptive?: boolean;
155
+ }
156
+ export interface TestSessionState {
157
+ readonly status: "in-progress" | "ended";
158
+ readonly currentItemKey: string | null;
159
+ readonly itemOutcomes: Readonly<Record<string, Readonly<Record<string, OutcomeValue>>>>;
160
+ readonly attemptedItems: readonly string[];
161
+ readonly attemptCounts: Readonly<Record<string, number>>;
162
+ /** Items that have been the current item at least once (feeds numberPresented). */
163
+ readonly presentedItems: readonly string[];
164
+ /** Items whose latest attempt carried a response (feeds numberResponded). */
165
+ readonly respondedItems: readonly string[];
166
+ /** Items whose latest attempt was correct / incorrect (feeds numberCorrect/Incorrect). */
167
+ readonly correctItems: readonly string[];
168
+ readonly incorrectItems: readonly string[];
169
+ /**
170
+ * Results held back in simultaneous-submission parts (QTI: the part's responses are
171
+ * submitted together). They commit when the part is left or the test ends; until
172
+ * then they are invisible to outcome processing and feedback.
173
+ */
174
+ readonly pendingItemResults: Readonly<Record<string, TestItemResult>>;
175
+ readonly testOutcomes: Readonly<Record<string, OutcomeValue>>;
176
+ }
177
+ export interface TestController {
178
+ readonly plan: TestPlan;
179
+ /** Static capability issues found in outcome processing, preconditions, and branch rules. */
180
+ readonly issues: readonly CapabilityIssue[];
181
+ readonly start: () => TestSessionState;
182
+ readonly currentItem: (state: TestSessionState) => TestPlanItem | null;
183
+ readonly canMoveTo: (state: TestSessionState, itemKey: string) => boolean;
184
+ readonly moveTo: (state: TestSessionState, itemKey: string) => TestSessionState;
185
+ /** Whether `next()` would change state (false when allowSkipping blocks the move). */
186
+ readonly canNext: (state: TestSessionState) => boolean;
187
+ readonly next: (state: TestSessionState) => TestSessionState;
188
+ /** Attempts left for the item under its effective maxAttempts (Infinity when unlimited). */
189
+ readonly remainingAttempts: (state: TestSessionState, itemKey: string) => number;
190
+ readonly canSubmitItem: (state: TestSessionState, itemKey: string) => boolean;
191
+ readonly submitItem: (state: TestSessionState, itemKey: string, result: TestItemResult) => TestSessionState;
192
+ readonly end: (state: TestSessionState) => TestSessionState;
193
+ readonly visibleTestFeedbacks: (state: TestSessionState) => readonly TestFeedbackView[];
194
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Runtime view types for the headless core. These are structural views of the
3
+ * `@conform-ed/contracts` QTI 3.0.1 shapes — the runtime validates items with the
4
+ * contract schemas, but works against these narrowed types because several contract
5
+ * schemas are `z.lazy` (statically `any`). No React or Mantine here.
6
+ */
7
+ /** One field of a record response; fields keep their runtime type (PCI JSON typing). */
8
+ export type ResponseFieldValue = string | number | boolean | null;
9
+ /** A record-cardinality response: named, individually-typed fields (PCI contracts). */
10
+ export type ResponseRecordValue = Readonly<Record<string, ResponseFieldValue>>;
11
+ /** A candidate response for one interaction, keyed in state by `responseIdentifier`. */
12
+ export type ResponseValue = string | readonly string[] | ResponseRecordValue | null;
13
+ /** Narrow a ResponseValue to its record variant. */
14
+ export declare function isResponseRecord(value: ResponseValue): value is ResponseRecordValue;
15
+ export type Cardinality = "single" | "multiple" | "ordered" | "record";
16
+ export interface CorrectResponseView {
17
+ readonly values: ReadonlyArray<{
18
+ readonly value: string;
19
+ }>;
20
+ }
21
+ export interface MapEntryView {
22
+ readonly mapKey: string;
23
+ readonly mappedValue: number;
24
+ readonly caseSensitive?: boolean;
25
+ }
26
+ export interface MappingView {
27
+ readonly mapEntries: readonly MapEntryView[];
28
+ readonly lowerBound?: number;
29
+ readonly upperBound?: number;
30
+ readonly defaultValue?: number;
31
+ }
32
+ /** One scored area for point responses (QTI `areaMapEntry`). */
33
+ export interface AreaMapEntryView {
34
+ readonly shape: string;
35
+ readonly coords: readonly number[];
36
+ readonly mappedValue: number;
37
+ }
38
+ export interface AreaMappingView {
39
+ readonly areaMapEntries: readonly AreaMapEntryView[];
40
+ readonly lowerBound?: number;
41
+ readonly upperBound?: number;
42
+ readonly defaultValue?: number;
43
+ }
44
+ export interface ResponseDeclarationView {
45
+ readonly identifier: string;
46
+ readonly cardinality: Cardinality;
47
+ readonly baseType?: string;
48
+ readonly correctResponse?: CorrectResponseView;
49
+ readonly mapping?: MappingView;
50
+ readonly areaMapping?: AreaMappingView;
51
+ }
52
+ /** The scored outcome for one response variable. */
53
+ export interface ScoreResult {
54
+ readonly identifier: string;
55
+ readonly score: number;
56
+ readonly maxScore: number;
57
+ readonly correct: boolean;
58
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@conform-ed/qti-react",
3
- "version": "0.0.12",
3
+ "version": "0.0.14",
4
4
  "files": [
5
5
  "src",
6
6
  "dist"
@@ -9,20 +9,22 @@
9
9
  "module": "src/index.ts",
10
10
  "exports": {
11
11
  ".": {
12
- "types": "./src/index.ts",
12
+ "types": "./dist/index.d.ts",
13
13
  "import": "./dist/index.js"
14
14
  }
15
15
  },
16
16
  "scripts": {
17
- "build": "bun build ./src/index.ts --outdir dist --format esm --target browser --external react --external react-dom --external zod",
18
- "typecheck": "tsgo --noEmit",
19
- "lint": "oxlint --config ../../.oxlintrc.jsonc .",
17
+ "build": "bun build ./src/index.ts --outdir dist --format esm --target browser --external react --external react-dom --external zod && tsgo -p tsconfig.build.json",
20
18
  "format": "oxfmt --config ../../.oxfmtrc.jsonc --check .",
21
- "test": "bun test"
19
+ "lint": "oxlint --config ../../.oxlintrc.jsonc .",
20
+ "test": "bun test",
21
+ "typecheck": "tsgo --noEmit"
22
22
  },
23
23
  "devDependencies": {
24
+ "@conform-ed/qti-xml": "0.0.13",
24
25
  "@types/react": "^19.2.17",
25
26
  "@types/react-dom": "^19",
27
+ "happy-dom": "^20.10.2",
26
28
  "react": "^19.2.7",
27
29
  "react-dom": "^19"
28
30
  },
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Capability Report types (ADR-0003): the runtime's answer to "can this content be
3
+ * delivered, and if not, why". In their own module so the RP interpreter can report
4
+ * issues without importing the React runtime.
5
+ */
6
+
7
+ export type CapabilityIssueType =
8
+ | "unsupported-interaction"
9
+ | "invalid-interaction"
10
+ | "unsupported-element"
11
+ | "unsupported-rp";
12
+
13
+ export interface CapabilityIssue {
14
+ readonly type: CapabilityIssueType;
15
+ /** The interaction kind, element name, or RP rule/operator/template at issue. */
16
+ readonly name: string;
17
+ readonly responseIdentifier?: string;
18
+ readonly detail?: string;
19
+ }
20
+
21
+ export interface CapabilityReport {
22
+ readonly deliverable: boolean;
23
+ readonly issues: readonly CapabilityIssue[];
24
+ }
@@ -13,8 +13,29 @@
13
13
  * HTML5 at once".
14
14
  */
15
15
 
16
- /** Interaction node kinds the v0 runtime can render. */
17
- export const v0InteractionKinds = ["choiceInteraction", "textEntryInteraction", "inlineChoiceInteraction"] as const;
16
+ /** Interaction node kinds conform-ed ships descriptors and Reference Skins for. */
17
+ export const v0InteractionKinds = [
18
+ "associateInteraction",
19
+ "choiceInteraction",
20
+ "drawingInteraction",
21
+ "endAttemptInteraction",
22
+ "extendedTextInteraction",
23
+ "gapMatchInteraction",
24
+ "graphicAssociateInteraction",
25
+ "graphicGapMatchInteraction",
26
+ "graphicOrderInteraction",
27
+ "hotspotInteraction",
28
+ "hottextInteraction",
29
+ "inlineChoiceInteraction",
30
+ "matchInteraction",
31
+ "mediaInteraction",
32
+ "orderInteraction",
33
+ "positionObjectStage",
34
+ "selectPointInteraction",
35
+ "sliderInteraction",
36
+ "textEntryInteraction",
37
+ "uploadInteraction",
38
+ ] as const;
18
39
 
19
40
  export type V0InteractionKind = (typeof v0InteractionKinds)[number];
20
41
 
@@ -26,16 +47,63 @@ const v0FlowElements = new Set<string>([
26
47
  "em",
27
48
  "b",
28
49
  "i",
50
+ "sub",
51
+ "sup",
29
52
  "br",
30
53
  "ul",
31
54
  "ol",
32
55
  "li",
33
56
  // language-critical
34
57
  "ruby",
58
+ "rb",
35
59
  "rt",
36
60
  "rp",
61
+ // media (the first media-milestone growth; src/poster route through the Asset Resolver)
62
+ "img",
63
+ "audio",
64
+ "video",
65
+ "source",
66
+ "track",
67
+ "picture",
68
+ "figure",
69
+ "figcaption",
70
+ // embedded media the corpus uses for stages and standalone assets
71
+ "object",
72
+ // structural vocabulary the official corpus uses (fixture-driven growth, ADR-0002)
73
+ "div",
74
+ "section",
75
+ "h1",
76
+ "h2",
77
+ "h3",
78
+ "h4",
79
+ "h5",
80
+ "h6",
81
+ "blockquote",
82
+ "hr",
83
+ // tables (gradebook-style content; conservative semantics)
84
+ "table",
85
+ "caption",
86
+ "thead",
87
+ "tbody",
88
+ "tfoot",
89
+ "tr",
90
+ "th",
91
+ "td",
37
92
  ]);
38
93
 
94
+ /** Element-specific attribute allowlists, additive to the global set. */
95
+ const v0ElementAttributes: ReadonlyMap<string, ReadonlySet<string>> = new Map([
96
+ ["img", new Set(["src", "alt", "width", "height"])],
97
+ ["audio", new Set(["src", "controls", "loop", "muted", "preload"])],
98
+ ["video", new Set(["src", "controls", "loop", "muted", "preload", "poster", "width", "height"])],
99
+ ["source", new Set(["src", "type"])],
100
+ ["track", new Set(["src", "kind", "srclang", "label", "default"])],
101
+ ["object", new Set(["data", "type", "width", "height"])],
102
+ ]);
103
+
104
+ /** Attribute names treated as packaged-asset references (rewritten by the Asset Resolver). */
105
+ const v0UrlAttributes = new Set<string>(["src", "poster", "data"]);
106
+
39
107
  /**
40
108
  * The MathML root. Its subtree is rendered structurally (presentation MathML) with the
41
109
  * same attribute hardening, but element names inside are not individually allowlisted
@@ -51,6 +119,10 @@ export interface ContentModel {
51
119
  readonly flowElements: ReadonlySet<string>;
52
120
  readonly mathRoot: string;
53
121
  readonly globalAttributes: ReadonlySet<string>;
122
+ /** Per-element attribute allowlists, additive to `globalAttributes`. */
123
+ readonly elementAttributes: ReadonlyMap<string, ReadonlySet<string>>;
124
+ /** Attributes whose values are asset references, routed through the Asset Resolver. */
125
+ readonly urlAttributes: ReadonlySet<string>;
54
126
  }
55
127
 
56
128
  export const v0ContentModel: ContentModel = {
@@ -58,6 +130,8 @@ export const v0ContentModel: ContentModel = {
58
130
  flowElements: v0FlowElements,
59
131
  mathRoot: v0MathRoot,
60
132
  globalAttributes: v0GlobalAttributes,
133
+ elementAttributes: v0ElementAttributes,
134
+ urlAttributes: v0UrlAttributes,
61
135
  };
62
136
 
63
137
  export function isAllowedFlowElement(model: ContentModel, name: string): boolean {
@@ -84,11 +158,13 @@ function isUnsafeAttribute(name: string, value: unknown): boolean {
84
158
  }
85
159
 
86
160
  /**
87
- * Reduce a raw attribute bag to the safe, allowlisted subset. Used by the body walk so
88
- * a node that validates against QTI structure still cannot carry script or handlers.
161
+ * Reduce a raw attribute bag to the safe, allowlisted subset for one element. Used by
162
+ * the body walk so a node that validates against QTI structure still cannot carry
163
+ * script or handlers. The allowlist is the global set plus the element's own entries.
89
164
  */
90
165
  export function sanitizeAttributes(
91
166
  model: ContentModel,
167
+ elementName: string,
92
168
  attributes: Record<string, unknown> | undefined,
93
169
  ): Record<string, string> {
94
170
  const safe: Record<string, string> = {};
@@ -97,12 +173,14 @@ export function sanitizeAttributes(
97
173
  return safe;
98
174
  }
99
175
 
176
+ const elementAllowed = model.elementAttributes.get(elementName);
177
+
100
178
  for (const [name, value] of Object.entries(attributes)) {
101
179
  if (isUnsafeAttribute(name, value)) {
102
180
  continue;
103
181
  }
104
182
 
105
- if (!model.globalAttributes.has(name)) {
183
+ if (!model.globalAttributes.has(name) && !elementAllowed?.has(name)) {
106
184
  continue;
107
185
  }
108
186
 
@@ -113,3 +191,24 @@ export function sanitizeAttributes(
113
191
 
114
192
  return safe;
115
193
  }
194
+
195
+ /**
196
+ * Attribute hardening for MathML subtrees: presentation attributes (mathvariant,
197
+ * linethickness, …) are not individually allowlisted — MathML has no scripting surface
198
+ * once event handlers and javascript: URLs are stripped.
199
+ */
200
+ export function sanitizeMathAttributes(attributes: Record<string, unknown> | undefined): Record<string, string> {
201
+ const safe: Record<string, string> = {};
202
+
203
+ if (!attributes) {
204
+ return safe;
205
+ }
206
+
207
+ for (const [name, value] of Object.entries(attributes)) {
208
+ if (!isUnsafeAttribute(name, value) && typeof value === "string") {
209
+ safe[name] = value;
210
+ }
211
+ }
212
+
213
+ return safe;
214
+ }
package/src/graphic.ts ADDED
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Graphic primitives shared by the graphic interaction family and areaMapping scoring:
3
+ * QTI shape/coords parsing and point-in-shape hit testing. Pure logic, no React.
4
+ *
5
+ * Shapes follow the QTI (HTML image-map) conventions:
6
+ * - `circle`: center-x, center-y, radius
7
+ * - `rect`: left-x, top-y, right-x, bottom-y
8
+ * - `poly`: x1, y1, ..., xn, yn
9
+ * - `ellipse`: center-x, center-y, radius-x, radius-y
10
+ * - `default`: the entire image
11
+ */
12
+
13
+ export type QtiShape = "circle" | "rect" | "poly" | "ellipse" | "default";
14
+
15
+ export interface Point {
16
+ readonly x: number;
17
+ readonly y: number;
18
+ }
19
+
20
+ /** Parse a QTI coords attribute ("10,20,30") into numbers. */
21
+ export function parseCoords(coords: string): number[] {
22
+ return coords
23
+ .split(",")
24
+ .map((entry) => Number(entry.trim()))
25
+ .filter((value) => !Number.isNaN(value));
26
+ }
27
+
28
+ /** Parse a QTI point value ("x y") or null when malformed. */
29
+ export function parsePoint(value: string): Point | null {
30
+ const [x, y, ...rest] = value.trim().split(/\s+/u).map(Number);
31
+
32
+ if (x === undefined || y === undefined || rest.length > 0 || Number.isNaN(x) || Number.isNaN(y)) {
33
+ return null;
34
+ }
35
+
36
+ return { x, y };
37
+ }
38
+
39
+ export function formatPoint(point: Point): string {
40
+ return `${point.x} ${point.y}`;
41
+ }
42
+
43
+ function pointInPolygon(coords: readonly number[], point: Point): boolean {
44
+ let inside = false;
45
+
46
+ for (let i = 0, j = coords.length - 2; i < coords.length; j = i, i += 2) {
47
+ const xi = coords[i]!;
48
+ const yi = coords[i + 1]!;
49
+ const xj = coords[j]!;
50
+ const yj = coords[j + 1]!;
51
+ const intersects = yi > point.y !== yj > point.y && point.x < ((xj - xi) * (point.y - yi)) / (yj - yi) + xi;
52
+
53
+ if (intersects) {
54
+ inside = !inside;
55
+ }
56
+ }
57
+
58
+ return inside;
59
+ }
60
+
61
+ /** QTI hit test: is `point` inside the area described by (shape, coords)? */
62
+ export function pointInShape(shape: string, coords: readonly number[], point: Point): boolean {
63
+ switch (shape) {
64
+ case "default":
65
+ return true;
66
+
67
+ case "circle": {
68
+ const [cx, cy, r] = coords;
69
+
70
+ if (cx === undefined || cy === undefined || r === undefined) {
71
+ return false;
72
+ }
73
+
74
+ return (point.x - cx) ** 2 + (point.y - cy) ** 2 <= r ** 2;
75
+ }
76
+
77
+ case "rect": {
78
+ const [left, top, right, bottom] = coords;
79
+
80
+ if (left === undefined || top === undefined || right === undefined || bottom === undefined) {
81
+ return false;
82
+ }
83
+
84
+ return point.x >= left && point.x <= right && point.y >= top && point.y <= bottom;
85
+ }
86
+
87
+ case "ellipse": {
88
+ const [cx, cy, rx, ry] = coords;
89
+
90
+ if (cx === undefined || cy === undefined || rx === undefined || ry === undefined || rx === 0 || ry === 0) {
91
+ return false;
92
+ }
93
+
94
+ return (point.x - cx) ** 2 / rx ** 2 + (point.y - cy) ** 2 / ry ** 2 <= 1;
95
+ }
96
+
97
+ case "poly":
98
+ return coords.length >= 6 && pointInPolygon(coords, point);
99
+
100
+ default:
101
+ return false;
102
+ }
103
+ }