@longsightgroup/qti3-core 0.9.0 → 0.9.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 (51) hide show
  1. package/README.md +39 -0
  2. package/dist/adaptive-turn-materializer.d.ts +14 -0
  3. package/dist/adaptive-turn-materializer.d.ts.map +1 -0
  4. package/dist/adaptive-turn-materializer.js +56 -0
  5. package/dist/adaptive-turn-materializer.js.map +1 -0
  6. package/dist/adaptive-turn.d.ts +23 -0
  7. package/dist/adaptive-turn.d.ts.map +1 -0
  8. package/dist/adaptive-turn.js +60 -0
  9. package/dist/adaptive-turn.js.map +1 -0
  10. package/dist/asset-url.d.ts.map +1 -1
  11. package/dist/asset-url.js +1 -0
  12. package/dist/asset-url.js.map +1 -1
  13. package/dist/delivery-redaction.d.ts +50 -0
  14. package/dist/delivery-redaction.d.ts.map +1 -0
  15. package/dist/delivery-redaction.js +176 -0
  16. package/dist/delivery-redaction.js.map +1 -0
  17. package/dist/delivery-security.d.ts +3 -18
  18. package/dist/delivery-security.d.ts.map +1 -1
  19. package/dist/delivery-security.js +35 -189
  20. package/dist/delivery-security.js.map +1 -1
  21. package/dist/index.d.ts +2 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +1 -0
  24. package/dist/index.js.map +1 -1
  25. package/dist/server-scoring.d.ts +4 -14
  26. package/dist/server-scoring.d.ts.map +1 -1
  27. package/dist/server-scoring.js +19 -157
  28. package/dist/server-scoring.js.map +1 -1
  29. package/dist/shared-vocabulary-support.d.ts.map +1 -1
  30. package/dist/shared-vocabulary-support.js +1 -0
  31. package/dist/shared-vocabulary-support.js.map +1 -1
  32. package/dist/support.d.ts.map +1 -1
  33. package/dist/support.js +8 -4
  34. package/dist/support.js.map +1 -1
  35. package/dist/trusted-item-session.d.ts +49 -0
  36. package/dist/trusted-item-session.d.ts.map +1 -0
  37. package/dist/trusted-item-session.js +260 -0
  38. package/dist/trusted-item-session.js.map +1 -0
  39. package/package.json +4 -2
  40. package/src/adaptive-turn-materializer.ts +85 -0
  41. package/src/adaptive-turn.ts +109 -0
  42. package/src/asset-url.ts +1 -0
  43. package/src/delivery-redaction.ts +268 -0
  44. package/src/delivery-security.ts +59 -245
  45. package/src/index.ts +7 -0
  46. package/src/server-scoring.ts +30 -226
  47. package/src/shared-vocabulary-support.ts +1 -0
  48. package/src/support.ts +9 -4
  49. package/src/trusted-item-session.ts +427 -0
  50. package/src/interaction-test-fixtures.ts +0 -44
  51. package/src/serializer-processing.fixtures.ts +0 -469
