@longsightgroup/qti3-core 0.8.1 → 0.8.2

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 (45) hide show
  1. package/dist/asset-url.d.ts +2 -0
  2. package/dist/asset-url.d.ts.map +1 -0
  3. package/dist/asset-url.js +8 -0
  4. package/dist/asset-url.js.map +1 -0
  5. package/dist/companion-materials-resolution.d.ts +30 -0
  6. package/dist/companion-materials-resolution.d.ts.map +1 -0
  7. package/dist/companion-materials-resolution.js +53 -0
  8. package/dist/companion-materials-resolution.js.map +1 -0
  9. package/dist/companion-materials.d.ts +6 -0
  10. package/dist/companion-materials.d.ts.map +1 -0
  11. package/dist/companion-materials.js +16 -0
  12. package/dist/companion-materials.js.map +1 -0
  13. package/dist/index.d.ts +4 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +3 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/parse-diagnostics.d.ts +2 -0
  18. package/dist/parse-diagnostics.d.ts.map +1 -0
  19. package/dist/parse-diagnostics.js +28 -0
  20. package/dist/parse-diagnostics.js.map +1 -0
  21. package/dist/parser-item-metadata.d.ts.map +1 -1
  22. package/dist/parser-item-metadata.js +37 -10
  23. package/dist/parser-item-metadata.js.map +1 -1
  24. package/dist/support.js +5 -5
  25. package/dist/support.js.map +1 -1
  26. package/dist/types.d.ts +7 -0
  27. package/dist/types.d.ts.map +1 -1
  28. package/dist/validation-companion-materials.d.ts +3 -0
  29. package/dist/validation-companion-materials.d.ts.map +1 -0
  30. package/dist/validation-companion-materials.js +40 -0
  31. package/dist/validation-companion-materials.js.map +1 -0
  32. package/dist/validation.d.ts.map +1 -1
  33. package/dist/validation.js +1 -27
  34. package/dist/validation.js.map +1 -1
  35. package/package.json +1 -1
  36. package/src/asset-url.ts +9 -0
  37. package/src/companion-materials-resolution.ts +99 -0
  38. package/src/companion-materials.ts +30 -0
  39. package/src/index.ts +11 -0
  40. package/src/parse-diagnostics.ts +28 -0
  41. package/src/parser-item-metadata.ts +79 -10
  42. package/src/support.ts +5 -5
  43. package/src/types.ts +8 -0
  44. package/src/validation-companion-materials.ts +48 -0
  45. package/src/validation.ts +1 -31
package/src/index.ts CHANGED
@@ -5,6 +5,15 @@ export {
5
5
  type QtiResolvedCatalogReference,
6
6
  type QtiResolvedCatalogSupport,
7
7
  } from "./catalog.js";
