@longsightgroup/qti3-core 0.7.2 → 0.8.0

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 (119) hide show
  1. package/README.md +41 -10
  2. package/dist/content-text.d.ts +14 -0
  3. package/dist/content-text.d.ts.map +1 -0
  4. package/dist/content-text.js +58 -0
  5. package/dist/content-text.js.map +1 -0
  6. package/dist/index.d.ts +7 -3
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +6 -2
  9. package/dist/index.js.map +1 -1
  10. package/dist/package-xml.d.ts +12 -0
  11. package/dist/package-xml.d.ts.map +1 -0
  12. package/dist/package-xml.js +17 -0
  13. package/dist/package-xml.js.map +1 -0
  14. package/dist/parser-custom-interactions.d.ts +8 -0
  15. package/dist/parser-custom-interactions.d.ts.map +1 -0
  16. package/dist/parser-custom-interactions.js +178 -0
  17. package/dist/parser-custom-interactions.js.map +1 -0
  18. package/dist/parser-declarations.d.ts +6 -0
  19. package/dist/parser-declarations.d.ts.map +1 -0
  20. package/dist/parser-declarations.js +136 -0
  21. package/dist/parser-declarations.js.map +1 -0
  22. package/dist/parser-item-metadata.d.ts +9 -0
  23. package/dist/parser-item-metadata.d.ts.map +1 -0
  24. package/dist/parser-item-metadata.js +158 -0
  25. package/dist/parser-item-metadata.js.map +1 -0
  26. package/dist/parser-processing.d.ts +5 -0
  27. package/dist/parser-processing.d.ts.map +1 -0
  28. package/dist/parser-processing.js +642 -0
  29. package/dist/parser-processing.js.map +1 -0
  30. package/dist/parser-values.d.ts +8 -0
  31. package/dist/parser-values.d.ts.map +1 -0
  32. package/dist/parser-values.js +49 -0
  33. package/dist/parser-values.js.map +1 -0
  34. package/dist/parser.d.ts.map +1 -1
  35. package/dist/parser.js +32 -1086
  36. package/dist/parser.js.map +1 -1
  37. package/dist/processing-expression-tags.d.ts +56 -0
  38. package/dist/processing-expression-tags.d.ts.map +1 -0
  39. package/dist/processing-expression-tags.js +52 -0
  40. package/dist/processing-expression-tags.js.map +1 -0
  41. package/dist/processing-rules.d.ts +3 -0
  42. package/dist/processing-rules.d.ts.map +1 -0
  43. package/dist/processing-rules.js +10 -0
  44. package/dist/processing-rules.js.map +1 -0
  45. package/dist/serializer-processing-expressions.d.ts +8 -0
  46. package/dist/serializer-processing-expressions.d.ts.map +1 -0
  47. package/dist/serializer-processing-expressions.js +198 -0
  48. package/dist/serializer-processing-expressions.js.map +1 -0
  49. package/dist/serializer-processing-xml.d.ts +15 -0
  50. package/dist/serializer-processing-xml.d.ts.map +1 -0
  51. package/dist/serializer-processing-xml.js +46 -0
  52. package/dist/serializer-processing-xml.js.map +1 -0
  53. package/dist/serializer-processing.d.ts +8 -0
  54. package/dist/serializer-processing.d.ts.map +1 -0
  55. package/dist/serializer-processing.js +106 -0
  56. package/dist/serializer-processing.js.map +1 -0
  57. package/dist/shared-vocabulary-authoring.d.ts +50 -0
  58. package/dist/shared-vocabulary-authoring.d.ts.map +1 -0
  59. package/dist/shared-vocabulary-authoring.js +302 -0
  60. package/dist/shared-vocabulary-authoring.js.map +1 -0
  61. package/dist/shared-vocabulary-interaction-sets.d.ts +6 -0
  62. package/dist/shared-vocabulary-interaction-sets.d.ts.map +1 -0
  63. package/dist/shared-vocabulary-interaction-sets.js +23 -0
  64. package/dist/shared-vocabulary-interaction-sets.js.map +1 -0
  65. package/dist/shared-vocabulary-interaction-validation.d.ts +3 -0
  66. package/dist/shared-vocabulary-interaction-validation.d.ts.map +1 -0
  67. package/dist/shared-vocabulary-interaction-validation.js +99 -0
  68. package/dist/shared-vocabulary-interaction-validation.js.map +1 -0
  69. package/dist/shared-vocabulary-registry-validation.d.ts +5 -0
  70. package/dist/shared-vocabulary-registry-validation.d.ts.map +1 -0
  71. package/dist/shared-vocabulary-registry-validation.js +147 -0
  72. package/dist/shared-vocabulary-registry-validation.js.map +1 -0
  73. package/dist/shared-vocabulary-support.d.ts.map +1 -1
  74. package/dist/shared-vocabulary-support.js +96 -56
  75. package/dist/shared-vocabulary-support.js.map +1 -1
  76. package/dist/shared-vocabulary.d.ts +6 -0
  77. package/dist/shared-vocabulary.d.ts.map +1 -1
  78. package/dist/shared-vocabulary.js +12 -0
  79. package/dist/shared-vocabulary.js.map +1 -1
  80. package/dist/support.d.ts +4 -1
  81. package/dist/support.d.ts.map +1 -1
  82. package/dist/support.js +159 -10
  83. package/dist/support.js.map +1 -1
  84. package/dist/types.d.ts +38 -7
  85. package/dist/types.d.ts.map +1 -1
  86. package/dist/validation.d.ts.map +1 -1
  87. package/dist/validation.js +29 -250
  88. package/dist/validation.js.map +1 -1
  89. package/dist/xml.d.ts +10 -0
  90. package/dist/xml.d.ts.map +1 -1
  91. package/dist/xml.js +321 -249
  92. package/dist/xml.js.map +1 -1
  93. package/package.json +1 -4
  94. package/src/content-text.ts +80 -0
  95. package/src/index.ts +67 -0
  96. package/src/interaction-test-fixtures.ts +44 -0
  97. package/src/package-xml.ts +30 -0
  98. package/src/parser-custom-interactions.ts +309 -0
  99. package/src/parser-declarations.ts +174 -0
  100. package/src/parser-item-metadata.ts +194 -0
  101. package/src/parser-processing.ts +701 -0
  102. package/src/parser-values.ts +61 -0
  103. package/src/parser.ts +59 -1230
  104. package/src/processing-expression-tags.ts +65 -0
  105. package/src/processing-rules.ts +11 -0
  106. package/src/serializer-processing-expressions.ts +456 -0
  107. package/src/serializer-processing-xml.ts +67 -0
  108. package/src/serializer-processing.fixtures.ts +469 -0
  109. package/src/serializer-processing.ts +196 -0
  110. package/src/shared-vocabulary-authoring.ts +409 -0
  111. package/src/shared-vocabulary-interaction-sets.ts +28 -0
  112. package/src/shared-vocabulary-interaction-validation.ts +143 -0
  113. package/src/shared-vocabulary-registry-validation.ts +207 -0
  114. package/src/shared-vocabulary-support.ts +127 -101
  115. package/src/shared-vocabulary.ts +18 -0
  116. package/src/support.ts +174 -14
  117. package/src/types.ts +48 -7
  118. package/src/validation.ts +33 -341
  119. package/src/xml.ts +378 -255