@@ -0,0 +1,268 @@
1
+ import type { QtiDiagnostic, QtiSourceLocation } from "./types.js";
2
+ import { descendants, parseXmlTree, type XmlNode } from "./xml.js";
3
+
4
+ export const DEFAULT_VALUE_DECLARATION_ELEMENT_NAMES = new Set([
5
+ "response-declaration",
6
+ "outcome-declaration",
7
+ "template-declaration",
8
+ ]);
9
+
10
+ export const FORBIDDEN_DELIVERY_SECRET_ELEMENT_NAMES = new Set([
11
+ "correct-response",
12
+ "mapping",
13
+ "area-mapping",
14
+ "match-table",
15
+ "interpolation-table",
16
+ "response-processing",
17
+ ]);
18
+
19
+ export type QtiDeliverySecurityFindingKind =
20
+ | "forbidden-delivery-element"
21
+ | "unsupported-secure-delivery-element"
22
+ | "unsupported-adaptive-response-processing";
23
+
24
+ export interface QtiDeliverySecurityFinding {
25
+ kind: QtiDeliverySecurityFindingKind;
26
+ qtiName: string;
27
+ localName: string;
28
+ message: string;
29
+ source?: QtiSourceLocation | undefined;
30
+ }
31
+
32
+ export interface QtiDeliverySecurityAnalysis {
33
+ diagnostics: QtiDiagnostic[];
34
+ findings: QtiDeliverySecurityFinding[];
35
+ /** True when this exact XML contains no known answer/scoring/feedback delivery leaks. */
36
+ deliverySafe: boolean;
37
+ /** True when secure-delivery redaction can be attempted for this XML. */
38
+ secureDeliverySupported: boolean;
39
+ }
40
+
41
+ export interface ParsedDeliveryXml {
42
+ root: XmlNode | undefined;
43
+ diagnostics: QtiDiagnostic[];
44
+ }
45
+
46
+ export interface DeliveryRedactionPolicy {
47
+ isForbiddenElement(node: XmlNode, normalizedName: string): boolean;
48
+ unsupportedFinding?(
49
+ node: XmlNode,
50
+ normalizedName: string,
51
+ nodes: readonly XmlNode[],
52
+ ): QtiDeliverySecurityFinding | null;
53
+ diagnosticCodeForFinding(kind: QtiDeliverySecurityFindingKind): string;
54
+ forbiddenElementMessage(node: XmlNode): string;
55
+ }
56
+
57
+ export interface RedactedDeliveryXmlResult {
58
+ ok: boolean;
59
+ diagnostics: QtiDiagnostic[];
60
+ analysis: QtiDeliverySecurityAnalysis;
61
+ xml?: string | undefined;
62
+ }
63
+
64
+ export function parseDeliveryXml(xml: string): ParsedDeliveryXml {
65
+ let parsed: ReturnType<typeof parseXmlTree>;
66
+ try {
67
+ parsed = parseXmlTree(xml);
68
+ } catch (error) {
69
+ return {
70
+ root: undefined,
71
+ diagnostics: [
72
+ {
73
+ code: "xml.parse",
74
+ severity: "error",
75
+ message: error instanceof Error ? error.message : String(error),
76
+ },
77
+ ],
78
+ };
79
+ }
80
+
81
+ const diagnostics: QtiDiagnostic[] = parsed.errors.map((error) => ({
82
+ code: "xml.parse",
83
+ severity: "error",
84
+ message: error.message,
85
+ }));
86
+ if (!parsed.root) {
87
+ diagnostics.push({
88
+ code: "xml.empty",
89
+ severity: "error",
90
+ message: "No XML root element was found.",
91
+ });
92
+ }
93
+ return { root: parsed.root, diagnostics };
94
+ }
95
+
96
+ export function analyzeDeliveryXml(
97
+ parsed: ParsedDeliveryXml,
98
+ policy: DeliveryRedactionPolicy,
99
+ ): QtiDeliverySecurityAnalysis {
100
+ const diagnostics = [...parsed.diagnostics];
101
+ const findings: QtiDeliverySecurityFinding[] = [];
102
+
103
+ if (parsed.root) {
104
+ const nodes = [parsed.root, ...descendants(parsed.root, () => true)];
105
+ for (const node of nodes) {
106
+ const normalizedName = normalizedQtiElementName(node.localName);
107
+ if (policy.isForbiddenElement(node, normalizedName)) {
108
+ findings.push({
109
+ kind: "forbidden-delivery-element",
110
+ qtiName: node.name,
111
+ localName: node.localName,
112
+ message: policy.forbiddenElementMessage(node),
113
+ source: node.source,
114
+ });
115
+ }
116
+
117
+ const unsupportedFinding = policy.unsupportedFinding?.(node, normalizedName, nodes);
118
+ if (unsupportedFinding) findings.push(unsupportedFinding);
119
+ }
120
+ }
121
+
122
+ diagnostics.push(
123
+ ...findings.map((finding) =>
124
+ findingToDiagnostic(finding, (kind) => policy.diagnosticCodeForFinding(kind)),
125
+ ),
126
+ );
127
+ const parseOk = parsed.diagnostics.every((diagnostic) => diagnostic.severity !== "error");
128
+
129
+ return {
130
+ diagnostics,
131
+ findings,
132
+ deliverySafe:
133
+ parseOk && !findings.some((finding) => finding.kind === "forbidden-delivery-element"),
134
+ secureDeliverySupported:
135
+ parseOk &&
136
+ !findings.some(
137
+ (finding) =>
138
+ finding.kind === "unsupported-secure-delivery-element" ||
139
+ finding.kind === "unsupported-adaptive-response-processing",
140
+ ),
141
+ };
142
+ }
143
+
144
+ export function redactDeliveryXml(
145
+ xml: string,
146
+ policy: DeliveryRedactionPolicy,
147
+ ): RedactedDeliveryXmlResult {
148
+ const parsed = parseDeliveryXml(xml);
149
+ const analysis = analyzeDeliveryXml(parsed, policy);
150
+ if (!analysis.secureDeliverySupported) {
151
+ return {
152
+ ok: false,
153
+ diagnostics: analysis.diagnostics,
154
+ analysis,
155
+ };
156
+ }
157
+
158
+ if (!parsed.root) {
159
+ return {
160
+ ok: false,
161
+ diagnostics: parsed.diagnostics,
162
+ analysis,
163
+ };
164
+ }
165
+
166
+ const redactionRanges = readRedactionRanges(
167
+ [parsed.root, ...descendants(parsed.root, () => true)],
168
+ (node, normalizedName) => policy.isForbiddenElement(node, normalizedName),
169
+ );
170
+ const redactedXml = removeSourceRanges(xml, redactionRanges);
171
+ const redactedAnalysis = analyzeDeliveryXml(parseDeliveryXml(redactedXml), policy);
172
+ if (!redactedAnalysis.deliverySafe || !redactedAnalysis.secureDeliverySupported) {
173
+ return {
174
+ ok: false,
175
+ diagnostics: [...analysis.diagnostics, ...redactedAnalysis.diagnostics],
176
+ analysis: redactedAnalysis,
177
+ };
178
+ }
179
+
180
+ return {
181
+ ok: true,
182
+ xml: redactedXml,
183
+ diagnostics: redactedAnalysis.diagnostics,
184
+ analysis: redactedAnalysis,
185
+ };
186
+ }
187
+
188
+ export function readRedactionRanges(
189
+ nodes: readonly XmlNode[],
190
+ shouldRedact: (node: XmlNode, normalizedName: string) => boolean,
191
+ ): RedactionRange[] {
192
+ const ranges = nodes.flatMap((node) => {
193
+ const normalizedName = normalizedQtiElementName(node.localName);
194
+ if (!shouldRedact(node, normalizedName)) return [];
195
+ return sourceRangeFor(node);
196
+ });
197
+ return mergeSourceRanges(ranges);
198
+ }
199
+
200
+ export function sourceRangeFor(node: XmlNode): RedactionRange[] {
201
+ const endOffset = node.sourceRange.endOffset;
202
+ if (node.sourceRange.startOffset < 0 || endOffset === undefined) return [];
203
+ return [{ startOffset: node.sourceRange.startOffset, endOffset }];
204
+ }
205
+
206
+ export function removeSourceRanges(xml: string, ranges: readonly RedactionRange[]): string {
207
+ let output = "";
208
+ let cursor = 0;
209
+ for (const range of ranges) {
210
+ output += xml.slice(cursor, range.startOffset);
211
+ cursor = range.endOffset;
212
+ }
213
+ return output + xml.slice(cursor);
214
+ }
215
+
216
+ export function normalizedQtiElementName(localName: string): string {
217
+ const lower = localName.toLowerCase();
218
+ return lower.startsWith("qti-") ? lower.slice("qti-".length) : lower;
219
+ }
220
+
221
+ export function parseXmlBoolean(value: string | undefined): boolean | undefined {
222
+ if (value === undefined) return undefined;
223
+ const normalized = value.trim().toLowerCase();
224
+ if (normalized === "true" || normalized === "1") return true;
225
+ if (normalized === "false" || normalized === "0") return false;
226
+ return undefined;
227
+ }
228
+
229
+ export function isDeclarationDefaultValue(node: XmlNode, normalizedName: string): boolean {
230
+ return (
231
+ normalizedName === "default-value" &&
232
+ DEFAULT_VALUE_DECLARATION_ELEMENT_NAMES.has(
233
+ normalizedQtiElementName(node.parent?.localName ?? ""),
234
+ )
235
+ );
236
+ }
237
+
238
+ export interface RedactionRange {
239
+ startOffset: number;
240
+ endOffset: number;
241
+ }
242
+
243
+ function mergeSourceRanges(ranges: readonly RedactionRange[]): RedactionRange[] {
244
+ const sorted = [...ranges].sort((left, right) => left.startOffset - right.startOffset);
245
+ const merged: RedactionRange[] = [];
246
+ for (const range of sorted) {
247
+ const last = merged.at(-1);
248
+ if (last && range.startOffset <= last.endOffset) {
249
+ last.endOffset = Math.max(last.endOffset, range.endOffset);
250
+ continue;
251
+ }
252
+ merged.push({ ...range });
253
+ }
254
+ return merged;
255
+ }
256
+
257
+ function findingToDiagnostic(
258
+ finding: QtiDeliverySecurityFinding,
259
+ diagnosticCodeForFinding: (kind: QtiDeliverySecurityFindingKind) => string,
260
+ ): QtiDiagnostic {
261
+ return {
262
+ code: diagnosticCodeForFinding(finding.kind),
263
+ severity: "error",
264
+ message: finding.message,
265
+ path: finding.source?.path,
266
+ source: finding.source,
267
+ };
268
+ }
@@ -1,13 +1,25 @@
1
- import type { QtiDiagnostic, QtiSourceLocation } from "./types.js";
2
- import { descendants, parseXmlTree, type XmlNode } from "./xml.js";
1
+ import {
2
+ FORBIDDEN_DELIVERY_SECRET_ELEMENT_NAMES,
3
+ analyzeDeliveryXml,
4
+ isDeclarationDefaultValue,
5
+ normalizedQtiElementName,
6
+ parseDeliveryXml,
7
+ parseXmlBoolean,
8
+ redactDeliveryXml,
9
+ type DeliveryRedactionPolicy,
10
+ type QtiDeliverySecurityAnalysis,
11
+ type QtiDeliverySecurityFindingKind,
12
+ } from "./delivery-redaction.js";
13
+ import type { XmlNode } from "./xml.js";
14
+
15
+ export type {
16
+ QtiDeliverySecurityAnalysis,
17
+ QtiDeliverySecurityFinding,
18
+ QtiDeliverySecurityFindingKind,
19
+ } from "./delivery-redaction.js";
3
20
 
