@conform-ed/qti-react 0.0.12
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.
- package/dist/index.js +403 -0
- package/package.json +33 -0
- package/src/content-model.ts +115 -0
- package/src/index.ts +49 -0
- package/src/interactions/choice.ts +21 -0
- package/src/interactions/index.ts +16 -0
- package/src/interactions/inline-choice.ts +19 -0
- package/src/interactions/text-entry.ts +21 -0
- package/src/response-processing.ts +144 -0
- package/src/runtime.ts +315 -0
- package/src/store.ts +92 -0
- package/src/types.ts +44 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
// src/content-model.ts
|
|
2
|
+
var v0InteractionKinds = ["choiceInteraction", "textEntryInteraction", "inlineChoiceInteraction"];
|
|
3
|
+
var v0FlowElements = new Set([
|
|
4
|
+
"p",
|
|
5
|
+
"span",
|
|
6
|
+
"strong",
|
|
7
|
+
"em",
|
|
8
|
+
"b",
|
|
9
|
+
"i",
|
|
10
|
+
"br",
|
|
11
|
+
"ul",
|
|
12
|
+
"ol",
|
|
13
|
+
"li",
|
|
14
|
+
"ruby",
|
|
15
|
+
"rt",
|
|
16
|
+
"rp"
|
|
17
|
+
]);
|
|
18
|
+
var v0MathRoot = "math";
|
|
19
|
+
var v0GlobalAttributes = new Set(["id", "class", "lang", "xml:lang", "dir"]);
|
|
20
|
+
var v0ContentModel = {
|
|
21
|
+
interactionKinds: new Set(v0InteractionKinds),
|
|
22
|
+
flowElements: v0FlowElements,
|
|
23
|
+
mathRoot: v0MathRoot,
|
|
24
|
+
globalAttributes: v0GlobalAttributes
|
|
25
|
+
};
|
|
26
|
+
function isAllowedFlowElement(model, name) {
|
|
27
|
+
return model.flowElements.has(name) || name === model.mathRoot;
|
|
28
|
+
}
|
|
29
|
+
function isInteractionKind(model, kind) {
|
|
30
|
+
return model.interactionKinds.has(kind);
|
|
31
|
+
}
|
|
32
|
+
function isUnsafeAttribute(name, value) {
|
|
33
|
+
const lowerName = name.toLowerCase();
|
|
34
|
+
if (lowerName.startsWith("on")) {
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
if (typeof value === "string" && /^\s*javascript:/iu.test(value)) {
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
function sanitizeAttributes(model, attributes) {
|
|
43
|
+
const safe = {};
|
|
44
|
+
if (!attributes) {
|
|
45
|
+
return safe;
|
|
46
|
+
}
|
|
47
|
+
for (const [name, value] of Object.entries(attributes)) {
|
|
48
|
+
if (isUnsafeAttribute(name, value)) {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (!model.globalAttributes.has(name)) {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (typeof value === "string") {
|
|
55
|
+
safe[name] = value;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return safe;
|
|
59
|
+
}
|
|
60
|
+
// src/response-processing.ts
|
|
61
|
+
function foldString(value) {
|
|
62
|
+
return value.normalize("NFD").replace(/\p{Diacritic}/gu, "").toLocaleLowerCase();
|
|
63
|
+
}
|
|
64
|
+
function asList(response) {
|
|
65
|
+
if (response === null) {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
return typeof response === "string" ? [response] : [...response];
|
|
69
|
+
}
|
|
70
|
+
function isStringBaseType(declaration) {
|
|
71
|
+
return declaration.baseType === "string" || declaration.baseType === undefined;
|
|
72
|
+
}
|
|
73
|
+
function valuesEqual(a, b, fold) {
|
|
74
|
+
return fold ? foldString(a) === foldString(b) : a === b;
|
|
75
|
+
}
|
|
76
|
+
function matchCorrect(declaration, response) {
|
|
77
|
+
const correct = declaration.correctResponse;
|
|
78
|
+
if (!correct) {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
const fold = isStringBaseType(declaration);
|
|
82
|
+
const expected = correct.values.map((entry) => entry.value);
|
|
83
|
+
const actual = asList(response);
|
|
84
|
+
if (expected.length !== actual.length) {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
if (declaration.cardinality === "ordered") {
|
|
88
|
+
return expected.every((value, index) => valuesEqual(value, actual[index] ?? "", fold));
|
|
89
|
+
}
|
|
90
|
+
const remaining = [...actual];
|
|
91
|
+
for (const value of expected) {
|
|
92
|
+
const matchIndex = remaining.findIndex((candidate) => valuesEqual(candidate, value, fold));
|
|
93
|
+
if (matchIndex === -1) {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
remaining.splice(matchIndex, 1);
|
|
97
|
+
}
|
|
98
|
+
return remaining.length === 0;
|
|
99
|
+
}
|
|
100
|
+
function clamp(value, lower, upper) {
|
|
101
|
+
let result = value;
|
|
102
|
+
if (lower !== undefined && result < lower) {
|
|
103
|
+
result = lower;
|
|
104
|
+
}
|
|
105
|
+
if (upper !== undefined && result > upper) {
|
|
106
|
+
result = upper;
|
|
107
|
+
}
|
|
108
|
+
return result;
|
|
109
|
+
}
|
|
110
|
+
function mapResponse(declaration, response) {
|
|
111
|
+
const mapping = declaration.mapping;
|
|
112
|
+
if (!mapping) {
|
|
113
|
+
return 0;
|
|
114
|
+
}
|
|
115
|
+
const defaultValue = mapping.defaultValue ?? 0;
|
|
116
|
+
let total = 0;
|
|
117
|
+
for (const member of asList(response)) {
|
|
118
|
+
const entry = mapping.mapEntries.find((candidate) => valuesEqual(candidate.mapKey, member, !candidate.caseSensitive));
|
|
119
|
+
total += entry ? entry.mappedValue : defaultValue;
|
|
120
|
+
}
|
|
121
|
+
return clamp(total, mapping.lowerBound, mapping.upperBound);
|
|
122
|
+
}
|
|
123
|
+
function scoreResponse(declaration, response) {
|
|
124
|
+
if (declaration.mapping) {
|
|
125
|
+
const score = mapResponse(declaration, response);
|
|
126
|
+
const positiveSum = declaration.mapping.mapEntries.reduce((sum, entry) => sum + Math.max(entry.mappedValue, 0), 0);
|
|
127
|
+
const maxScore = declaration.mapping.upperBound ?? positiveSum;
|
|
128
|
+
return {
|
|
129
|
+
identifier: declaration.identifier,
|
|
130
|
+
score,
|
|
131
|
+
maxScore,
|
|
132
|
+
correct: maxScore > 0 && score >= maxScore
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
const correct = matchCorrect(declaration, response);
|
|
136
|
+
return {
|
|
137
|
+
identifier: declaration.identifier,
|
|
138
|
+
score: correct ? 1 : 0,
|
|
139
|
+
maxScore: 1,
|
|
140
|
+
correct
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
// src/store.ts
|
|
144
|
+
function createAttemptStore(declarations, initialResponses) {
|
|
145
|
+
const declarationsById = new Map(declarations.map((declaration) => [declaration.identifier, declaration]));
|
|
146
|
+
const listeners = new Set;
|
|
147
|
+
let snapshot = {
|
|
148
|
+
responses: { ...initialResponses },
|
|
149
|
+
submitted: false,
|
|
150
|
+
scores: []
|
|
151
|
+
};
|
|
152
|
+
function emit(next) {
|
|
153
|
+
snapshot = next;
|
|
154
|
+
for (const listener of listeners) {
|
|
155
|
+
listener();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
function computeScores(responses) {
|
|
159
|
+
return [...declarationsById.values()].map((declaration) => scoreResponse(declaration, responses[declaration.identifier] ?? null));
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
getSnapshot: () => snapshot,
|
|
163
|
+
subscribe: (listener) => {
|
|
164
|
+
listeners.add(listener);
|
|
165
|
+
return () => {
|
|
166
|
+
listeners.delete(listener);
|
|
167
|
+
};
|
|
168
|
+
},
|
|
169
|
+
setResponse: (responseIdentifier, value) => {
|
|
170
|
+
if (snapshot.submitted) {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
emit({
|
|
174
|
+
...snapshot,
|
|
175
|
+
responses: { ...snapshot.responses, [responseIdentifier]: value }
|
|
176
|
+
});
|
|
177
|
+
},
|
|
178
|
+
submit: () => {
|
|
179
|
+
const scores = computeScores(snapshot.responses);
|
|
180
|
+
emit({ ...snapshot, submitted: true, scores });
|
|
181
|
+
return scores;
|
|
182
|
+
},
|
|
183
|
+
reset: () => {
|
|
184
|
+
emit({ responses: { ...initialResponses }, submitted: false, scores: [] });
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
// src/runtime.ts
|
|
189
|
+
import {
|
|
190
|
+
createContext,
|
|
191
|
+
createElement,
|
|
192
|
+
useContext,
|
|
193
|
+
useMemo,
|
|
194
|
+
useSyncExternalStore
|
|
195
|
+
} from "react";
|
|
196
|
+
function defineInteraction(descriptor) {
|
|
197
|
+
return descriptor;
|
|
198
|
+
}
|
|
199
|
+
var RuntimeContext = createContext(null);
|
|
200
|
+
function useRuntimeContext() {
|
|
201
|
+
const context = useContext(RuntimeContext);
|
|
202
|
+
if (!context) {
|
|
203
|
+
throw new Error("QTI runtime components must be rendered inside an <ItemRenderer>.");
|
|
204
|
+
}
|
|
205
|
+
return context;
|
|
206
|
+
}
|
|
207
|
+
function responseIncludes(value, optionIdentifier) {
|
|
208
|
+
if (value === null) {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
return typeof value === "string" ? value === optionIdentifier : value.includes(optionIdentifier);
|
|
212
|
+
}
|
|
213
|
+
function isCorrectOption(declaration, optionIdentifier) {
|
|
214
|
+
return Boolean(declaration?.correctResponse?.values.some((entry) => entry.value === optionIdentifier));
|
|
215
|
+
}
|
|
216
|
+
function createQtiRuntime(config) {
|
|
217
|
+
const model = config.contentModel ?? v0ContentModel;
|
|
218
|
+
const descriptorsByKind = new Map(config.interactions.map((descriptor) => [descriptor.kind, descriptor]));
|
|
219
|
+
function renderFlow(node, key) {
|
|
220
|
+
const isMath = node.name === model.mathRoot;
|
|
221
|
+
if (!isMath && !isAllowedFlowElement(model, node.name)) {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
const attributes = sanitizeAttributes(model, node.attributes);
|
|
225
|
+
const children = node.children?.map((child, index) => renderNode(child, index));
|
|
226
|
+
return createElement(node.name, { key, ...attributes }, node.value ?? children);
|
|
227
|
+
}
|
|
228
|
+
function renderNode(node, key) {
|
|
229
|
+
if (isInteractionKind(model, node.kind) && descriptorsByKind.has(node.kind) && config.skin[node.kind]) {
|
|
230
|
+
return createElement(InteractionHost, { key, node });
|
|
231
|
+
}
|
|
232
|
+
if (node.kind === "xml") {
|
|
233
|
+
return renderFlow(node, key);
|
|
234
|
+
}
|
|
235
|
+
const value = node.value;
|
|
236
|
+
return typeof value === "string" ? value : null;
|
|
237
|
+
}
|
|
238
|
+
function InteractionHost({ node }) {
|
|
239
|
+
const { store, declarationsById } = useRuntimeContext();
|
|
240
|
+
const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
|
|
241
|
+
const responseIdentifier = node.responseIdentifier;
|
|
242
|
+
const declaration = declarationsById.get(responseIdentifier);
|
|
243
|
+
const cardinality = declaration?.cardinality ?? "single";
|
|
244
|
+
const value = snapshot.responses[responseIdentifier] ?? null;
|
|
245
|
+
const disabled = snapshot.submitted;
|
|
246
|
+
const answered = value !== null && !(typeof value === "string" && value.trim() === "") && !(Array.isArray(value) && value.length === 0);
|
|
247
|
+
let status = answered ? "answered" : "unanswered";
|
|
248
|
+
if (disabled) {
|
|
249
|
+
const scored = snapshot.scores.find((score) => score.identifier === responseIdentifier);
|
|
250
|
+
status = scored?.correct ? "correct" : "incorrect";
|
|
251
|
+
}
|
|
252
|
+
const setValue = (next) => {
|
|
253
|
+
store.setResponse(responseIdentifier, next);
|
|
254
|
+
};
|
|
255
|
+
const getOptionProps = (optionIdentifier) => {
|
|
256
|
+
const selected = responseIncludes(value, optionIdentifier);
|
|
257
|
+
let status2 = selected ? "selected" : "idle";
|
|
258
|
+
if (disabled) {
|
|
259
|
+
if (isCorrectOption(declaration, optionIdentifier)) {
|
|
260
|
+
status2 = "correct";
|
|
261
|
+
} else if (selected) {
|
|
262
|
+
status2 = "incorrect";
|
|
263
|
+
} else {
|
|
264
|
+
status2 = "idle";
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return {
|
|
268
|
+
role: cardinality === "single" ? "radio" : "checkbox",
|
|
269
|
+
tabIndex: 0,
|
|
270
|
+
"aria-checked": selected,
|
|
271
|
+
"aria-disabled": disabled,
|
|
272
|
+
"data-status": status2,
|
|
273
|
+
onClick: () => {
|
|
274
|
+
if (disabled) {
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (cardinality === "single") {
|
|
278
|
+
setValue(optionIdentifier);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const current = value === null ? [] : typeof value === "string" ? [value] : [...value];
|
|
282
|
+
const next = selected ? current.filter((entry) => entry !== optionIdentifier) : [...current, optionIdentifier];
|
|
283
|
+
setValue(next);
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
};
|
|
287
|
+
const renderContent = (nodes) => nodes ? nodes.map((child, index) => renderNode(child, index)) : null;
|
|
288
|
+
const Skin = config.skin[node.kind];
|
|
289
|
+
if (!Skin) {
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
return createElement(Skin, {
|
|
293
|
+
node,
|
|
294
|
+
responseIdentifier,
|
|
295
|
+
value,
|
|
296
|
+
setValue,
|
|
297
|
+
disabled,
|
|
298
|
+
showFeedback: disabled,
|
|
299
|
+
status,
|
|
300
|
+
getOptionProps,
|
|
301
|
+
renderContent
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
function ItemRenderer({ item, children }) {
|
|
305
|
+
const store = useMemo(() => {
|
|
306
|
+
const initial = {};
|
|
307
|
+
for (const node of item.itemBody.content ?? []) {}
|
|
308
|
+
return createAttemptStore(item.responseDeclarations, initial);
|
|
309
|
+
}, [item]);
|
|
310
|
+
const declarationsById = useMemo(() => new Map(item.responseDeclarations.map((declaration) => [declaration.identifier, declaration])), [item]);
|
|
311
|
+
const body = (item.itemBody.content ?? []).map((node, index) => renderNode(node, index));
|
|
312
|
+
return createElement(RuntimeContext.Provider, { value: { store, declarationsById } }, body, children);
|
|
313
|
+
}
|
|
314
|
+
function useAttempt() {
|
|
315
|
+
const { store } = useRuntimeContext();
|
|
316
|
+
const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
|
|
317
|
+
return {
|
|
318
|
+
...snapshot,
|
|
319
|
+
submit: store.submit,
|
|
320
|
+
reset: store.reset
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
return { ItemRenderer, useAttempt };
|
|
324
|
+
}
|
|
325
|
+
// src/interactions/choice.ts
|
|
326
|
+
import { z } from "zod";
|
|
327
|
+
var choiceInteractionNodeSchema = z.object({
|
|
328
|
+
kind: z.literal("choiceInteraction"),
|
|
329
|
+
responseIdentifier: z.string().min(1),
|
|
330
|
+
simpleChoices: z.array(z.object({ identifier: z.string().min(1) })).min(1),
|
|
331
|
+
maxChoices: z.number().int().optional()
|
|
332
|
+
});
|
|
333
|
+
var choiceInteraction = defineInteraction({
|
|
334
|
+
kind: "choiceInteraction",
|
|
335
|
+
schema: choiceInteractionNodeSchema,
|
|
336
|
+
scoring: "qti-standard",
|
|
337
|
+
initialResponse() {
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
// src/interactions/inline-choice.ts
|
|
343
|
+
import { z as z2 } from "zod";
|
|
344
|
+
var inlineChoiceInteractionNodeSchema = z2.object({
|
|
345
|
+
kind: z2.literal("inlineChoiceInteraction"),
|
|
346
|
+
responseIdentifier: z2.string().min(1),
|
|
347
|
+
inlineChoices: z2.array(z2.object({ identifier: z2.string().min(1) })).min(1)
|
|
348
|
+
});
|
|
349
|
+
var inlineChoiceInteraction = defineInteraction({
|
|
350
|
+
kind: "inlineChoiceInteraction",
|
|
351
|
+
schema: inlineChoiceInteractionNodeSchema,
|
|
352
|
+
scoring: "qti-standard",
|
|
353
|
+
initialResponse() {
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
// src/interactions/text-entry.ts
|
|
359
|
+
import { z as z3 } from "zod";
|
|
360
|
+
var textEntryInteractionNodeSchema = z3.object({
|
|
361
|
+
kind: z3.literal("textEntryInteraction"),
|
|
362
|
+
responseIdentifier: z3.string().min(1),
|
|
363
|
+
expectedLength: z3.number().int().optional(),
|
|
364
|
+
placeholderText: z3.string().optional(),
|
|
365
|
+
patternMask: z3.string().optional()
|
|
366
|
+
});
|
|
367
|
+
var textEntryInteraction = defineInteraction({
|
|
368
|
+
kind: "textEntryInteraction",
|
|
369
|
+
schema: textEntryInteractionNodeSchema,
|
|
370
|
+
scoring: "qti-standard",
|
|
371
|
+
initialResponse() {
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
// src/interactions/index.ts
|
|
377
|
+
var qtiCoreInteractions = [
|
|
378
|
+
choiceInteraction,
|
|
379
|
+
textEntryInteraction,
|
|
380
|
+
inlineChoiceInteraction
|
|
381
|
+
];
|
|
382
|
+
|
|
383
|
+
// src/index.ts
|
|
384
|
+
var qtiReactPackageName = "@conform-ed/qti-react";
|
|
385
|
+
export {
|
|
386
|
+
v0InteractionKinds,
|
|
387
|
+
v0ContentModel,
|
|
388
|
+
textEntryInteraction,
|
|
389
|
+
scoreResponse,
|
|
390
|
+
sanitizeAttributes,
|
|
391
|
+
qtiReactPackageName,
|
|
392
|
+
qtiCoreInteractions,
|
|
393
|
+
matchCorrect,
|
|
394
|
+
mapResponse,
|
|
395
|
+
isInteractionKind,
|
|
396
|
+
isAllowedFlowElement,
|
|
397
|
+
inlineChoiceInteraction,
|
|
398
|
+
foldString,
|
|
399
|
+
defineInteraction,
|
|
400
|
+
createQtiRuntime,
|
|
401
|
+
createAttemptStore,
|
|
402
|
+
choiceInteraction
|
|
403
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@conform-ed/qti-react",
|
|
3
|
+
"version": "0.0.12",
|
|
4
|
+
"files": [
|
|
5
|
+
"src",
|
|
6
|
+
"dist"
|
|
7
|
+
],
|
|
8
|
+
"type": "module",
|
|
9
|
+
"module": "src/index.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./src/index.ts",
|
|
13
|
+
"import": "./dist/index.js"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
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 .",
|
|
20
|
+
"format": "oxfmt --config ../../.oxfmtrc.jsonc --check .",
|
|
21
|
+
"test": "bun test"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/react": "^19.2.17",
|
|
25
|
+
"@types/react-dom": "^19",
|
|
26
|
+
"react": "^19.2.7",
|
|
27
|
+
"react-dom": "^19"
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"react": ">=19",
|
|
31
|
+
"zod": ">=4.4.0"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single source of truth for what may appear inside an item/stimulus body.
|
|
3
|
+
*
|
|
4
|
+
* Both the renderer's allowlist tree-walk and (later) the authoring editor schema
|
|
5
|
+
* derive from this definition. QTI 3 bodies are validated for *structure* by
|
|
6
|
+
* `@conform-ed/contracts`, but their embedded HTML flow content is modelled as a
|
|
7
|
+
* generic node tree — so validation does not sanitize. This allowlist is the
|
|
8
|
+
* sanitizer: the renderer emits React only for elements/attributes named here and
|
|
9
|
+
* drops everything else. It never injects HTML strings.
|
|
10
|
+
*
|
|
11
|
+
* v0 scope: the minimal flow/inline vocabulary plus the language-critical bits
|
|
12
|
+
* (ruby/furigana, MathML). It grows incrementally with the renderer — never "all of
|
|
13
|
+
* HTML5 at once".
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Interaction node kinds the v0 runtime can render. */
|
|
17
|
+
export const v0InteractionKinds = ["choiceInteraction", "textEntryInteraction", "inlineChoiceInteraction"] as const;
|
|
18
|
+
|
|
19
|
+
export type V0InteractionKind = (typeof v0InteractionKinds)[number];
|
|
20
|
+
|
|
21
|
+
/** Allowed HTML flow/inline element names for generic `kind: "xml"` body nodes. */
|
|
22
|
+
const v0FlowElements = new Set<string>([
|
|
23
|
+
"p",
|
|
24
|
+
"span",
|
|
25
|
+
"strong",
|
|
26
|
+
"em",
|
|
27
|
+
"b",
|
|
28
|
+
"i",
|
|
29
|
+
"br",
|
|
30
|
+
"ul",
|
|
31
|
+
"ol",
|
|
32
|
+
"li",
|
|
33
|
+
// language-critical
|
|
34
|
+
"ruby",
|
|
35
|
+
"rt",
|
|
36
|
+
"rp",
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The MathML root. Its subtree is rendered structurally (presentation MathML) with the
|
|
41
|
+
* same attribute hardening, but element names inside are not individually allowlisted
|
|
42
|
+
* in v0 — MathML has no scripting surface once event-handler attributes are stripped.
|
|
43
|
+
*/
|
|
44
|
+
const v0MathRoot = "math";
|
|
45
|
+
|
|
46
|
+
/** Globally safe attribute names. Everything else (notably `on*`, `style`) is dropped. */
|
|
47
|
+
const v0GlobalAttributes = new Set<string>(["id", "class", "lang", "xml:lang", "dir"]);
|
|
48
|
+
|
|
49
|
+
export interface ContentModel {
|
|
50
|
+
readonly interactionKinds: ReadonlySet<string>;
|
|
51
|
+
readonly flowElements: ReadonlySet<string>;
|
|
52
|
+
readonly mathRoot: string;
|
|
53
|
+
readonly globalAttributes: ReadonlySet<string>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const v0ContentModel: ContentModel = {
|
|
57
|
+
interactionKinds: new Set<string>(v0InteractionKinds),
|
|
58
|
+
flowElements: v0FlowElements,
|
|
59
|
+
mathRoot: v0MathRoot,
|
|
60
|
+
globalAttributes: v0GlobalAttributes,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export function isAllowedFlowElement(model: ContentModel, name: string): boolean {
|
|
64
|
+
return model.flowElements.has(name) || name === model.mathRoot;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function isInteractionKind(model: ContentModel, kind: string): boolean {
|
|
68
|
+
return model.interactionKinds.has(kind);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** True for an attribute name/value pair that must never reach the DOM. */
|
|
72
|
+
function isUnsafeAttribute(name: string, value: unknown): boolean {
|
|
73
|
+
const lowerName = name.toLowerCase();
|
|
74
|
+
|
|
75
|
+
if (lowerName.startsWith("on")) {
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (typeof value === "string" && /^\s*javascript:/iu.test(value)) {
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
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.
|
|
89
|
+
*/
|
|
90
|
+
export function sanitizeAttributes(
|
|
91
|
+
model: ContentModel,
|
|
92
|
+
attributes: Record<string, unknown> | undefined,
|
|
93
|
+
): Record<string, string> {
|
|
94
|
+
const safe: Record<string, string> = {};
|
|
95
|
+
|
|
96
|
+
if (!attributes) {
|
|
97
|
+
return safe;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
for (const [name, value] of Object.entries(attributes)) {
|
|
101
|
+
if (isUnsafeAttribute(name, value)) {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!model.globalAttributes.has(name)) {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (typeof value === "string") {
|
|
110
|
+
safe[name] = value;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return safe;
|
|
115
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// @conform-ed/qti-react — headless QTI 3 runtime (MIT). No Mantine; React + contracts only.
|
|
2
|
+
|
|
3
|
+
export const qtiReactPackageName = "@conform-ed/qti-react";
|
|
4
|
+
|
|
5
|
+
export {
|
|
6
|
+
v0ContentModel,
|
|
7
|
+
v0InteractionKinds,
|
|
8
|
+
isAllowedFlowElement,
|
|
9
|
+
isInteractionKind,
|
|
10
|
+
sanitizeAttributes,
|
|
11
|
+
type ContentModel,
|
|
12
|
+
type V0InteractionKind,
|
|
13
|
+
} from "./content-model";
|
|
14
|
+
|
|
15
|
+
export { foldString, mapResponse, matchCorrect, scoreResponse } from "./response-processing";
|
|
16
|
+
|
|
17
|
+
export { createAttemptStore, type AttemptSnapshot, type AttemptStore } from "./store";
|
|
18
|
+
|
|
19
|
+
export {
|
|
20
|
+
createQtiRuntime,
|
|
21
|
+
defineInteraction,
|
|
22
|
+
type AssessmentItemView,
|
|
23
|
+
type AttemptController,
|
|
24
|
+
type BodyNode,
|
|
25
|
+
type InteractionDescriptor,
|
|
26
|
+
type InteractionNode,
|
|
27
|
+
type InteractionRenderProps,
|
|
28
|
+
type InteractionSkin,
|
|
29
|
+
type InteractionStatus,
|
|
30
|
+
type ItemRendererProps,
|
|
31
|
+
type OptionProps,
|
|
32
|
+
type OptionStatus,
|
|
33
|
+
type QtiRuntime,
|
|
34
|
+
type QtiRuntimeConfig,
|
|
35
|
+
type SkinRegistry,
|
|
36
|
+
type XmlContentNode,
|
|
37
|
+
} from "./runtime";
|
|
38
|
+
|
|
39
|
+
export { choiceInteraction, inlineChoiceInteraction, qtiCoreInteractions, textEntryInteraction } from "./interactions";
|
|
40
|
+
|
|
41
|
+
export type {
|
|
42
|
+
Cardinality,
|
|
43
|
+
CorrectResponseView,
|
|
44
|
+
MapEntryView,
|
|
45
|
+
MappingView,
|
|
46
|
+
ResponseDeclarationView,
|
|
47
|
+
ResponseValue,
|
|
48
|
+
ScoreResult,
|
|
49
|
+
} from "./types";
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
import { defineInteraction } from "../runtime";
|
|
4
|
+
import type { ResponseValue } from "../types";
|
|
5
|
+
|
|
6
|
+
/** Minimal node shape the runtime/skin rely on; whole-item validation uses the contract. */
|
|
7
|
+
const choiceInteractionNodeSchema = z.object({
|
|
8
|
+
kind: z.literal("choiceInteraction"),
|
|
9
|
+
responseIdentifier: z.string().min(1),
|
|
10
|
+
simpleChoices: z.array(z.object({ identifier: z.string().min(1) })).min(1),
|
|
11
|
+
maxChoices: z.number().int().optional(),
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export const choiceInteraction = defineInteraction({
|
|
15
|
+
kind: "choiceInteraction",
|
|
16
|
+
schema: choiceInteractionNodeSchema,
|
|
17
|
+
scoring: "qti-standard",
|
|
18
|
+
initialResponse(): ResponseValue {
|
|
19
|
+
return null;
|
|
20
|
+
},
|
|
21
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { InteractionDescriptor } from "../runtime";
|
|
2
|
+
|
|
3
|
+
import { choiceInteraction } from "./choice";
|
|
4
|
+
import { inlineChoiceInteraction } from "./inline-choice";
|
|
5
|
+
import { textEntryInteraction } from "./text-entry";
|
|
6
|
+
|
|
7
|
+
export { choiceInteraction } from "./choice";
|
|
8
|
+
export { inlineChoiceInteraction } from "./inline-choice";
|
|
9
|
+
export { textEntryInteraction } from "./text-entry";
|
|
10
|
+
|
|
11
|
+
/** The v0 interaction set conform-ed ships; emergent assembles these plus its extensions. */
|
|
12
|
+
export const qtiCoreInteractions: readonly InteractionDescriptor[] = [
|
|
13
|
+
choiceInteraction,
|
|
14
|
+
textEntryInteraction,
|
|
15
|
+
inlineChoiceInteraction,
|
|
16
|
+
];
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
import { defineInteraction } from "../runtime";
|
|
4
|
+
import type { ResponseValue } from "../types";
|
|
5
|
+
|
|
6
|
+
const inlineChoiceInteractionNodeSchema = z.object({
|
|
7
|
+
kind: z.literal("inlineChoiceInteraction"),
|
|
8
|
+
responseIdentifier: z.string().min(1),
|
|
9
|
+
inlineChoices: z.array(z.object({ identifier: z.string().min(1) })).min(1),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export const inlineChoiceInteraction = defineInteraction({
|
|
13
|
+
kind: "inlineChoiceInteraction",
|
|
14
|
+
schema: inlineChoiceInteractionNodeSchema,
|
|
15
|
+
scoring: "qti-standard",
|
|
16
|
+
initialResponse(): ResponseValue {
|
|
17
|
+
return null;
|
|
18
|
+
},
|
|
19
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
import { defineInteraction } from "../runtime";
|
|
4
|
+
import type { ResponseValue } from "../types";
|
|
5
|
+
|
|
6
|
+
const textEntryInteractionNodeSchema = z.object({
|
|
7
|
+
kind: z.literal("textEntryInteraction"),
|
|
8
|
+
responseIdentifier: z.string().min(1),
|
|
9
|
+
expectedLength: z.number().int().optional(),
|
|
10
|
+
placeholderText: z.string().optional(),
|
|
11
|
+
patternMask: z.string().optional(),
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export const textEntryInteraction = defineInteraction({
|
|
15
|
+
kind: "textEntryInteraction",
|
|
16
|
+
schema: textEntryInteractionNodeSchema,
|
|
17
|
+
scoring: "qti-standard",
|
|
18
|
+
initialResponse(): ResponseValue {
|
|
19
|
+
return null;
|
|
20
|
+
},
|
|
21
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side response processing for the v0 standard scoring templates.
|
|
3
|
+
*
|
|
4
|
+
* Implements `match_correct` and `map_response` (QTI's two standard RP templates),
|
|
5
|
+
* which cover the v0 interactions (choice, textEntry, inlineChoice). Pure functions:
|
|
6
|
+
* deterministic given (declaration, response), so scoring is replayable and runs
|
|
7
|
+
* fully offline in the headless core (ADR-0003 / ADR-0006).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ResponseDeclarationView, ResponseValue, ScoreResult } from "./types";
|
|
11
|
+
|
|
12
|
+
/** Lowercase + strip combining diacritics, for non-case/accent-sensitive comparison. */
|
|
13
|
+
export function foldString(value: string): string {
|
|
14
|
+
return value
|
|
15
|
+
.normalize("NFD")
|
|
16
|
+
.replace(/\p{Diacritic}/gu, "")
|
|
17
|
+
.toLocaleLowerCase();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function asList(response: ResponseValue): string[] {
|
|
21
|
+
if (response === null) {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return typeof response === "string" ? [response] : [...response];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isStringBaseType(declaration: ResponseDeclarationView): boolean {
|
|
29
|
+
return declaration.baseType === "string" || declaration.baseType === undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function valuesEqual(a: string, b: string, fold: boolean): boolean {
|
|
33
|
+
return fold ? foldString(a) === foldString(b) : a === b;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* `match_correct`: true when the response exactly matches `correctResponse`, respecting
|
|
38
|
+
* cardinality. String base types fold case/diacritics (textEntry friendliness);
|
|
39
|
+
* identifier base types compare exactly. Returns false when no correctResponse exists.
|
|
40
|
+
*/
|
|
41
|
+
export function matchCorrect(declaration: ResponseDeclarationView, response: ResponseValue): boolean {
|
|
42
|
+
const correct = declaration.correctResponse;
|
|
43
|
+
|
|
44
|
+
if (!correct) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const fold = isStringBaseType(declaration);
|
|
49
|
+
const expected = correct.values.map((entry) => entry.value);
|
|
50
|
+
const actual = asList(response);
|
|
51
|
+
|
|
52
|
+
if (expected.length !== actual.length) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (declaration.cardinality === "ordered") {
|
|
57
|
+
return expected.every((value, index) => valuesEqual(value, actual[index] ?? "", fold));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// single + multiple: order-independent set match
|
|
61
|
+
const remaining = [...actual];
|
|
62
|
+
|
|
63
|
+
for (const value of expected) {
|
|
64
|
+
const matchIndex = remaining.findIndex((candidate) => valuesEqual(candidate, value, fold));
|
|
65
|
+
|
|
66
|
+
if (matchIndex === -1) {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
remaining.splice(matchIndex, 1);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return remaining.length === 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function clamp(value: number, lower: number | undefined, upper: number | undefined): number {
|
|
77
|
+
let result = value;
|
|
78
|
+
|
|
79
|
+
if (lower !== undefined && result < lower) {
|
|
80
|
+
result = lower;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (upper !== undefined && result > upper) {
|
|
84
|
+
result = upper;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return result;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* `map_response`: sum the mapped values of the response's members, each member mapped at
|
|
92
|
+
* most once. Honors per-entry `caseSensitive` (default: case/diacritic-insensitive),
|
|
93
|
+
* applies the mapping's `defaultValue` to unmatched members, and clamps to
|
|
94
|
+
* [lowerBound, upperBound]. Returns 0 when no mapping exists.
|
|
95
|
+
*/
|
|
96
|
+
export function mapResponse(declaration: ResponseDeclarationView, response: ResponseValue): number {
|
|
97
|
+
const mapping = declaration.mapping;
|
|
98
|
+
|
|
99
|
+
if (!mapping) {
|
|
100
|
+
return 0;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const defaultValue = mapping.defaultValue ?? 0;
|
|
104
|
+
let total = 0;
|
|
105
|
+
|
|
106
|
+
for (const member of asList(response)) {
|
|
107
|
+
const entry = mapping.mapEntries.find((candidate) =>
|
|
108
|
+
valuesEqual(candidate.mapKey, member, !candidate.caseSensitive),
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
total += entry ? entry.mappedValue : defaultValue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return clamp(total, mapping.lowerBound, mapping.upperBound);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Apply the appropriate standard template: `map_response` when a mapping is declared,
|
|
119
|
+
* otherwise `match_correct`. `maxScore` is the mapping upper bound (or the sum of
|
|
120
|
+
* positive mapped values) for mapped items, else 1 for match_correct.
|
|
121
|
+
*/
|
|
122
|
+
export function scoreResponse(declaration: ResponseDeclarationView, response: ResponseValue): ScoreResult {
|
|
123
|
+
if (declaration.mapping) {
|
|
124
|
+
const score = mapResponse(declaration, response);
|
|
125
|
+
const positiveSum = declaration.mapping.mapEntries.reduce((sum, entry) => sum + Math.max(entry.mappedValue, 0), 0);
|
|
126
|
+
const maxScore = declaration.mapping.upperBound ?? positiveSum;
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
identifier: declaration.identifier,
|
|
130
|
+
score,
|
|
131
|
+
maxScore,
|
|
132
|
+
correct: maxScore > 0 && score >= maxScore,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const correct = matchCorrect(declaration, response);
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
identifier: declaration.identifier,
|
|
140
|
+
score: correct ? 1 : 0,
|
|
141
|
+
maxScore: 1,
|
|
142
|
+
correct,
|
|
143
|
+
};
|
|
144
|
+
}
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The headless runtime (ADR-0002): a factory that assembles a QTI item renderer from an
|
|
3
|
+
* injected set of interaction descriptors plus a skin registry. The kind-union is the
|
|
4
|
+
* injected set — no global registry, no module augmentation. The core owns response
|
|
5
|
+
* state and a11y wiring; skins are controlled components.
|
|
6
|
+
*
|
|
7
|
+
* No Mantine, and no JSX — flow content and skins are composed with `createElement` so
|
|
8
|
+
* the package needs no JSX build step.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
createContext,
|
|
13
|
+
createElement,
|
|
14
|
+
useContext,
|
|
15
|
+
useMemo,
|
|
16
|
+
useSyncExternalStore,
|
|
17
|
+
type ComponentType,
|
|
18
|
+
type ReactNode,
|
|
19
|
+
} from "react";
|
|
20
|
+
import type { ZodType } from "zod";
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
isAllowedFlowElement,
|
|
24
|
+
isInteractionKind,
|
|
25
|
+
sanitizeAttributes,
|
|
26
|
+
v0ContentModel,
|
|
27
|
+
type ContentModel,
|
|
28
|
+
} from "./content-model";
|
|
29
|
+
import { createAttemptStore, type AttemptSnapshot, type AttemptStore } from "./store";
|
|
30
|
+
import type { Cardinality, ResponseDeclarationView, ResponseValue, ScoreResult } from "./types";
|
|
31
|
+
|
|
32
|
+
// ---------- Node views (structural; validated upstream by @conform-ed/contracts) ----------
|
|
33
|
+
|
|
34
|
+
export interface XmlContentNode {
|
|
35
|
+
kind: "xml";
|
|
36
|
+
name: string;
|
|
37
|
+
value?: string;
|
|
38
|
+
attributes?: Record<string, unknown>;
|
|
39
|
+
children?: BodyNode[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface InteractionNode {
|
|
43
|
+
kind: string;
|
|
44
|
+
responseIdentifier: string;
|
|
45
|
+
[field: string]: unknown;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type BodyNode = XmlContentNode | InteractionNode | { kind: string; value?: string; children?: BodyNode[] };
|
|
49
|
+
|
|
50
|
+
export interface AssessmentItemView {
|
|
51
|
+
responseDeclarations: readonly ResponseDeclarationView[];
|
|
52
|
+
itemBody: { content?: BodyNode[] };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---------- Descriptor + skin contract ----------
|
|
56
|
+
|
|
57
|
+
export interface InteractionDescriptor<Kind extends string = string> {
|
|
58
|
+
readonly kind: Kind;
|
|
59
|
+
readonly schema: ZodType;
|
|
60
|
+
readonly scoring: "qti-standard";
|
|
61
|
+
/** The empty/initial response for a fresh attempt at this interaction. */
|
|
62
|
+
initialResponse(node: InteractionNode): ResponseValue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function defineInteraction<Kind extends string>(
|
|
66
|
+
descriptor: InteractionDescriptor<Kind>,
|
|
67
|
+
): InteractionDescriptor<Kind> {
|
|
68
|
+
return descriptor;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type OptionStatus = "idle" | "selected" | "correct" | "incorrect";
|
|
72
|
+
|
|
73
|
+
/** Whole-interaction status (for interactions without options, e.g. textEntry). */
|
|
74
|
+
export type InteractionStatus = "unanswered" | "answered" | "correct" | "incorrect";
|
|
75
|
+
|
|
76
|
+
/** Prop-getter result a skin spreads onto an option element to inherit selection + a11y. */
|
|
77
|
+
export interface OptionProps {
|
|
78
|
+
role: "radio" | "checkbox";
|
|
79
|
+
tabIndex: number;
|
|
80
|
+
"aria-checked": boolean;
|
|
81
|
+
"aria-disabled": boolean;
|
|
82
|
+
"data-status": OptionStatus;
|
|
83
|
+
onClick: () => void;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Controlled props every interaction skin receives by default (ADR-0002). */
|
|
87
|
+
export interface InteractionRenderProps {
|
|
88
|
+
node: InteractionNode;
|
|
89
|
+
responseIdentifier: string;
|
|
90
|
+
value: ResponseValue;
|
|
91
|
+
setValue: (value: ResponseValue) => void;
|
|
92
|
+
/** True after submit — the interaction is read-only. */
|
|
93
|
+
disabled: boolean;
|
|
94
|
+
/** True after submit — show correct/incorrect chrome. */
|
|
95
|
+
showFeedback: boolean;
|
|
96
|
+
/** Whole-interaction status (drives feedback for option-less interactions). */
|
|
97
|
+
status: InteractionStatus;
|
|
98
|
+
getOptionProps: (optionIdentifier: string) => OptionProps;
|
|
99
|
+
/** Render body fragments (prompt, choice labels) through the core allowlist walk. */
|
|
100
|
+
renderContent: (nodes: readonly BodyNode[] | undefined) => ReactNode;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export type InteractionSkin = ComponentType<InteractionRenderProps>;
|
|
104
|
+
export type SkinRegistry = Readonly<Record<string, InteractionSkin>>;
|
|
105
|
+
|
|
106
|
+
export interface QtiRuntimeConfig {
|
|
107
|
+
readonly interactions: readonly InteractionDescriptor[];
|
|
108
|
+
readonly skin: SkinRegistry;
|
|
109
|
+
readonly contentModel?: ContentModel;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface ItemRendererProps {
|
|
113
|
+
item: AssessmentItemView;
|
|
114
|
+
// Rendered inside the same runtime context as the item body, after it. Lets a consumer
|
|
115
|
+
// drop controls (a Submit bar, a score panel) that call `useAttempt()` for this item —
|
|
116
|
+
// the attempt store is per-item and scoped to this subtree.
|
|
117
|
+
children?: ReactNode;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface QtiRuntime {
|
|
121
|
+
ItemRenderer: ComponentType<ItemRendererProps>;
|
|
122
|
+
useAttempt: () => AttemptController;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface AttemptController extends AttemptSnapshot {
|
|
126
|
+
submit: () => readonly ScoreResult[];
|
|
127
|
+
reset: () => void;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---------- Internal context ----------
|
|
131
|
+
|
|
132
|
+
interface RuntimeContextValue {
|
|
133
|
+
store: AttemptStore;
|
|
134
|
+
declarationsById: ReadonlyMap<string, ResponseDeclarationView>;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const RuntimeContext = createContext<RuntimeContextValue | null>(null);
|
|
138
|
+
|
|
139
|
+
function useRuntimeContext(): RuntimeContextValue {
|
|
140
|
+
const context = useContext(RuntimeContext);
|
|
141
|
+
|
|
142
|
+
if (!context) {
|
|
143
|
+
throw new Error("QTI runtime components must be rendered inside an <ItemRenderer>.");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return context;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function responseIncludes(value: ResponseValue, optionIdentifier: string): boolean {
|
|
150
|
+
if (value === null) {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return typeof value === "string" ? value === optionIdentifier : value.includes(optionIdentifier);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function isCorrectOption(declaration: ResponseDeclarationView | undefined, optionIdentifier: string): boolean {
|
|
158
|
+
return Boolean(declaration?.correctResponse?.values.some((entry) => entry.value === optionIdentifier));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function createQtiRuntime(config: QtiRuntimeConfig): QtiRuntime {
|
|
162
|
+
const model = config.contentModel ?? v0ContentModel;
|
|
163
|
+
const descriptorsByKind = new Map(config.interactions.map((descriptor) => [descriptor.kind, descriptor]));
|
|
164
|
+
|
|
165
|
+
function renderFlow(node: XmlContentNode, key: number): ReactNode {
|
|
166
|
+
const isMath = node.name === model.mathRoot;
|
|
167
|
+
|
|
168
|
+
if (!isMath && !isAllowedFlowElement(model, node.name)) {
|
|
169
|
+
return null; // not allowlisted → dropped (the sanitizer)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const attributes = sanitizeAttributes(model, node.attributes);
|
|
173
|
+
const children = node.children?.map((child, index) => renderNode(child, index));
|
|
174
|
+
|
|
175
|
+
return createElement(node.name, { key, ...attributes }, node.value ?? children);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function renderNode(node: BodyNode, key: number): ReactNode {
|
|
179
|
+
if (isInteractionKind(model, node.kind) && descriptorsByKind.has(node.kind) && config.skin[node.kind]) {
|
|
180
|
+
return createElement(InteractionHost, { key, node: node as InteractionNode });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (node.kind === "xml") {
|
|
184
|
+
return renderFlow(node as XmlContentNode, key);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const value = (node as { value?: string }).value;
|
|
188
|
+
|
|
189
|
+
return typeof value === "string" ? value : null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function InteractionHost({ node }: { node: InteractionNode }): ReactNode {
|
|
193
|
+
const { store, declarationsById } = useRuntimeContext();
|
|
194
|
+
const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
|
|
195
|
+
|
|
196
|
+
const responseIdentifier = node.responseIdentifier;
|
|
197
|
+
const declaration = declarationsById.get(responseIdentifier);
|
|
198
|
+
const cardinality: Cardinality = declaration?.cardinality ?? "single";
|
|
199
|
+
const value = snapshot.responses[responseIdentifier] ?? null;
|
|
200
|
+
const disabled = snapshot.submitted;
|
|
201
|
+
|
|
202
|
+
const answered =
|
|
203
|
+
value !== null &&
|
|
204
|
+
!(typeof value === "string" && value.trim() === "") &&
|
|
205
|
+
!(Array.isArray(value) && value.length === 0);
|
|
206
|
+
|
|
207
|
+
let status: InteractionStatus = answered ? "answered" : "unanswered";
|
|
208
|
+
|
|
209
|
+
if (disabled) {
|
|
210
|
+
const scored = snapshot.scores.find((score) => score.identifier === responseIdentifier);
|
|
211
|
+
status = scored?.correct ? "correct" : "incorrect";
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const setValue = (next: ResponseValue): void => {
|
|
215
|
+
store.setResponse(responseIdentifier, next);
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const getOptionProps = (optionIdentifier: string): OptionProps => {
|
|
219
|
+
const selected = responseIncludes(value, optionIdentifier);
|
|
220
|
+
|
|
221
|
+
let status: OptionStatus = selected ? "selected" : "idle";
|
|
222
|
+
|
|
223
|
+
if (disabled) {
|
|
224
|
+
if (isCorrectOption(declaration, optionIdentifier)) {
|
|
225
|
+
status = "correct";
|
|
226
|
+
} else if (selected) {
|
|
227
|
+
status = "incorrect";
|
|
228
|
+
} else {
|
|
229
|
+
status = "idle";
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
role: cardinality === "single" ? "radio" : "checkbox",
|
|
235
|
+
tabIndex: 0,
|
|
236
|
+
"aria-checked": selected,
|
|
237
|
+
"aria-disabled": disabled,
|
|
238
|
+
"data-status": status,
|
|
239
|
+
onClick: () => {
|
|
240
|
+
if (disabled) {
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (cardinality === "single") {
|
|
245
|
+
setValue(optionIdentifier);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const current = value === null ? [] : typeof value === "string" ? [value] : [...value];
|
|
250
|
+
const next = selected
|
|
251
|
+
? current.filter((entry) => entry !== optionIdentifier)
|
|
252
|
+
: [...current, optionIdentifier];
|
|
253
|
+
|
|
254
|
+
setValue(next);
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const renderContent = (nodes: readonly BodyNode[] | undefined): ReactNode =>
|
|
260
|
+
nodes ? nodes.map((child, index) => renderNode(child, index)) : null;
|
|
261
|
+
|
|
262
|
+
const Skin = config.skin[node.kind];
|
|
263
|
+
|
|
264
|
+
if (!Skin) {
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return createElement(Skin, {
|
|
269
|
+
node,
|
|
270
|
+
responseIdentifier,
|
|
271
|
+
value,
|
|
272
|
+
setValue,
|
|
273
|
+
disabled,
|
|
274
|
+
showFeedback: disabled,
|
|
275
|
+
status,
|
|
276
|
+
getOptionProps,
|
|
277
|
+
renderContent,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function ItemRenderer({ item, children }: ItemRendererProps): ReactNode {
|
|
282
|
+
const store = useMemo(() => {
|
|
283
|
+
const initial: Record<string, ResponseValue> = {};
|
|
284
|
+
|
|
285
|
+
for (const node of item.itemBody.content ?? []) {
|
|
286
|
+
// initial responses are seeded lazily by skins; declarations drive scoring.
|
|
287
|
+
void node;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return createAttemptStore(item.responseDeclarations, initial);
|
|
291
|
+
}, [item]);
|
|
292
|
+
|
|
293
|
+
const declarationsById = useMemo(
|
|
294
|
+
() => new Map(item.responseDeclarations.map((declaration) => [declaration.identifier, declaration])),
|
|
295
|
+
[item],
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
const body = (item.itemBody.content ?? []).map((node, index) => renderNode(node, index));
|
|
299
|
+
|
|
300
|
+
return createElement(RuntimeContext.Provider, { value: { store, declarationsById } }, body, children);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function useAttempt(): AttemptController {
|
|
304
|
+
const { store } = useRuntimeContext();
|
|
305
|
+
const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
|
|
306
|
+
|
|
307
|
+
return {
|
|
308
|
+
...snapshot,
|
|
309
|
+
submit: store.submit,
|
|
310
|
+
reset: store.reset,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return { ItemRenderer, useAttempt };
|
|
315
|
+
}
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The response store the headless core owns (ADR-0002): it holds candidate responses
|
|
3
|
+
* keyed by `responseIdentifier`, the submitted flag, and the scored outcomes. Skins are
|
|
4
|
+
* controlled against it; they never own response state (only ephemeral UI state).
|
|
5
|
+
*
|
|
6
|
+
* Backed by an external store so `useSyncExternalStore` can subscribe with narrow,
|
|
7
|
+
* snapshot-stable reads. No React import here — the hook lives in the runtime.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { scoreResponse } from "./response-processing";
|
|
11
|
+
import type { ResponseDeclarationView, ResponseValue, ScoreResult } from "./types";
|
|
12
|
+
|
|
13
|
+
export interface AttemptSnapshot {
|
|
14
|
+
readonly responses: Readonly<Record<string, ResponseValue>>;
|
|
15
|
+
readonly submitted: boolean;
|
|
16
|
+
readonly scores: readonly ScoreResult[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface AttemptStore {
|
|
20
|
+
// Function-property signatures (not methods): these are bound arrow functions, safe to
|
|
21
|
+
// pass by reference (e.g. to useSyncExternalStore).
|
|
22
|
+
readonly getSnapshot: () => AttemptSnapshot;
|
|
23
|
+
readonly subscribe: (listener: () => void) => () => void;
|
|
24
|
+
readonly setResponse: (responseIdentifier: string, value: ResponseValue) => void;
|
|
25
|
+
readonly submit: () => readonly ScoreResult[];
|
|
26
|
+
readonly reset: () => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function createAttemptStore(
|
|
30
|
+
declarations: readonly ResponseDeclarationView[],
|
|
31
|
+
initialResponses: Readonly<Record<string, ResponseValue>>,
|
|
32
|
+
): AttemptStore {
|
|
33
|
+
const declarationsById = new Map(declarations.map((declaration) => [declaration.identifier, declaration]));
|
|
34
|
+
const listeners = new Set<() => void>();
|
|
35
|
+
|
|
36
|
+
let snapshot: AttemptSnapshot = {
|
|
37
|
+
responses: { ...initialResponses },
|
|
38
|
+
submitted: false,
|
|
39
|
+
scores: [],
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function emit(next: AttemptSnapshot): void {
|
|
43
|
+
snapshot = next;
|
|
44
|
+
|
|
45
|
+
for (const listener of listeners) {
|
|
46
|
+
listener();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function computeScores(responses: Readonly<Record<string, ResponseValue>>): readonly ScoreResult[] {
|
|
51
|
+
return [...declarationsById.values()].map((declaration) =>
|
|
52
|
+
scoreResponse(declaration, responses[declaration.identifier] ?? null),
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Arrow-function properties: inherently bound, so they can be passed by reference
|
|
57
|
+
// (e.g. to useSyncExternalStore) without `this` hazards.
|
|
58
|
+
return {
|
|
59
|
+
getSnapshot: () => snapshot,
|
|
60
|
+
|
|
61
|
+
subscribe: (listener) => {
|
|
62
|
+
listeners.add(listener);
|
|
63
|
+
|
|
64
|
+
return () => {
|
|
65
|
+
listeners.delete(listener);
|
|
66
|
+
};
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
setResponse: (responseIdentifier, value) => {
|
|
70
|
+
if (snapshot.submitted) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
emit({
|
|
75
|
+
...snapshot,
|
|
76
|
+
responses: { ...snapshot.responses, [responseIdentifier]: value },
|
|
77
|
+
});
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
submit: () => {
|
|
81
|
+
const scores = computeScores(snapshot.responses);
|
|
82
|
+
|
|
83
|
+
emit({ ...snapshot, submitted: true, scores });
|
|
84
|
+
|
|
85
|
+
return scores;
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
reset: () => {
|
|
89
|
+
emit({ responses: { ...initialResponses }, submitted: false, scores: [] });
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
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
|
+
|
|
8
|
+
/** A candidate response for one interaction, keyed in state by `responseIdentifier`. */
|
|
9
|
+
export type ResponseValue = string | readonly string[] | null;
|
|
10
|
+
|
|
11
|
+
export type Cardinality = "single" | "multiple" | "ordered" | "record";
|
|
12
|
+
|
|
13
|
+
export interface CorrectResponseView {
|
|
14
|
+
readonly values: ReadonlyArray<{ readonly value: string }>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface MapEntryView {
|
|
18
|
+
readonly mapKey: string;
|
|
19
|
+
readonly mappedValue: number;
|
|
20
|
+
readonly caseSensitive?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface MappingView {
|
|
24
|
+
readonly mapEntries: readonly MapEntryView[];
|
|
25
|
+
readonly lowerBound?: number;
|
|
26
|
+
readonly upperBound?: number;
|
|
27
|
+
readonly defaultValue?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ResponseDeclarationView {
|
|
31
|
+
readonly identifier: string;
|
|
32
|
+
readonly cardinality: Cardinality;
|
|
33
|
+
readonly baseType?: string;
|
|
34
|
+
readonly correctResponse?: CorrectResponseView;
|
|
35
|
+
readonly mapping?: MappingView;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The scored outcome for one response variable. */
|
|
39
|
+
export interface ScoreResult {
|
|
40
|
+
readonly identifier: string;
|
|
41
|
+
readonly score: number;
|
|
42
|
+
readonly maxScore: number;
|
|
43
|
+
readonly correct: boolean;
|
|
44
|
+
}
|