@@ -0,0 +1,65 @@
1
+ /**
2
+ * QTI processing-expression element tags used by the structured serializer.
3
+ *
4
+ * The parser still owns XML-to-model recognition in `parser-processing.ts`; when adding a new
5
+ * expression variant, update parser behavior, serializer behavior, and the exhaustive coverage
6
+ * table in `serializer-processing.fixtures.ts`.
7
+ */
8
+
9
+ export const identifierExpressionTags = {
10
+ correct: "qti-correct",
11
+ default: "qti-default",
12
+ mapResponse: "qti-map-response",
13
+ mapResponsePoint: "qti-map-response-point",
14
+ variable: "qti-variable",
15
+ } as const;
16
+
17
+ export type IdentifierExpressionType = keyof typeof identifierExpressionTags;
18
+
19
+ export const unaryExpressionTags = {
20
+ containerSize: "qti-container-size",
21
+ round: "qti-round",
22
+ truncate: "qti-truncate",
23
+ integerToFloat: "qti-integer-to-float",
24
+ } as const;
25
+
26
+ export type UnaryExpressionType = keyof typeof unaryExpressionTags;
27
+
28
+ export const naryExpressionTags = {
29
+ multiple: "qti-multiple",
30
+ ordered: "qti-ordered",
31
+ sum: "qti-sum",
32
+ product: "qti-product",
33
+ min: "qti-min",
34
+ max: "qti-max",
35
+ and: "qti-and",
36
+ or: "qti-or",
37
+ gcd: "qti-gcd",
38
+ lcm: "qti-lcm",
39
+ } as const;
40
+
41
+ export type NaryExpressionType = keyof typeof naryExpressionTags;
42
+
43
+ export const leftRightExpressionTags = {
44
+ match: "qti-match",
45
+ subtract: "qti-subtract",
46
+ divide: "qti-divide",
47
+ power: "qti-power",
48
+ integerDivide: "qti-integer-divide",
49
+ integerModulus: "qti-integer-modulus",
50
+ equal: "qti-equal",
51
+ } as const;
52
+
53
+ export type LeftRightExpressionType = keyof typeof leftRightExpressionTags;
54
+
55
+ export const numericCompareTags = {
56
+ lt: "qti-lt",
57
+ lte: "qti-lte",
58
+ gt: "qti-gt",
59
+ gte: "qti-gte",
60
+ } as const;
61
+
62
+ export const durationCompareTags = {
63
+ lt: "qti-duration-lt",
64
+ gte: "qti-duration-gte",
65
+ } as const;
@@ -0,0 +1,11 @@
1
+ import type { QtiResponseCondition, QtiResponseRule } from "./types.js";
2
+
3
+ export function responseConditionsFromRules(
4
+ rules: readonly QtiResponseRule[],
5
+ ): QtiResponseCondition[] {
6
+ return rules.flatMap((rule) => {
7
+ if (rule.type === "responseCondition") return [rule.condition];
8
+ if (rule.type === "responseProcessingFragment") return responseConditionsFromRules(rule.rules);
9
+ return [];
10
+ });
11
+ }
@@ -0,0 +1,456 @@
1
+ import {
2
+ durationCompareTags,
3
+ identifierExpressionTags,
4
+ leftRightExpressionTags,
5
+ naryExpressionTags,
6
+ numericCompareTags,
7
+ unaryExpressionTags,
8
+ type IdentifierExpressionType,
9
+ type LeftRightExpressionType,
10
+ type NaryExpressionType,
11
+ type UnaryExpressionType,
12
+ } from "./processing-expression-tags.js";
13
+ import { escapeXmlText } from "./xml.js";
14
+ import {
15
+ addSerializerDiagnostic,
16
+ knownAttributesWithBagFallback,
17
+ renderElement,
18
+ sortedBagAttributes,
19
+ type SerializationContext,
20
+ type XmlAttribute,
21
+ } from "./serializer-processing-xml.js";
22
+ import type { QtiProcessingExpression, QtiSourceLocation, QtiValue } from "./types.js";
23
+ import { qtiValueToString } from "./value-format.js";
24
+
25
+ type IdentifierExpression = Extract<QtiProcessingExpression, { type: IdentifierExpressionType }>;
26
+ type UnaryExpression = Extract<QtiProcessingExpression, { type: UnaryExpressionType }>;
27
+ type NaryExpression = Extract<QtiProcessingExpression, { type: NaryExpressionType }>;
28
+ type LeftRightExpression = Extract<QtiProcessingExpression, { type: LeftRightExpressionType }>;
29
+
30
+ /**
31
+ * Parser asymmetries that do not map 1:1 to a single expression type tag.
32
+ * See `docs/plans/issue-12-response-processing-serialization-revised.md`.
33
+ */
34
+ export function serializeExpression(
35
+ expression: QtiProcessingExpression,
36
+ context: SerializationContext,
37
+ indent: number,
38
+ ): string[] {
39
+ if (isIdentifierExpression(expression)) {
40
+ return identifierExpression(
41
+ identifierExpressionTags[expression.type],
42
+ expression.identifier,
43
+ expression.source,
44
+ context,
45
+ indent,
46
+ );
47
+ }
48
+ if (isNaryExpression(expression)) {
49
+ return renderExpressionContainer(
50
+ naryExpressionTags[expression.type],
51
+ [],
52
+ expression.expressions,
53
+ context,
54
+ indent,
55
+ );
56
+ }
57
+ if (isUnaryExpression(expression)) {
58
+ return renderExpressionContainer(
59
+ unaryExpressionTags[expression.type],
60
+ [],
61
+ [expression.expression],
62
+ context,
63
+ indent,
64
+ );
65
+ }
66
+ if (isLeftRightExpression(expression)) {
67
+ return renderExpressionContainer(
68
+ leftRightExpressionTags[expression.type],
69
+ [],
70
+ [expression.left, expression.right],
71
+ context,
72
+ indent,
73
+ );
74
+ }
75
+
76
+ switch (expression.type) {
77
+ case "baseValue":
78
+ return serializeBaseValue(expression, context, indent);
79
+ case "null":
80
+ return renderElement("qti-null", [], [], indent);
81
+ case "isNull":
82
+ return serializeIsNull(expression, context, indent);
83
+ case "matchCorrect":
84
+ return serializeMatchCorrect(expression, context, indent);
85
+ case "randomInteger":
86
+ return renderElement(
87
+ "qti-random-integer",
88
+ knownAttributesWithBagFallback(expression.attributes, [
89
+ { name: "min", fallback: finiteNumberFallback(expression.min) },
90
+ { name: "max", fallback: finiteNumberFallback(expression.max) },
91
+ { name: "step", fallback: finiteNumberFallback(expression.step) },
92
+ ]),
93
+ [],
94
+ indent,
95
+ );
96
+ case "randomFloat":
97
+ return renderElement(
98
+ "qti-random-float",
99
+ knownAttributesWithBagFallback(expression.attributes, [
100
+ { name: "min", fallback: finiteNumberFallback(expression.min) },
101
+ { name: "max", fallback: finiteNumberFallback(expression.max) },
102
+ ]),
103
+ [],
104
+ indent,
105
+ );
106
+ case "random":
107
+ return renderElement(
108
+ "qti-random",
109
+ [],
110
+ expression.values.flatMap((value) => serializeExpression(value, context, indent + 1)),
111
+ indent,
112
+ );
113
+ case "index":
114
+ return renderExpressionContainer(
115
+ "qti-index",
116
+ [["n", expression.n]],
117
+ [expression.expression],
118
+ context,
119
+ indent,
120
+ );
121
+ case "roundTo":
122
+ return renderExpressionContainer(
123
+ "qti-round-to",
124
+ [
125
+ ["rounding-mode", expression.roundingMode],
126
+ ["figures", expression.figures],
127
+ ],
128
+ [expression.expression],
129
+ context,
130
+ indent,
131
+ );
132
+ case "anyN":
133
+ return renderExpressionContainer(
134
+ "qti-any-n",
135
+ [
136
+ ["min", expression.min],
137
+ ["max", expression.max],
138
+ ],
139
+ expression.expressions,
140
+ context,
141
+ indent,
142
+ );
143
+ case "not":
144
+ return renderExpressionContainer("qti-not", [], [expression.expression], context, indent);
145
+ case "equalRounded":
146
+ return renderExpressionContainer(
147
+ "qti-equal-rounded",
148
+ [
149
+ ["rounding-mode", expression.roundingMode],
150
+ ["figures", expression.figures],
151
+ ],
152
+ [expression.left, expression.right],
153
+ context,
154
+ indent,
155
+ );
156
+ case "numericCompare":
157
+ return renderExpressionContainer(
158
+ numericCompareTags[expression.operator],
159
+ [],
160
+ [expression.left, expression.right],
161
+ context,
162
+ indent,
163
+ );
164
+ case "durationCompare":
165
+ return renderExpressionContainer(
166
+ durationCompareTags[expression.operator],
167
+ [],
168
+ [expression.left, expression.right],
169
+ context,
170
+ indent,
171
+ );
172
+ case "stringMatch":
173
+ return renderExpressionContainer(
174
+ "qti-string-match",
175
+ [
176
+ ["case-sensitive", expression.caseSensitive],
177
+ ["substring", expression.substring],
178
+ ],
179
+ [expression.left, expression.right],
180
+ context,
181
+ indent,
182
+ );
183
+ case "substring":
184
+ return renderExpressionContainer(
185
+ "qti-substring",
186
+ [["case-sensitive", expression.caseSensitive]],
187
+ [expression.left, expression.right],
188
+ context,
189
+ indent,
190
+ );
191
+ case "patternMatch":
192
+ return renderExpressionContainer(
193
+ "qti-pattern-match",
194
+ [["pattern", expression.pattern]],
195
+ [expression.expression],
196
+ context,
197
+ indent,
198
+ );
199
+ case "fieldValue":
200
+ return renderExpressionContainer(
201
+ "qti-field-value",
202
+ [["field-identifier", expression.fieldIdentifier]],
203
+ [expression.expression],
204
+ context,
205
+ indent,
206
+ );
207
+ case "member":
208
+ return renderExpressionContainer(
209
+ "qti-member",
210
+ [],
211
+ [expression.value, expression.collection],
212
+ context,
213
+ indent,
214
+ );
215
+ case "delete":
216
+ return renderExpressionContainer(
217
+ "qti-delete",
218
+ [],
219
+ [expression.value, expression.collection],
220
+ context,
221
+ indent,
222
+ );
223
+ case "contains":
224
+ return renderExpressionContainer(
225
+ "qti-contains",
226
+ [],
227
+ [expression.collection, expression.values],
228
+ context,
229
+ indent,
230
+ );
231
+ case "inside":
232
+ return renderExpressionContainer(
233
+ "qti-inside",
234
+ knownAttributesWithBagFallback(expression.attributes, [
235
+ { name: "shape", fallback: expression.shape },
236
+ { name: "coords", fallback: expression.coords.join(",") },
237
+ ]),
238
+ [expression.expression],
239
+ context,
240
+ indent,
241
+ );
242
+ case "mathConstant":
243
+ return renderElement("qti-math-constant", [["name", expression.name]], [], indent);
244
+ case "mathOperator":
245
+ return renderExpressionContainer(
246
+ "qti-math-operator",
247
+ [["name", expression.name]],
248
+ expression.expressions,
249
+ context,
250
+ indent,
251
+ );
252
+ case "repeat":
253
+ return renderExpressionContainer(
254
+ "qti-repeat",
255
+ [["number-repeats", expression.numberRepeats]],
256
+ expression.expressions,
257
+ context,
258
+ indent,
259
+ );
260
+ case "statsOperator":
261
+ return renderExpressionContainer(
262
+ "qti-stats-operator",
263
+ [["name", expression.name]],
264
+ [expression.expression],
265
+ context,
266
+ indent,
267
+ );
268
+ case "customOperator":
269
+ return renderExpressionContainer(
270
+ "qti-custom-operator",
271
+ customOperatorAttributes(expression),
272
+ expression.expressions,
273
+ context,
274
+ indent,
275
+ );
276
+ default:
277
+ return unsupportedExpression(expression, context);
278
+ }
279
+ }
280
+
281
+ function isIdentifierExpression(
282
+ expression: QtiProcessingExpression,
283
+ ): expression is IdentifierExpression {
284
+ return expression.type in identifierExpressionTags;
285
+ }
286
+
287
+ function isUnaryExpression(expression: QtiProcessingExpression): expression is UnaryExpression {
288
+ return expression.type in unaryExpressionTags;
289
+ }
290
+
291
+ function isNaryExpression(expression: QtiProcessingExpression): expression is NaryExpression {
292
+ return expression.type in naryExpressionTags;
293
+ }
294
+
295
+ function isLeftRightExpression(
296
+ expression: QtiProcessingExpression,
297
+ ): expression is LeftRightExpression {
298
+ return expression.type in leftRightExpressionTags;
299
+ }
300
+
301
+ function serializeBaseValue(
302
+ expression: Extract<QtiProcessingExpression, { type: "baseValue" }>,
303
+ context: SerializationContext,
304
+ indent: number,
305
+ ): string[] {
306
+ if (expression.baseType === undefined) {
307
+ addSerializerDiagnostic(context, "responseProcessing.serialize.invalidAttribute", {
308
+ message: "qti-base-value requires base-type.",
309
+ source: expression.source,
310
+ });
311
+ return [];
312
+ }
313
+
314
+ const text = baseValueText(expression);
315
+ if (text === undefined) {
316
+ addSerializerDiagnostic(context, "responseProcessing.serialize.invalidExpression", {
317
+ message: "qti-base-value can serialize only single scalar values.",
318
+ source: expression.source,
319
+ });
320
+ return [];
321
+ }
322
+
323
+ return renderElement(
324
+ "qti-base-value",
325
+ [["base-type", expression.baseType]],
326
+ escapeXmlText(text),
327
+ indent,
328
+ );
329
+ }
330
+
331
+ function baseValueText(
332
+ expression: Extract<QtiProcessingExpression, { type: "baseValue" }>,
333
+ ): string | undefined {
334
+ if (expression.rawValue !== undefined) return expression.rawValue;
335
+ if (!isSerializableBaseValue(expression.value)) return undefined;
336
+ return qtiValueToString(expression.value);
337
+ }
338
+
339
+ function serializeIsNull(
340
+ expression: Extract<QtiProcessingExpression, { type: "isNull" }>,
341
+ context: SerializationContext,
342
+ indent: number,
343
+ ): string[] {
344
+ if (!requireIdentifier(expression.identifier, "qti-is-null", expression.source, context)) {
345
+ return [];
346
+ }
347
+ return renderElement(
348
+ "qti-is-null",
349
+ [],
350
+ renderElement("qti-variable", [["identifier", expression.identifier]], [], indent + 1),
351
+ indent,
352
+ );
353
+ }
354
+
355
+ function serializeMatchCorrect(
356
+ expression: Extract<QtiProcessingExpression, { type: "matchCorrect" }>,
357
+ context: SerializationContext,
358
+ indent: number,
359
+ ): string[] {
360
+ if (
361
+ !requireIdentifier(expression.identifier, "qti-match", expression.source, context) ||
362
+ !requireIdentifier(expression.correctIdentifier, "qti-match", expression.source, context)
363
+ ) {
364
+ return [];
365
+ }
366
+ return renderElement(
367
+ "qti-match",
368
+ [],
369
+ [
370
+ ...renderElement("qti-variable", [["identifier", expression.identifier]], [], indent + 1),
371
+ ...renderElement(
372
+ "qti-correct",
373
+ [["identifier", expression.correctIdentifier]],
374
+ [],
375
+ indent + 1,
376
+ ),
377
+ ],
378
+ indent,
379
+ );
380
+ }
381
+
382
+ function customOperatorAttributes(
383
+ expression: Extract<QtiProcessingExpression, { type: "customOperator" }>,
384
+ ): XmlAttribute[] {
385
+ const bag = { ...expression.attributes };
386
+ if (expression.definition !== undefined && bag.definition === undefined) {
387
+ bag.definition = expression.definition;
388
+ }
389
+ if (expression.className !== undefined && bag.class === undefined) {
390
+ bag.class = expression.className;
391
+ }
392
+ return sortedBagAttributes(bag);
393
+ }
394
+
395
+ function identifierExpression(
396
+ tagName: string,
397
+ identifier: string,
398
+ source: QtiSourceLocation | undefined,
399
+ context: SerializationContext,
400
+ indent: number,
401
+ ): string[] {
402
+ if (!requireIdentifier(identifier, tagName, source, context)) return [];
403
+ return renderElement(tagName, [["identifier", identifier]], [], indent);
404
+ }
405
+
406
+ function renderExpressionContainer(
407
+ tagName: string,
408
+ attrs: readonly XmlAttribute[],
409
+ expressions: readonly QtiProcessingExpression[],
410
+ context: SerializationContext,
411
+ indent: number,
412
+ ): string[] {
413
+ return renderElement(
414
+ tagName,
415
+ attrs,
416
+ expressions.flatMap((child) => serializeExpression(child, context, indent + 1)),
417
+ indent,
418
+ );
419
+ }
420
+
421
+ function requireIdentifier(
422
+ identifier: string,
423
+ tagName: string,
424
+ source: QtiSourceLocation | undefined,
425
+ context: SerializationContext,
426
+ ): boolean {
427
+ if (identifier !== "") return true;
428
+ addSerializerDiagnostic(context, "responseProcessing.serialize.invalidAttribute", {
429
+ message: `${tagName} requires a non-empty identifier.`,
430
+ source,
431
+ });
432
+ return false;
433
+ }
434
+
435
+ function finiteNumberFallback(value: number): number | undefined {
436
+ return Number.isFinite(value) ? value : undefined;
437
+ }
438
+
439
+ function isSerializableBaseValue(value: QtiValue): value is string | number | boolean {
440
+ return (
441
+ typeof value === "string" ||
442
+ typeof value === "boolean" ||
443
+ (typeof value === "number" && Number.isFinite(value))
444
+ );
445
+ }
446
+
447
+ function unsupportedExpression(
448
+ expression: QtiProcessingExpression,
449
+ context: SerializationContext,
450
+ ): string[] {
451
+ addSerializerDiagnostic(context, "responseProcessing.serialize.unsupportedExpression", {
452
+ message: `Unsupported response-processing expression type: ${expression.type}.`,
453
+ source: expression.source,
454
+ });
455
+ return [];
456
+ }
@@ -0,0 +1,67 @@
1
+ import { escapeXmlAttribute } from "./xml.js";
2
+
3
+ export type XmlAttribute = readonly [name: string, value: string | number | boolean | undefined];
4
+
5
+ export interface SerializationContext {
6
+ diagnostics: import("./types.js").QtiDiagnostic[];
7
+ }
8
+
9
+ export function renderElement(
10
+ name: string,
11
+ attrs: readonly XmlAttribute[],
12
+ content: readonly string[] | string,
13
+ indent: number,
14
+ ): string[] {
15
+ const prefix = " ".repeat(indent);
16
+ const attributeText = renderAttributes(attrs);
17
+ if (typeof content === "string") {
18
+ return [`${prefix}<${name}${attributeText}>${content}</${name}>`];
19
+ }
20
+ if (content.length === 0) return [`${prefix}<${name}${attributeText}/>`];
21
+ return [`${prefix}<${name}${attributeText}>`, ...content, `${prefix}</${name}>`];
22
+ }
23
+
24
+ export function renderAttributes(attrs: readonly XmlAttribute[]): string {
25
+ return attrs
26
+ .filter((attr): attr is readonly [string, string | number | boolean] => attr[1] !== undefined)
27
+ .map(([name, value]) => ` ${name}="${escapeXmlAttribute(String(value))}"`)
28
+ .join("");
29
+ }
30
+
31
+ export function knownAttributesWithBagFallback(
32
+ bag: Record<string, string> | undefined,
33
+ known: readonly { name: string; fallback?: string | number | boolean | undefined }[],
34
+ ): XmlAttribute[] {
35
+ const knownNames = new Set(known.map((entry) => entry.name));
36
+ const resolved: XmlAttribute[] = known.flatMap(({ name, fallback }) => {
37
+ const bagValue = bag?.[name];
38
+ if (bagValue !== undefined) return [[name, bagValue] as const];
39
+ if (fallback !== undefined) return [[name, fallback] as const];
40
+ return [];
41
+ });
42
+ const extras = Object.entries(bag ?? {})
43
+ .filter(([name]) => !knownNames.has(name))
44
+ .sort(([left], [right]) => left.localeCompare(right));
45
+ return [...resolved, ...extras];
46
+ }
47
+
48
+ export function sortedBagAttributes(bag: Record<string, string> | undefined): XmlAttribute[] {
49
+ return Object.entries(bag ?? {})
50
+ .sort(([left], [right]) => left.localeCompare(right))
51
+ .map(([name, value]) => [name, value] as const);
52
+ }
53
+
54
+ export function addSerializerDiagnostic(
55
+ context: SerializationContext,
56
+ code: string,
57
+ diagnostic: Omit<import("./types.js").QtiDiagnostic, "code" | "severity"> & {
58
+ severity?: import("./types.js").QtiDiagnostic["severity"];
59
+ },
60
+ ): void {
61
+ context.diagnostics.push({
62
+ code,
63
+ severity: diagnostic.severity ?? "error",
64
+ message: diagnostic.message,
65
+ ...(diagnostic.source !== undefined ? { source: diagnostic.source } : {}),
66
+ });
67
+ }