4
21
  const FORBIDDEN_DELIVERY_ELEMENT_NAMES = new Set([
5
- "correct-response",
6
- "mapping",
7
- "area-mapping",
8
- "match-table",
9
- "interpolation-table",
10
- "response-processing",
22
+ ...FORBIDDEN_DELIVERY_SECRET_ELEMENT_NAMES,
11
23
  "feedback-inline",
12
24
  "feedback-block",
13
25
  "modal-feedback",
@@ -18,222 +30,63 @@ const UNSUPPORTED_SECURE_DELIVERY_ELEMENT_NAMES = new Set([
18
30
  "set-correct-response",
19
31
  ]);
20
32
 
21
- const DEFAULT_VALUE_DECLARATION_ELEMENT_NAMES = new Set([
22
- "response-declaration",
23
- "outcome-declaration",
24
- "template-declaration",
25
- ]);
26
-
27
- export type QtiDeliverySecurityFindingKind =
28
- | "forbidden-delivery-element"
29
- | "unsupported-secure-delivery-element"
30
- | "unsupported-adaptive-response-processing";
31
-
32
- export interface QtiDeliverySecurityFinding {
33
- kind: QtiDeliverySecurityFindingKind;
34
- qtiName: string;
35
- localName: string;
36
- message: string;
37
- source?: QtiSourceLocation | undefined;
38
- }
39
-
40
- export interface QtiDeliverySecurityAnalysis {
41
- diagnostics: QtiDiagnostic[];
42
- findings: QtiDeliverySecurityFinding[];
43
- /** True when this exact XML contains no known answer/scoring/feedback delivery leaks. */
44
- deliverySafe: boolean;
45
- /** True when secure-delivery redaction can be attempted for this XML. */
46
- secureDeliverySupported: boolean;
47
- }
48
-
49
33
  export interface QtiDeliverySafeXmlResult {
50
34
  ok: boolean;
51
- diagnostics: QtiDiagnostic[];
35
+ diagnostics: QtiDeliverySecurityAnalysis["diagnostics"];
52
36
  analysis: QtiDeliverySecurityAnalysis;
53
37
  xml?: string | undefined;
54
38
  }
55
39
 
56
40
  export function analyzeQtiDeliverySecurity(xml: string): QtiDeliverySecurityAnalysis {
57
- const parsed = parseDeliveryXml(xml);
58
- return analyzeParsedDeliveryXml(parsed);
41
+ return analyzeDeliveryXml(parseDeliveryXml(xml), staticDeliveryPolicy);
59
42
  }
60
43
 
61
- function analyzeParsedDeliveryXml(parsed: {
62
- root: XmlNode | undefined;
63
- diagnostics: QtiDiagnostic[];
64
- }): QtiDeliverySecurityAnalysis {
65
- const diagnostics = [...parsed.diagnostics];
66
- const findings: QtiDeliverySecurityFinding[] = [];
67
-
68
- if (parsed.root) {
69
- const nodes = [parsed.root, ...descendants(parsed.root, () => true)];
70
- for (const node of nodes) {
71
- const normalizedName = normalizedQtiElementName(node.localName);
72
- if (isForbiddenDeliveryElement(node, normalizedName)) {
73
- findings.push({
74
- kind: "forbidden-delivery-element",
75
- qtiName: node.name,
76
- localName: node.localName,
77
- message: `${node.name} exposes answer keys, scoring, mapping, feedback, or solution information during delivery.`,
78
- source: node.source,
79
- });
80
- }
81
- if (UNSUPPORTED_SECURE_DELIVERY_ELEMENT_NAMES.has(normalizedName)) {
82
- findings.push({
83
- kind: "unsupported-secure-delivery-element",
84
- qtiName: node.name,
85
- localName: node.localName,
86
- message: `${node.name} is not supported by secure delivery redaction v1.`,
87
- source: node.source,
88
- });
89
- }
44
+ export function buildQtiDeliverySafeXml(xml: string): QtiDeliverySafeXmlResult {
45
+ return redactDeliveryXml(xml, staticDeliveryPolicy);
46
+ }
47
+
48
+ const staticDeliveryPolicy: DeliveryRedactionPolicy = {
49
+ isForbiddenElement(node, normalizedName) {
50
+ return (
51
+ FORBIDDEN_DELIVERY_ELEMENT_NAMES.has(normalizedName) ||
52
+ isDeclarationDefaultValue(node, normalizedName)
53
+ );
54
+ },
55
+ unsupportedFinding(node, normalizedName, nodes) {
56
+ if (UNSUPPORTED_SECURE_DELIVERY_ELEMENT_NAMES.has(normalizedName)) {
57
+ return {
58
+ kind: "unsupported-secure-delivery-element",
59
+ qtiName: node.name,
60
+ localName: node.localName,
61
+ message: `${node.name} is not supported by secure delivery redaction v1.`,
62
+ source: node.source,
63
+ };
90
64
  }
91
65
 
92
66
  if (
93
- parseXmlBoolean(parsed.root.attributes.adaptive) === true &&
94
- nodes.some((node) => normalizedQtiElementName(node.localName) === "response-processing")
67
+ node.parent === undefined &&
68
+ parseXmlBoolean(node.attributes.adaptive) === true &&
69
+ nodes.some(
70
+ (candidate) => normalizedQtiElementName(candidate.localName) === "response-processing",
71
+ )
95
72
  ) {
96
- findings.push({
73
+ return {
97
74
  kind: "unsupported-adaptive-response-processing",
98
- qtiName: parsed.root.name,
99
- localName: parsed.root.localName,
75
+ qtiName: node.name,
76
+ localName: node.localName,
100
77
  message:
101
78
  "Adaptive response processing requires server-side item materialization before secure delivery.",
102
- source: parsed.root.source,
103
- });
79
+ source: node.source,
80
+ };
104
81
  }
105
- }
106
-
107
- diagnostics.push(...findings.map(findingToDiagnostic));
108
- const parseOk = parsed.diagnostics.every((diagnostic) => diagnostic.severity !== "error");
109
82
 
110
- return {
111
- diagnostics,
112
- findings,
113
- deliverySafe:
114
- parseOk && !findings.some((finding) => finding.kind === "forbidden-delivery-element"),
115
- secureDeliverySupported:
116
- parseOk &&
117
- !findings.some(
118
- (finding) =>
119
- finding.kind === "unsupported-secure-delivery-element" ||
120
- finding.kind === "unsupported-adaptive-response-processing",
121
- ),
122
- };
123
- }
124
-
125
- export function buildQtiDeliverySafeXml(xml: string): QtiDeliverySafeXmlResult {
126
- const parsed = parseDeliveryXml(xml);
127
- const analysis = analyzeParsedDeliveryXml(parsed);
128
- if (!analysis.secureDeliverySupported) {
129
- return {
130
- ok: false,
131
- diagnostics: analysis.diagnostics,
132
- analysis,
133
- };
134
- }
135
-
136
- if (!parsed.root) {
137
- return {
138
- ok: false,
139
- diagnostics: parsed.diagnostics,
140
- analysis,
141
- };
142
- }
143
-
144
- const redactionRanges = readRedactionRanges([
145
- parsed.root,
146
- ...descendants(parsed.root, () => true),
147
- ]);
148
- const redactedXml = removeSourceRanges(xml, redactionRanges);
149
- const redactedAnalysis = analyzeQtiDeliverySecurity(redactedXml);
150
- if (!redactedAnalysis.deliverySafe) {
151
- return {
152
- ok: false,
153
- diagnostics: [...analysis.diagnostics, ...redactedAnalysis.diagnostics],
154
- analysis: redactedAnalysis,
155
- };
156
- }
157
-
158
- return {
159
- ok: true,
160
- xml: redactedXml,
161
- diagnostics: redactedAnalysis.diagnostics,
162
- analysis: redactedAnalysis,
163
- };
164
- }
165
-
166
- function parseDeliveryXml(xml: string): {
167
- root: XmlNode | undefined;
168
- diagnostics: QtiDiagnostic[];
169
- } {
170
- let parsed: ReturnType<typeof parseXmlTree>;
171
- try {
172
- parsed = parseXmlTree(xml);
173
- } catch (error) {
174
- return {
175
- root: undefined,
176
- diagnostics: [
177
- {
178
- code: "xml.parse",
179
- severity: "error",
180
- message: error instanceof Error ? error.message : String(error),
181
- },
182
- ],
183
- };
184
- }
185
-
186
- const diagnostics: QtiDiagnostic[] = parsed.errors.map((error) => ({
187
- code: "xml.parse",
188
- severity: "error",
189
- message: error.message,
190
- }));
191
- if (!parsed.root) {
192
- diagnostics.push({
193
- code: "xml.empty",
194
- severity: "error",
195
- message: "No XML root element was found.",
196
- });
197
- }
198
- return { root: parsed.root, diagnostics };
199
- }
200
-
201
- function normalizedQtiElementName(localName: string): string {
202
- const lower = localName.toLowerCase();
203
- return lower.startsWith("qti-") ? lower.slice("qti-".length) : lower;
204
- }
205
-
206
- function parseXmlBoolean(value: string | undefined): boolean | undefined {
207
- if (value === undefined) return undefined;
208
- const normalized = value.trim().toLowerCase();
209
- if (normalized === "true" || normalized === "1") return true;
210
- if (normalized === "false" || normalized === "0") return false;
211
- return undefined;
212
- }
213
-
214
- function isForbiddenDeliveryElement(
215
- node: XmlNode,
216
- normalizedName = normalizedQtiElementName(node.localName),
217
- ): boolean {
218
- if (FORBIDDEN_DELIVERY_ELEMENT_NAMES.has(normalizedName)) return true;
219
- return (
220
- normalizedName === "default-value" &&
221
- DEFAULT_VALUE_DECLARATION_ELEMENT_NAMES.has(
222
- normalizedQtiElementName(node.parent?.localName ?? ""),
223
- )
224
- );
225
- }
226
-
227
- function findingToDiagnostic(finding: QtiDeliverySecurityFinding): QtiDiagnostic {
228
- const code = diagnosticCodeForFinding(finding.kind);
229
- return {
230
- code,
231
- severity: "error",
232
- message: finding.message,
233
- path: finding.source?.path,
234
- source: finding.source,
235
- };
236
- }
83
+ return null;
84
+ },
85
+ diagnosticCodeForFinding,
86
+ forbiddenElementMessage(node: XmlNode) {
87
+ return `${node.name} exposes answer keys, scoring, mapping, feedback, or solution information during delivery.`;
88
+ },
89
+ };
237
90
 
238
91
  function diagnosticCodeForFinding(kind: QtiDeliverySecurityFindingKind): string {
239
92
  if (kind === "forbidden-delivery-element") return "delivery.forbiddenElement";
@@ -242,42 +95,3 @@ function diagnosticCodeForFinding(kind: QtiDeliverySecurityFindingKind): string
242
95
  }
243
96
  return "delivery.unsupportedSecureDelivery";
244
97
  }
245
-
246
- interface RedactionRange {
247
- startOffset: number;
248
- endOffset: number;
249
- }
250
-
251
- function readRedactionRanges(nodes: XmlNode[]): RedactionRange[] {
252
- const ranges = nodes.flatMap((node) => {
253
- if (!isForbiddenDeliveryElement(node)) return [];
254
- const endOffset = node.sourceRange.endOffset;
255
- if (node.sourceRange.startOffset < 0 || endOffset === undefined) return [];
256
- return [{ startOffset: node.sourceRange.startOffset, endOffset }];
257
- });
258
- return mergeSourceRanges(ranges);
259
- }
260
-
261
- function mergeSourceRanges(ranges: RedactionRange[]): RedactionRange[] {
262
- const sorted = [...ranges].sort((left, right) => left.startOffset - right.startOffset);
263
- const merged: RedactionRange[] = [];
264
- for (const range of sorted) {
265
- const last = merged.at(-1);
266
- if (last && range.startOffset <= last.endOffset) {
267
- last.endOffset = Math.max(last.endOffset, range.endOffset);
268
- continue;
269
- }
270
- merged.push({ ...range });
271
- }
272
- return merged;
273
- }
274
-
275
- function removeSourceRanges(xml: string, ranges: RedactionRange[]): string {
276
- let output = "";
277
- let cursor = 0;
278
- for (const range of ranges) {
279
- output += xml.slice(cursor, range.startOffset);
280
- cursor = range.endOffset;
281
- }
282
- return output + xml.slice(cursor);
283
- }
package/src/index.ts CHANGED
@@ -1,3 +1,9 @@
1
+ export {
2
+ processQtiAdaptiveItemTurn,
3
+ type QtiAdaptiveTurnInput,
4
+ type QtiAdaptiveTurnResponseInput,
5
+ type QtiAdaptiveTurnResult,
6
+ } from "./adaptive-turn.js";
1
7
  export {
2
8
  createCatalogSupportResolution,
3
9
  type QtiCatalogSupportResolution,
@@ -227,6 +233,7 @@ export type {
227
233
  QtiResponseProcessing,
228
234
  QtiResponseRule,
229
235
  QtiScoreResult,
236
+ QtiStylesheet,
230
237
  QtiLookupOutcomeValue,
231
238
  QtiSetOutcomeValue,
232
239
  QtiSupportStatus,