@conform-ed/qti-react 0.0.16 → 0.0.18
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/headless.d.ts +14 -0
- package/dist/headless.js +3062 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +142 -96
- package/dist/item-capability.d.ts +43 -0
- package/package.json +10 -4
- package/src/headless.ts +26 -0
- package/src/index.ts +2 -0
- package/src/item-capability.ts +211 -0
- package/src/runtime.ts +17 -123
package/dist/index.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ export declare const qtiReactPackageName = "@conform-ed/qti-react";
|
|
|
2
2
|
export { v0ContentModel, v0InteractionKinds, isAllowedFlowElement, isInteractionKind, sanitizeAttributes, type ContentModel, type V0InteractionKind, } from "./content-model";
|
|
3
3
|
export { foldString, mapResponse, matchCorrect, mapResponsePoint, scoreResponse } from "./response-processing";
|
|
4
4
|
export { assessmentItemViewFromNormalized, assessmentTestViewFromNormalized, stimulusContentFromNormalized, } from "./normalized-item";
|
|
5
|
+
export { referenceInteractionKinds, reportItemCapability, type ItemCapabilityOptions } from "./item-capability";
|
|
5
6
|
export { formatPoint, parseCoords, parsePoint, pointInShape, type Point, type QtiShape } from "./graphic";
|
|
6
7
|
export { applyCorrectResponseOverrides, collectRpIssues, collectTemplateIssues, executeResponseProcessing, executeTemplateProcessing, mulberry32, resolveTemplate, } from "./rp";
|
|
7
8
|
export type { CustomOperatorImplementation, InterpolationTableEntryView, InterpolationTableView, MatchTableEntryView, MatchTableView, MaybeRpValue, OutcomeDeclarationView, OutcomeValue, ResponseNormalization, ResponseProcessingContext, ResponseProcessingResult, ResponseProcessingView, RpConditionBranch, RpExpressionView, RpRecordField, RpRuleView, RpScalar, RpValue, TemplateConditionBranch, TemplateDeclarationView, TemplateProcessingContext, TemplateProcessingResult, TemplateProcessingView, TemplateRuleView, } from "./rp";
|
package/dist/index.js
CHANGED
|
@@ -2930,6 +2930,127 @@ function collectTemplateIssues(view, options) {
|
|
|
2930
2930
|
walkRules(view.rules);
|
|
2931
2931
|
return issues;
|
|
2932
2932
|
}
|
|
2933
|
+
// src/item-capability.ts
|
|
2934
|
+
var feedbackKinds = new Set(["feedbackInline", "feedbackBlock"]);
|
|
2935
|
+
var templateContentKinds = new Set(["templateInline", "templateBlock"]);
|
|
2936
|
+
var intrinsicLeafKinds = new Set(["text", "printedVariable"]);
|
|
2937
|
+
function isFeedbackNode(node) {
|
|
2938
|
+
return feedbackKinds.has(node.kind);
|
|
2939
|
+
}
|
|
2940
|
+
function isTemplateContentNode(node) {
|
|
2941
|
+
return templateContentKinds.has(node.kind);
|
|
2942
|
+
}
|
|
2943
|
+
function isInteractionNode(node) {
|
|
2944
|
+
return node.kind !== "xml" && typeof node.responseIdentifier === "string";
|
|
2945
|
+
}
|
|
2946
|
+
function reportItemCapability(item, options) {
|
|
2947
|
+
const model = options.model ?? v0ContentModel;
|
|
2948
|
+
const customOperatorClasses = options.customOperatorClasses ?? new Set;
|
|
2949
|
+
const issues = [];
|
|
2950
|
+
const seen = new Set;
|
|
2951
|
+
function report(issue) {
|
|
2952
|
+
const dedupeKey = `${issue.type}:${issue.name}:${issue.responseIdentifier ?? ""}`;
|
|
2953
|
+
if (!seen.has(dedupeKey)) {
|
|
2954
|
+
seen.add(dedupeKey);
|
|
2955
|
+
issues.push(issue);
|
|
2956
|
+
}
|
|
2957
|
+
}
|
|
2958
|
+
function walk(node) {
|
|
2959
|
+
if (isFeedbackNode(node) || isTemplateContentNode(node) || node.kind === "rubricBlock") {
|
|
2960
|
+
for (const child of node.content ?? []) {
|
|
2961
|
+
walk(child);
|
|
2962
|
+
}
|
|
2963
|
+
return;
|
|
2964
|
+
}
|
|
2965
|
+
if (isInteractionNode(node)) {
|
|
2966
|
+
if (!options.supportedInteractions.has(node.kind)) {
|
|
2967
|
+
report({ type: "unsupported-interaction", name: node.kind, responseIdentifier: node.responseIdentifier });
|
|
2968
|
+
return;
|
|
2969
|
+
}
|
|
2970
|
+
const schema = options.interactionSchemas?.get(node.kind);
|
|
2971
|
+
if (schema) {
|
|
2972
|
+
const parsed = schema.safeParse(node);
|
|
2973
|
+
if (!parsed.success) {
|
|
2974
|
+
const detail = parsed.error.issues[0]?.message;
|
|
2975
|
+
report({
|
|
2976
|
+
type: "invalid-interaction",
|
|
2977
|
+
name: node.kind,
|
|
2978
|
+
responseIdentifier: node.responseIdentifier,
|
|
2979
|
+
...detail !== undefined ? { detail } : {}
|
|
2980
|
+
});
|
|
2981
|
+
}
|
|
2982
|
+
}
|
|
2983
|
+
return;
|
|
2984
|
+
}
|
|
2985
|
+
if (node.kind === "xml") {
|
|
2986
|
+
const xmlNode = node;
|
|
2987
|
+
if (xmlNode.name === model.mathRoot) {
|
|
2988
|
+
return;
|
|
2989
|
+
}
|
|
2990
|
+
if (!isAllowedFlowElement(model, xmlNode.name)) {
|
|
2991
|
+
report({ type: "unsupported-element", name: xmlNode.name });
|
|
2992
|
+
}
|
|
2993
|
+
for (const child of xmlNode.children ?? []) {
|
|
2994
|
+
walk(child);
|
|
2995
|
+
}
|
|
2996
|
+
return;
|
|
2997
|
+
}
|
|
2998
|
+
if (intrinsicLeafKinds.has(node.kind)) {
|
|
2999
|
+
return;
|
|
3000
|
+
}
|
|
3001
|
+
report({ type: "unsupported-element", name: node.kind });
|
|
3002
|
+
}
|
|
3003
|
+
for (const node of item.itemBody.content ?? []) {
|
|
3004
|
+
walk(node);
|
|
3005
|
+
}
|
|
3006
|
+
for (const ref of item.assessmentStimulusRefs ?? []) {
|
|
3007
|
+
const stimulus = options.resolveStimulus?.(ref) ?? null;
|
|
3008
|
+
if (stimulus === null) {
|
|
3009
|
+
report({ type: "unsupported-element", name: "assessmentStimulusRef", detail: ref.href });
|
|
3010
|
+
continue;
|
|
3011
|
+
}
|
|
3012
|
+
for (const node of stimulus.content) {
|
|
3013
|
+
walk(node);
|
|
3014
|
+
}
|
|
3015
|
+
}
|
|
3016
|
+
for (const feedback of item.modalFeedbacks ?? []) {
|
|
3017
|
+
for (const child of feedback.content ?? []) {
|
|
3018
|
+
walk(child);
|
|
3019
|
+
}
|
|
3020
|
+
}
|
|
3021
|
+
for (const issue of collectRpIssues(item.responseProcessing, {
|
|
3022
|
+
customOperatorClasses,
|
|
3023
|
+
outcomeDeclarations: item.outcomeDeclarations ?? []
|
|
3024
|
+
})) {
|
|
3025
|
+
report(issue);
|
|
3026
|
+
}
|
|
3027
|
+
for (const issue of collectTemplateIssues(item.templateProcessing, { customOperatorClasses })) {
|
|
3028
|
+
report(issue);
|
|
3029
|
+
}
|
|
3030
|
+
return { deliverable: issues.length === 0, issues };
|
|
3031
|
+
}
|
|
3032
|
+
var referenceInteractionKinds = [
|
|
3033
|
+
"associateInteraction",
|
|
3034
|
+
"choiceInteraction",
|
|
3035
|
+
"drawingInteraction",
|
|
3036
|
+
"endAttemptInteraction",
|
|
3037
|
+
"extendedTextInteraction",
|
|
3038
|
+
"gapMatchInteraction",
|
|
3039
|
+
"graphicAssociateInteraction",
|
|
3040
|
+
"graphicGapMatchInteraction",
|
|
3041
|
+
"graphicOrderInteraction",
|
|
3042
|
+
"hotspotInteraction",
|
|
3043
|
+
"hottextInteraction",
|
|
3044
|
+
"inlineChoiceInteraction",
|
|
3045
|
+
"matchInteraction",
|
|
3046
|
+
"mediaInteraction",
|
|
3047
|
+
"orderInteraction",
|
|
3048
|
+
"positionObjectStage",
|
|
3049
|
+
"selectPointInteraction",
|
|
3050
|
+
"sliderInteraction",
|
|
3051
|
+
"textEntryInteraction",
|
|
3052
|
+
"uploadInteraction"
|
|
3053
|
+
];
|
|
2933
3054
|
// src/response-validity.ts
|
|
2934
3055
|
var countConstraintKinds = ["minChoices", "maxChoices", "minAssociations", "maxAssociations", "minStrings"];
|
|
2935
3056
|
function collectInteractionConstraints(content) {
|
|
@@ -4669,22 +4790,21 @@ function responseIncludes(value, optionIdentifier) {
|
|
|
4669
4790
|
function isCorrectOption(declaration, optionIdentifier) {
|
|
4670
4791
|
return Boolean(declaration?.correctResponse?.values.some((entry) => entry.value === optionIdentifier));
|
|
4671
4792
|
}
|
|
4672
|
-
function
|
|
4793
|
+
function isInteractionNode2(node) {
|
|
4673
4794
|
return node.kind !== "xml" && typeof node.responseIdentifier === "string";
|
|
4674
4795
|
}
|
|
4675
|
-
var
|
|
4676
|
-
function
|
|
4677
|
-
return
|
|
4796
|
+
var feedbackKinds2 = new Set(["feedbackInline", "feedbackBlock"]);
|
|
4797
|
+
function isFeedbackNode2(node) {
|
|
4798
|
+
return feedbackKinds2.has(node.kind);
|
|
4678
4799
|
}
|
|
4679
|
-
var
|
|
4680
|
-
function
|
|
4681
|
-
return
|
|
4800
|
+
var templateContentKinds2 = new Set(["templateInline", "templateBlock"]);
|
|
4801
|
+
function isTemplateContentNode2(node) {
|
|
4802
|
+
return templateContentKinds2.has(node.kind);
|
|
4682
4803
|
}
|
|
4683
4804
|
function templateVisible(value, view) {
|
|
4684
4805
|
const matched = Array.isArray(value) ? value.includes(view.identifier) : value === view.identifier;
|
|
4685
4806
|
return matched !== (view.showHide === "hide");
|
|
4686
4807
|
}
|
|
4687
|
-
var intrinsicLeafKinds = new Set(["text", "printedVariable"]);
|
|
4688
4808
|
function createStaticStore(outcomes) {
|
|
4689
4809
|
const snapshot = {
|
|
4690
4810
|
responses: {},
|
|
@@ -4782,13 +4902,13 @@ function createQtiRuntime(config) {
|
|
|
4782
4902
|
if (override) {
|
|
4783
4903
|
return createElement(Fragment, { key }, override(node, key));
|
|
4784
4904
|
}
|
|
4785
|
-
if (
|
|
4905
|
+
if (isInteractionNode2(node)) {
|
|
4786
4906
|
if (descriptorsByKind.has(node.kind) && config.skin[node.kind]) {
|
|
4787
4907
|
return createElement(InteractionHost, { key, node });
|
|
4788
4908
|
}
|
|
4789
4909
|
return renderUnsupported(node, key);
|
|
4790
4910
|
}
|
|
4791
|
-
if (
|
|
4911
|
+
if (isFeedbackNode2(node)) {
|
|
4792
4912
|
return createElement(FeedbackHost, {
|
|
4793
4913
|
key,
|
|
4794
4914
|
feedback: node,
|
|
@@ -4796,7 +4916,7 @@ function createQtiRuntime(config) {
|
|
|
4796
4916
|
overrides
|
|
4797
4917
|
});
|
|
4798
4918
|
}
|
|
4799
|
-
if (
|
|
4919
|
+
if (isTemplateContentNode2(node)) {
|
|
4800
4920
|
return createElement(TemplateContentHost, {
|
|
4801
4921
|
key,
|
|
4802
4922
|
view: node,
|
|
@@ -5039,91 +5159,15 @@ function createQtiRuntime(config) {
|
|
|
5039
5159
|
return catalogIdref !== undefined ? catalogSupports.get(catalogIdref) ?? noSupports : noSupports;
|
|
5040
5160
|
}
|
|
5041
5161
|
function canDeliver(item) {
|
|
5042
|
-
const
|
|
5043
|
-
const
|
|
5044
|
-
|
|
5045
|
-
|
|
5046
|
-
|
|
5047
|
-
|
|
5048
|
-
|
|
5049
|
-
}
|
|
5050
|
-
}
|
|
5051
|
-
function walk(node) {
|
|
5052
|
-
if (isFeedbackNode(node) || isTemplateContentNode(node) || node.kind === "rubricBlock") {
|
|
5053
|
-
for (const child of node.content ?? []) {
|
|
5054
|
-
walk(child);
|
|
5055
|
-
}
|
|
5056
|
-
return;
|
|
5057
|
-
}
|
|
5058
|
-
if (isInteractionNode(node)) {
|
|
5059
|
-
const descriptor = descriptorsByKind.get(node.kind);
|
|
5060
|
-
if (!descriptor || !config.skin[node.kind]) {
|
|
5061
|
-
report({
|
|
5062
|
-
type: "unsupported-interaction",
|
|
5063
|
-
name: node.kind,
|
|
5064
|
-
responseIdentifier: node.responseIdentifier
|
|
5065
|
-
});
|
|
5066
|
-
return;
|
|
5067
|
-
}
|
|
5068
|
-
const parsed = descriptor.schema.safeParse(node);
|
|
5069
|
-
if (!parsed.success) {
|
|
5070
|
-
const detail = parsed.error.issues[0]?.message;
|
|
5071
|
-
report({
|
|
5072
|
-
type: "invalid-interaction",
|
|
5073
|
-
name: node.kind,
|
|
5074
|
-
responseIdentifier: node.responseIdentifier,
|
|
5075
|
-
...detail !== undefined ? { detail } : {}
|
|
5076
|
-
});
|
|
5077
|
-
}
|
|
5078
|
-
return;
|
|
5079
|
-
}
|
|
5080
|
-
if (node.kind === "xml") {
|
|
5081
|
-
const xmlNode = node;
|
|
5082
|
-
if (xmlNode.name === model.mathRoot) {
|
|
5083
|
-
return;
|
|
5084
|
-
}
|
|
5085
|
-
if (!isAllowedFlowElement(model, xmlNode.name)) {
|
|
5086
|
-
report({ type: "unsupported-element", name: xmlNode.name });
|
|
5087
|
-
}
|
|
5088
|
-
for (const child of xmlNode.children ?? []) {
|
|
5089
|
-
walk(child);
|
|
5090
|
-
}
|
|
5091
|
-
return;
|
|
5092
|
-
}
|
|
5093
|
-
if (intrinsicLeafKinds.has(node.kind)) {
|
|
5094
|
-
return;
|
|
5095
|
-
}
|
|
5096
|
-
report({ type: "unsupported-element", name: node.kind });
|
|
5097
|
-
}
|
|
5098
|
-
for (const node of item.itemBody.content ?? []) {
|
|
5099
|
-
walk(node);
|
|
5100
|
-
}
|
|
5101
|
-
for (const ref of item.assessmentStimulusRefs ?? []) {
|
|
5102
|
-
const stimulus = config.resolveStimulus?.(ref) ?? null;
|
|
5103
|
-
if (stimulus === null) {
|
|
5104
|
-
report({ type: "unsupported-element", name: "assessmentStimulusRef", detail: ref.href });
|
|
5105
|
-
continue;
|
|
5106
|
-
}
|
|
5107
|
-
for (const node of stimulus.content) {
|
|
5108
|
-
walk(node);
|
|
5109
|
-
}
|
|
5110
|
-
}
|
|
5111
|
-
for (const feedback of item.modalFeedbacks ?? []) {
|
|
5112
|
-
for (const child of feedback.content ?? []) {
|
|
5113
|
-
walk(child);
|
|
5114
|
-
}
|
|
5115
|
-
}
|
|
5116
|
-
const customOperatorClasses = new Set(Object.keys(config.customOperators ?? {}));
|
|
5117
|
-
for (const issue of collectRpIssues(item.responseProcessing, {
|
|
5118
|
-
customOperatorClasses,
|
|
5119
|
-
outcomeDeclarations: item.outcomeDeclarations ?? []
|
|
5120
|
-
})) {
|
|
5121
|
-
report(issue);
|
|
5122
|
-
}
|
|
5123
|
-
for (const issue of collectTemplateIssues(item.templateProcessing, { customOperatorClasses })) {
|
|
5124
|
-
report(issue);
|
|
5125
|
-
}
|
|
5126
|
-
return { deliverable: issues.length === 0, issues };
|
|
5162
|
+
const supportedInteractions = new Set([...descriptorsByKind.keys()].filter((kind) => Boolean(config.skin[kind])));
|
|
5163
|
+
const interactionSchemas = new Map([...descriptorsByKind].map(([kind, descriptor]) => [kind, descriptor.schema]));
|
|
5164
|
+
return reportItemCapability(item, {
|
|
5165
|
+
supportedInteractions,
|
|
5166
|
+
interactionSchemas,
|
|
5167
|
+
model,
|
|
5168
|
+
customOperatorClasses: new Set(Object.keys(config.customOperators ?? {})),
|
|
5169
|
+
...config.resolveStimulus !== undefined ? { resolveStimulus: config.resolveStimulus } : {}
|
|
5170
|
+
});
|
|
5127
5171
|
}
|
|
5128
5172
|
return { ItemRenderer, ContentRenderer, useAttempt, useCatalogSupports, canDeliver };
|
|
5129
5173
|
}
|
|
@@ -6758,7 +6802,9 @@ export {
|
|
|
6758
6802
|
resolveTemplate,
|
|
6759
6803
|
resolvePnpActivation,
|
|
6760
6804
|
resolveCatalogSupports,
|
|
6805
|
+
reportItemCapability,
|
|
6761
6806
|
referenceSkin,
|
|
6807
|
+
referenceInteractionKinds,
|
|
6762
6808
|
qtiReactPackageName,
|
|
6763
6809
|
qtiCoreInteractions,
|
|
6764
6810
|
positionObjectStage,
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless capability gate (ADR-0003): "can a runtime that supports `supportedInteractions`
|
|
3
|
+
* deliver this item, and if not, why" — extracted from the React runtime's `canDeliver`
|
|
4
|
+
* so server-side callers (e.g. an ingest pipeline) can reach the *same* decision without
|
|
5
|
+
* importing React. The React runtime delegates to this, passing the interaction set its
|
|
6
|
+
* descriptors + skins cover; a headless caller passes the set its delivery supports.
|
|
7
|
+
*
|
|
8
|
+
* This module is React-free by construction (content-model + RP collectors only; the view
|
|
9
|
+
* shapes are type-only imports), so it ships through the `@conform-ed/qti-react/headless`
|
|
10
|
+
* entry alongside the normalize → view adapters.
|
|
11
|
+
*/
|
|
12
|
+
import type { ZodType } from "zod";
|
|
13
|
+
import type { CapabilityReport } from "./capability";
|
|
14
|
+
import { type ContentModel } from "./content-model";
|
|
15
|
+
import type { AssessmentItemView, AssessmentStimulusRefView, StimulusContentView } from "./runtime";
|
|
16
|
+
export interface ItemCapabilityOptions {
|
|
17
|
+
/** Interaction kinds the target runtime can render (descriptor + skin both present). */
|
|
18
|
+
readonly supportedInteractions: ReadonlySet<string>;
|
|
19
|
+
/** Content model deciding the flow-element allowlist + math root; defaults to v0. */
|
|
20
|
+
readonly model?: ContentModel;
|
|
21
|
+
/** Custom-operator classes the target runtime registers (for RP capability). */
|
|
22
|
+
readonly customOperatorClasses?: ReadonlySet<string>;
|
|
23
|
+
/** Resolver for shared-stimulus refs; unresolved refs are not deliverable. */
|
|
24
|
+
readonly resolveStimulus?: (ref: AssessmentStimulusRefView) => StimulusContentView | null;
|
|
25
|
+
/**
|
|
26
|
+
* Optional per-kind schemas for the stricter `invalid-interaction` check. The React
|
|
27
|
+
* runtime supplies its descriptor schemas; a headless caller that has already validated
|
|
28
|
+
* structure (e.g. against the qti-xml contracts schema) can omit them.
|
|
29
|
+
*/
|
|
30
|
+
readonly interactionSchemas?: ReadonlyMap<string, ZodType>;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Report whether `item` can be delivered by a runtime with the given capabilities, and
|
|
34
|
+
* every reason it cannot. Pure and React-free; the React runtime's `canDeliver` is a thin
|
|
35
|
+
* wrapper over this.
|
|
36
|
+
*/
|
|
37
|
+
export declare function reportItemCapability(item: AssessmentItemView, options: ItemCapabilityOptions): CapabilityReport;
|
|
38
|
+
/**
|
|
39
|
+
* Interaction kinds the bundled reference skin renders — the default "supported set" for
|
|
40
|
+
* callers that deliver with the reference skin. Kept in parity with the reference skin by
|
|
41
|
+
* a test; pass an explicit set to `reportItemCapability` for a custom delivery surface.
|
|
42
|
+
*/
|
|
43
|
+
export declare const referenceInteractionKinds: readonly string[];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@conform-ed/qti-react",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.18",
|
|
4
4
|
"files": [
|
|
5
5
|
"src",
|
|
6
6
|
"dist"
|
|
@@ -9,12 +9,18 @@
|
|
|
9
9
|
"module": "src/index.ts",
|
|
10
10
|
"exports": {
|
|
11
11
|
".": {
|
|
12
|
+
"development": "./src/index.ts",
|
|
12
13
|
"types": "./dist/index.d.ts",
|
|
13
14
|
"import": "./dist/index.js"
|
|
15
|
+
},
|
|
16
|
+
"./headless": {
|
|
17
|
+
"development": "./src/headless.ts",
|
|
18
|
+
"types": "./dist/headless.d.ts",
|
|
19
|
+
"import": "./dist/headless.js"
|
|
14
20
|
}
|
|
15
21
|
},
|
|
16
22
|
"scripts": {
|
|
17
|
-
"build": "bun build ./src/index.ts --outdir dist --format esm --target browser --external react --external react-dom --external zod && tsgo -p tsconfig.build.json",
|
|
23
|
+
"build": "bun build ./src/index.ts ./src/headless.ts --outdir dist --format esm --target browser --external react --external react-dom --external zod && tsgo -p tsconfig.build.json",
|
|
18
24
|
"format": "oxfmt --config ../../.oxfmtrc.jsonc --check .",
|
|
19
25
|
"lint": "oxlint --config ../../.oxlintrc.jsonc .",
|
|
20
26
|
"test": "bun test",
|
|
@@ -24,8 +30,8 @@
|
|
|
24
30
|
"xspattern": "^3.1.0"
|
|
25
31
|
},
|
|
26
32
|
"devDependencies": {
|
|
27
|
-
"@conform-ed/contracts": "0.0.
|
|
28
|
-
"@conform-ed/qti-xml": "0.0.
|
|
33
|
+
"@conform-ed/contracts": "0.0.18",
|
|
34
|
+
"@conform-ed/qti-xml": "0.0.18",
|
|
29
35
|
"@types/react": "^19.2.17",
|
|
30
36
|
"@types/react-dom": "^19",
|
|
31
37
|
"happy-dom": "^20.10.2",
|
package/src/headless.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless (React-free) surface of @conform-ed/qti-react: the normalize → view adapters
|
|
3
|
+
* and the capability gate, importable on a server (e.g. a QTI ingest pipeline) without
|
|
4
|
+
* pulling React. Exposed at `@conform-ed/qti-react/headless`; everything here is also
|
|
5
|
+
* re-exported from the package root for React consumers.
|
|
6
|
+
*
|
|
7
|
+
* Keep this entry free of React-coupled imports — only ./normalized-item and
|
|
8
|
+
* ./item-capability (value) plus type-only re-exports.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export {
|
|
12
|
+
assessmentItemViewFromNormalized,
|
|
13
|
+
assessmentTestViewFromNormalized,
|
|
14
|
+
stimulusContentFromNormalized,
|
|
15
|
+
} from "./normalized-item";
|
|
16
|
+
export { referenceInteractionKinds, reportItemCapability, type ItemCapabilityOptions } from "./item-capability";
|
|
17
|
+
export type { CapabilityIssue, CapabilityIssueType, CapabilityReport } from "./capability";
|
|
18
|
+
export type {
|
|
19
|
+
AssessmentItemView,
|
|
20
|
+
AssessmentStimulusRefView,
|
|
21
|
+
BodyNode,
|
|
22
|
+
InteractionNode,
|
|
23
|
+
StimulusContentView,
|
|
24
|
+
XmlContentNode,
|
|
25
|
+
} from "./runtime";
|
|
26
|
+
export type { AssessmentTestView } from "./test";
|
package/src/index.ts
CHANGED
|
@@ -20,6 +20,8 @@ export {
|
|
|
20
20
|
stimulusContentFromNormalized,
|
|
21
21
|
} from "./normalized-item";
|
|
22
22
|
|
|
23
|
+
export { referenceInteractionKinds, reportItemCapability, type ItemCapabilityOptions } from "./item-capability";
|
|
24
|
+
|
|
23
25
|
export { formatPoint, parseCoords, parsePoint, pointInShape, type Point, type QtiShape } from "./graphic";
|
|
24
26
|
|
|
25
27
|
export {
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless capability gate (ADR-0003): "can a runtime that supports `supportedInteractions`
|
|
3
|
+
* deliver this item, and if not, why" — extracted from the React runtime's `canDeliver`
|
|
4
|
+
* so server-side callers (e.g. an ingest pipeline) can reach the *same* decision without
|
|
5
|
+
* importing React. The React runtime delegates to this, passing the interaction set its
|
|
6
|
+
* descriptors + skins cover; a headless caller passes the set its delivery supports.
|
|
7
|
+
*
|
|
8
|
+
* This module is React-free by construction (content-model + RP collectors only; the view
|
|
9
|
+
* shapes are type-only imports), so it ships through the `@conform-ed/qti-react/headless`
|
|
10
|
+
* entry alongside the normalize → view adapters.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { ZodType } from "zod";
|
|
14
|
+
|
|
15
|
+
import type { CapabilityIssue, CapabilityReport } from "./capability";
|
|
16
|
+
import { isAllowedFlowElement, v0ContentModel, type ContentModel } from "./content-model";
|
|
17
|
+
import { collectRpIssues, collectTemplateIssues } from "./rp";
|
|
18
|
+
import type {
|
|
19
|
+
AssessmentItemView,
|
|
20
|
+
AssessmentStimulusRefView,
|
|
21
|
+
BodyNode,
|
|
22
|
+
InteractionNode,
|
|
23
|
+
StimulusContentView,
|
|
24
|
+
XmlContentNode,
|
|
25
|
+
} from "./runtime";
|
|
26
|
+
|
|
27
|
+
const feedbackKinds = new Set(["feedbackInline", "feedbackBlock"]);
|
|
28
|
+
const templateContentKinds = new Set(["templateInline", "templateBlock"]);
|
|
29
|
+
|
|
30
|
+
/** Body node kinds that render without a descriptor, skin, or content-model entry. */
|
|
31
|
+
const intrinsicLeafKinds = new Set(["text", "printedVariable"]);
|
|
32
|
+
|
|
33
|
+
function isFeedbackNode(node: BodyNode): boolean {
|
|
34
|
+
return feedbackKinds.has(node.kind);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isTemplateContentNode(node: BodyNode): boolean {
|
|
38
|
+
return templateContentKinds.has(node.kind);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* An interaction node is any non-xml node carrying a `responseIdentifier` — including
|
|
43
|
+
* kinds this runtime has never heard of. The discriminator must not depend on the
|
|
44
|
+
* supported set, or unknown interactions would be indistinguishable from text.
|
|
45
|
+
*/
|
|
46
|
+
function isInteractionNode(node: BodyNode): node is InteractionNode {
|
|
47
|
+
return node.kind !== "xml" && typeof (node as { responseIdentifier?: unknown }).responseIdentifier === "string";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ItemCapabilityOptions {
|
|
51
|
+
/** Interaction kinds the target runtime can render (descriptor + skin both present). */
|
|
52
|
+
readonly supportedInteractions: ReadonlySet<string>;
|
|
53
|
+
/** Content model deciding the flow-element allowlist + math root; defaults to v0. */
|
|
54
|
+
readonly model?: ContentModel;
|
|
55
|
+
/** Custom-operator classes the target runtime registers (for RP capability). */
|
|
56
|
+
readonly customOperatorClasses?: ReadonlySet<string>;
|
|
57
|
+
/** Resolver for shared-stimulus refs; unresolved refs are not deliverable. */
|
|
58
|
+
readonly resolveStimulus?: (ref: AssessmentStimulusRefView) => StimulusContentView | null;
|
|
59
|
+
/**
|
|
60
|
+
* Optional per-kind schemas for the stricter `invalid-interaction` check. The React
|
|
61
|
+
* runtime supplies its descriptor schemas; a headless caller that has already validated
|
|
62
|
+
* structure (e.g. against the qti-xml contracts schema) can omit them.
|
|
63
|
+
*/
|
|
64
|
+
readonly interactionSchemas?: ReadonlyMap<string, ZodType>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Report whether `item` can be delivered by a runtime with the given capabilities, and
|
|
69
|
+
* every reason it cannot. Pure and React-free; the React runtime's `canDeliver` is a thin
|
|
70
|
+
* wrapper over this.
|
|
71
|
+
*/
|
|
72
|
+
export function reportItemCapability(item: AssessmentItemView, options: ItemCapabilityOptions): CapabilityReport {
|
|
73
|
+
const model = options.model ?? v0ContentModel;
|
|
74
|
+
const customOperatorClasses = options.customOperatorClasses ?? new Set<string>();
|
|
75
|
+
const issues: CapabilityIssue[] = [];
|
|
76
|
+
const seen = new Set<string>();
|
|
77
|
+
|
|
78
|
+
function report(issue: CapabilityIssue): void {
|
|
79
|
+
const dedupeKey = `${issue.type}:${issue.name}:${issue.responseIdentifier ?? ""}`;
|
|
80
|
+
|
|
81
|
+
if (!seen.has(dedupeKey)) {
|
|
82
|
+
seen.add(dedupeKey);
|
|
83
|
+
issues.push(issue);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function walk(node: BodyNode): void {
|
|
88
|
+
if (isFeedbackNode(node) || isTemplateContentNode(node) || node.kind === "rubricBlock") {
|
|
89
|
+
for (const child of (node as unknown as { content?: readonly BodyNode[] }).content ?? []) {
|
|
90
|
+
walk(child);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (isInteractionNode(node)) {
|
|
97
|
+
if (!options.supportedInteractions.has(node.kind)) {
|
|
98
|
+
report({ type: "unsupported-interaction", name: node.kind, responseIdentifier: node.responseIdentifier });
|
|
99
|
+
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const schema = options.interactionSchemas?.get(node.kind);
|
|
104
|
+
|
|
105
|
+
if (schema) {
|
|
106
|
+
const parsed = schema.safeParse(node);
|
|
107
|
+
|
|
108
|
+
if (!parsed.success) {
|
|
109
|
+
const detail = parsed.error.issues[0]?.message;
|
|
110
|
+
|
|
111
|
+
report({
|
|
112
|
+
type: "invalid-interaction",
|
|
113
|
+
name: node.kind,
|
|
114
|
+
responseIdentifier: node.responseIdentifier,
|
|
115
|
+
...(detail !== undefined ? { detail } : {}),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (node.kind === "xml") {
|
|
124
|
+
const xmlNode = node as XmlContentNode;
|
|
125
|
+
|
|
126
|
+
if (xmlNode.name === model.mathRoot) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (!isAllowedFlowElement(model, xmlNode.name)) {
|
|
131
|
+
report({ type: "unsupported-element", name: xmlNode.name });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
for (const child of xmlNode.children ?? []) {
|
|
135
|
+
walk(child);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (intrinsicLeafKinds.has(node.kind)) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
report({ type: "unsupported-element", name: node.kind });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
for (const node of item.itemBody.content ?? []) {
|
|
149
|
+
walk(node);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
for (const ref of item.assessmentStimulusRefs ?? []) {
|
|
153
|
+
const stimulus = options.resolveStimulus?.(ref) ?? null;
|
|
154
|
+
|
|
155
|
+
if (stimulus === null) {
|
|
156
|
+
report({ type: "unsupported-element", name: "assessmentStimulusRef", detail: ref.href });
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
for (const node of stimulus.content) {
|
|
161
|
+
walk(node);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
for (const feedback of item.modalFeedbacks ?? []) {
|
|
166
|
+
for (const child of feedback.content ?? []) {
|
|
167
|
+
walk(child);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
for (const issue of collectRpIssues(item.responseProcessing, {
|
|
172
|
+
customOperatorClasses,
|
|
173
|
+
outcomeDeclarations: item.outcomeDeclarations ?? [],
|
|
174
|
+
})) {
|
|
175
|
+
report(issue);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
for (const issue of collectTemplateIssues(item.templateProcessing, { customOperatorClasses })) {
|
|
179
|
+
report(issue);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return { deliverable: issues.length === 0, issues };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Interaction kinds the bundled reference skin renders — the default "supported set" for
|
|
187
|
+
* callers that deliver with the reference skin. Kept in parity with the reference skin by
|
|
188
|
+
* a test; pass an explicit set to `reportItemCapability` for a custom delivery surface.
|
|
189
|
+
*/
|
|
190
|
+
export const referenceInteractionKinds: readonly string[] = [
|
|
191
|
+
"associateInteraction",
|
|
192
|
+
"choiceInteraction",
|
|
193
|
+
"drawingInteraction",
|
|
194
|
+
"endAttemptInteraction",
|
|
195
|
+
"extendedTextInteraction",
|
|
196
|
+
"gapMatchInteraction",
|
|
197
|
+
"graphicAssociateInteraction",
|
|
198
|
+
"graphicGapMatchInteraction",
|
|
199
|
+
"graphicOrderInteraction",
|
|
200
|
+
"hotspotInteraction",
|
|
201
|
+
"hottextInteraction",
|
|
202
|
+
"inlineChoiceInteraction",
|
|
203
|
+
"matchInteraction",
|
|
204
|
+
"mediaInteraction",
|
|
205
|
+
"orderInteraction",
|
|
206
|
+
"positionObjectStage",
|
|
207
|
+
"selectPointInteraction",
|
|
208
|
+
"sliderInteraction",
|
|
209
|
+
"textEntryInteraction",
|
|
210
|
+
"uploadInteraction",
|
|
211
|
+
];
|