8
+ export {
9
+ createCompanionMaterialsResolution,
10
+ type QtiCompanionMaterialsResolution,
11
+ type QtiCompanionMaterialsResolutionOptions,
12
+ type QtiResolvedCompanionMaterialUnparsedChild,
13
+ type QtiResolvedDigitalCompanionMaterial,
14
+ type QtiResolvedPhysicalCompanionMaterial,
15
+ } from "./companion-materials-resolution.js";
16
+ export { isResolvableAssetUrl } from "./asset-url.js";
8
17
  export {
9
18
  analyzeQtiDeliverySecurity,
10
19
  buildQtiDeliverySafeXml,
@@ -20,6 +29,7 @@ export {
20
29
  type QtiPackageXmlTree,
21
30
  } from "./package-xml.js";
22
31
  export { parseQtiXml } from "./parser.js";
32
+ export { isConformanceParseDiagnostic } from "./parse-diagnostics.js";
23
33
  export {
24
34
  scoreQtiItemServerSide,
25
35
  type QtiServerScoringInput,
@@ -179,6 +189,7 @@ export type {
179
189
  QtiCompanionMaterialsUnparsedChild,
180
190
  QtiContentNode,
181
191
  QtiCustomInteractionDefinition,
192
+ QtiDigitalMaterial,
182
193
  QtiInteractionMarkupDefinition,
183
194
  QtiDiagnostic,
184
195
  QtiDocument,
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Diagnostic codes emitted while parsing QTI XML, before validation runs.
3
+ * Conformance fixtures use this classifier to separate parse-time diagnostics
4
+ * from validation diagnostics in `parseQtiXml` results.
5
+ */
6
+ const CONFORMANCE_PARSE_DIAGNOSTIC_CODES = new Set([
7
+ "xml.parse",
8
+ "xml.empty",
9
+ "qti.root",
10
+ "processing.unsupported",
11
+ "processing.response.forbidden",
12
+ "interaction.unsupported",
13
+ "interaction.deprecated",
14
+ "interaction.custom.markupInteraction",
15
+ "interaction.portableCustom.markupInteraction",
16
+ "interaction.portableCustom.child.duplicate",
17
+ "companionMaterials.child.unsupported",
18
+ "companionMaterials.digitalMaterial.fileHref.duplicate",
19
+ "companionMaterials.digitalMaterial.fileHref.missing",
20
+ "companionMaterials.digitalMaterial.fileHref.empty",
21
+ "companionMaterials.digitalMaterial.resourceIcon.duplicate",
22
+ "companionMaterials.physicalMaterial.empty",
23
+ "item.child.duplicate",
24
+ ]);
25
+
26
+ export function isConformanceParseDiagnostic(code: string): boolean {
27
+ return CONFORMANCE_PARSE_DIAGNOSTIC_CODES.has(code);
28
+ }
@@ -9,10 +9,16 @@ import type {
9
9
  QtiCompanionMaterialsUnparsedChild,
10
10
  QtiContentNode,
11
11
  QtiDiagnostic,
12
+ QtiDigitalMaterial,
12
13
  QtiModalFeedback,
13
14
  QtiPhysicalMaterial,
14
15
  QtiStylesheet,
15
16
  } from "./types.js";
17
+ import {
18
+ PARSED_COMPANION_MATERIAL_CHILD_NAMES,
19
+ type ParsedCompanionMaterialChildQtiName,
20
+ pushCompanionMaterialParseWarning,
21
+ } from "./companion-materials.js";
16
22
  import { childElements, descendants, textContent, type XmlNode } from "./xml.js";
17
23
 
18
24
  export function firstChildElement(
@@ -146,12 +152,23 @@ export function parseCompanionMaterialsInfo(
146
152
  if (!node) return undefined;
147
153
 
148
154
  const physicalMaterials: QtiPhysicalMaterial[] = [];
155
+ const digitalMaterials: QtiDigitalMaterial[] = [];
149
156
  const unparsedChildren: QtiCompanionMaterialsUnparsedChild[] = [];
150
157
 
151
- for (const child of childElements(node)) {
152
- if (child.localName === "qti-physical-material") {
158
+ const companionMaterialHandlers = {
159
+ "qti-physical-material": (child) => {
153
160
  const material = parsePhysicalMaterial(child, diagnostics);
154
161
  if (material) physicalMaterials.push(material);
162
+ },
163
+ "qti-digital-material": (child) => {
164
+ const material = parseDigitalMaterial(child, diagnostics);
165
+ if (material) digitalMaterials.push(material);
166
+ },
167
+ } satisfies Record<ParsedCompanionMaterialChildQtiName, (child: XmlNode) => void>;
168
+
169
+ for (const child of childElements(node)) {
170
+ if (isParsedCompanionMaterialChildQtiName(child.localName)) {
171
+ companionMaterialHandlers[child.localName](child);
155
172
  continue;
156
173
  }
157
174
 
@@ -167,6 +184,7 @@ export function parseCompanionMaterialsInfo(
167
184
 
168
185
  return {
169
186
  physicalMaterials,
187
+ digitalMaterials,
170
188
  unparsedChildren,
171
189
  source: node.source,
172
190
  };
@@ -178,17 +196,68 @@ function parsePhysicalMaterial(
178
196
  ): QtiPhysicalMaterial | undefined {
179
197
  const text = textContent(node).trim();
180
198
  if (text.length === 0) {
181
- diagnostics.push({
182
- code: "companionMaterials.physicalMaterial.empty",
183
- severity: "warning",
184
- message: "qti-physical-material requires non-empty text content.",
185
- path: node.source?.path,
186
- source: node.source,
187
- });
188
- return undefined;
199
+ return pushCompanionMaterialParseWarning(
200
+ diagnostics,
201
+ "companionMaterials.physicalMaterial.empty",
202
+ "qti-physical-material requires non-empty text content.",
203
+ node.source?.path,
204
+ node.source,
205
+ );
189
206
  }
190
207
  return {
191
208
  text,
192
209
  source: node.source,
193
210
  };
194
211
  }
212
+
213
+ function parseDigitalMaterial(
214
+ node: XmlNode,
215
+ diagnostics: QtiDiagnostic[],
216
+ ): QtiDigitalMaterial | undefined {
217
+ const fileHrefNode = firstChildElement(
218
+ node,
219
+ "qti-file-href",
220
+ diagnostics,
221
+ "companionMaterials.digitalMaterial.fileHref.duplicate",
222
+ );
223
+ if (!fileHrefNode) {
224
+ return pushCompanionMaterialParseWarning(
225
+ diagnostics,
226
+ "companionMaterials.digitalMaterial.fileHref.missing",
227
+ "qti-digital-material requires a qti-file-href child.",
228
+ node.source?.path,
229
+ node.source,
230
+ );
231
+ }
232
+
233
+ const fileHref = textContent(fileHrefNode).trim();
234
+ if (fileHref.length === 0) {
235
+ return pushCompanionMaterialParseWarning(
236
+ diagnostics,
237
+ "companionMaterials.digitalMaterial.fileHref.empty",
238
+ "qti-digital-material qti-file-href requires non-empty text content.",
239
+ fileHrefNode.source?.path,
240
+ fileHrefNode.source,
241
+ );
242
+ }
243
+
244
+ const resourceIconNode = firstChildElement(
245
+ node,
246
+ "qti-resource-icon",
247
+ diagnostics,
248
+ "companionMaterials.digitalMaterial.resourceIcon.duplicate",
249
+ );
250
+ const resourceIcon = resourceIconNode ? textContent(resourceIconNode).trim() : "";
251
+ return {
252
+ fileHref,
253
+ resourceIcon: resourceIcon.length > 0 ? resourceIcon : undefined,
254
+ attributes: node.attributes,
255
+ source: node.source,
256
+ };
257
+ }
258
+
259
+ function isParsedCompanionMaterialChildQtiName(
260
+ localName: string,
261
+ ): localName is ParsedCompanionMaterialChildQtiName {
262
+ return PARSED_COMPANION_MATERIAL_CHILD_NAMES.has(localName);
263
+ }
package/src/support.ts CHANGED
@@ -255,7 +255,7 @@ export const itemMetadataSupport: QtiItemMetadataElementSupport[] = [
255
255
  "packages/core/src/parser-item-metadata.test.ts",
256
256
  ],
257
257
  notes:
258
- "Parses qti-physical-material text only. qti-digital-material is preserved in unparsedChildren and emits companionMaterials.child.unsupported at parse time.",
258
+ "Parses qti-physical-material text and qti-digital-material file references. Digital materials require non-empty qti-file-href text and may include label, mime-type, and qti-resource-icon metadata. Hosts read resolved materials through createCompanionMaterialsResolution() or player.getCompanionMaterialsResolution().",
259
259
  },
260
260
  {
261
261
  qtiName: "qti-physical-material",
@@ -277,10 +277,10 @@ export const itemMetadataSupport: QtiItemMetadataElementSupport[] = [
277
277
  {
278
278
  qtiName: "qti-digital-material",
279
279
  category: "itemMetadata",
280
- support: "unsupported",
280
+ support: "parsed",
281
281
  specReference: "QTI 3.0.1 ASI",
282
- parse: false,
283
- validate: false,
282
+ parse: true,
283
+ validate: true,
284
284
  render: false,
285
285
  process: false,
286
286
  fixtures: [
@@ -291,7 +291,7 @@ export const itemMetadataSupport: QtiItemMetadataElementSupport[] = [
291
291
  "packages/core/src/parser-item-metadata.test.ts",
292
292
  ],
293
293
  notes:
294
- "Recognized as an unparsed child of qti-companion-materials-info. Full digital material parsing is not implemented yet.",
294
+ "Child of qti-companion-materials-info. Parsed from qti-file-href with optional label, mime-type, and qti-resource-icon metadata. Element attributes are preserved on the parsed model. Missing or empty qti-file-href emits companionMaterials.digitalMaterial.fileHref.* diagnostics.",
295
295
  },
296
296
  ];
297
297
 
package/src/types.ts CHANGED
@@ -456,6 +456,7 @@ export interface QtiAssessmentItem {
456
456
 
457
457
  export interface QtiCompanionMaterialsInfo {
458
458
  physicalMaterials: QtiPhysicalMaterial[];
459
+ digitalMaterials: QtiDigitalMaterial[];
459
460
  unparsedChildren: QtiCompanionMaterialsUnparsedChild[];
460
461
  source?: QtiSourceLocation | undefined;
461
462
  }
@@ -470,6 +471,13 @@ export interface QtiPhysicalMaterial {
470
471
  source?: QtiSourceLocation | undefined;
471
472
  }
472
473
 
474
+ export interface QtiDigitalMaterial {
475
+ fileHref: string;
476
+ resourceIcon?: string | undefined;
477
+ attributes: Record<string, string>;
478
+ source?: QtiSourceLocation | undefined;
479
+ }
480
+
473
481
  export interface QtiModalFeedback {
474
482
  identifier: string;
475
483
  outcomeIdentifier: string;
@@ -0,0 +1,48 @@
1
+ import type { QtiAssessmentItem, QtiDiagnostic } from "./types.js";
2
+ import { PARSED_COMPANION_MATERIAL_CHILD_NAMES } from "./companion-materials.js";
3
+
4
+ export function validateCompanionMaterials(
5
+ item: QtiAssessmentItem,
6
+ diagnostics: QtiDiagnostic[],
7
+ ): void {
8
+ const companionMaterials = item.companionMaterials;
9
+ if (!companionMaterials) return;
10
+
11
+ for (const child of companionMaterials.unparsedChildren) {
12
+ if (!PARSED_COMPANION_MATERIAL_CHILD_NAMES.has(child.qtiName)) continue;
13
+
14
+ diagnostics.push({
15
+ code: "companionMaterials.model.inconsistent",
16
+ severity: "error",
17
+ message: `${child.qtiName} must be represented in the parsed companion materials model, not as an unparsed child.`,
18
+ path: child.source?.path,
19
+ source: child.source,
20
+ });
21
+ }
22
+
23
+ for (const material of companionMaterials.physicalMaterials) {
24
+ if (material.text.trim().length === 0) {
25
+ diagnostics.push({
26
+ code: "companionMaterials.physicalMaterial.empty.model",
27
+ severity: "error",
28
+ message:
29
+ "qti-physical-material requires non-empty text content in the parsed companion materials model.",
30
+ path: material.source?.path,
31
+ source: material.source,
32
+ });
33
+ }
34
+ }
35
+
36
+ for (const material of companionMaterials.digitalMaterials) {
37
+ if (material.fileHref.trim().length === 0) {
38
+ diagnostics.push({
39
+ code: "companionMaterials.digitalMaterial.fileHref.empty.model",
40
+ severity: "error",
41
+ message:
42
+ "qti-digital-material requires non-empty qti-file-href text content in the parsed companion materials model.",
43
+ path: material.source?.path,
44
+ source: material.source,
45
+ });
46
+ }
47
+ }
48
+ }
package/src/validation.ts CHANGED
@@ -25,6 +25,7 @@ import { validateInteractionSharedVocabulary } from "./shared-vocabulary-interac
25
25
  import { isValidQtiPatternMask } from "./pattern-mask.js";
26
26
  import { validateQtiDataSsmlMetadata } from "./tts.js";
27
27
  import { qtiValueToStringList } from "./value-format.js";
28
+ import { validateCompanionMaterials } from "./validation-companion-materials.js";
28
29
 
29
30
  const BUILT_IN_COMPLETION_STATUS = "completionStatus";
30
31
 
@@ -713,37 +714,6 @@ function validateOutcomeLookupTables(item: QtiAssessmentItem, diagnostics: QtiDi
713
714
  }
714
715
  }
715
716
 
716
- function validateCompanionMaterials(item: QtiAssessmentItem, diagnostics: QtiDiagnostic[]): void {
717
- const companionMaterials = item.companionMaterials;
718
- if (!companionMaterials) return;
719
-
720
- for (const child of companionMaterials.unparsedChildren) {
721
- if (child.qtiName === "qti-physical-material") {
722
- diagnostics.push({
723
- code: "companionMaterials.model.inconsistent",
724
- severity: "error",
725
- message:
726
- "Empty qti-physical-material must be represented as a parse warning, not an unparsed child.",
727
- path: child.source?.path,
728
- source: child.source,
729
- });
730
- }
731
- }
732
-
733
- for (const material of companionMaterials.physicalMaterials) {
734
- if (material.text.trim().length === 0) {
735
- diagnostics.push({
736
- code: "companionMaterials.physicalMaterial.empty.model",
737
- severity: "error",
738
- message:
739
- "qti-physical-material requires non-empty text content in the parsed companion materials model.",
740
- path: material.source?.path,
741
- source: material.source,
742
- });
743
- }
744
- }
745
- }
746
-
747
717
  function validateStylesheets(item: QtiAssessmentItem, diagnostics: QtiDiagnostic[]): void {
748
718
  for (const stylesheet of item.stylesheets) {
749
719
  if (stylesheet.href.trim().length > 0) continue;