@conform-ed/qti-react 0.0.12 → 0.0.14
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/capability.d.ts +17 -0
- package/dist/content-model.d.ts +42 -0
- package/dist/graphic.d.ts +23 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +4556 -212
- package/dist/interactions/associate.d.ts +2 -0
- package/dist/interactions/choice.d.ts +2 -0
- package/dist/interactions/drawing.d.ts +2 -0
- package/dist/interactions/end-attempt.d.ts +2 -0
- package/dist/interactions/extended-text.d.ts +2 -0
- package/dist/interactions/gap-match.d.ts +2 -0
- package/dist/interactions/graphic.d.ts +13 -0
- package/dist/interactions/hottext.d.ts +2 -0
- package/dist/interactions/index.d.ts +18 -0
- package/dist/interactions/inline-choice.d.ts +2 -0
- package/dist/interactions/match.d.ts +2 -0
- package/dist/interactions/media.d.ts +2 -0
- package/dist/interactions/order.d.ts +2 -0
- package/dist/interactions/slider.d.ts +2 -0
- package/dist/interactions/text-entry.d.ts +2 -0
- package/dist/interactions/upload.d.ts +2 -0
- package/dist/normalized-item.d.ts +30 -0
- package/dist/pci/index.d.ts +6 -0
- package/dist/pci/interaction.d.ts +8 -0
- package/dist/pci/markup.d.ts +10 -0
- package/dist/pci/mount.d.ts +50 -0
- package/dist/pci/registry.d.ts +53 -0
- package/dist/pci/response.d.ts +11 -0
- package/dist/pci/skin.d.ts +12 -0
- package/dist/reference-skin/associate.d.ts +8 -0
- package/dist/reference-skin/choice.d.ts +8 -0
- package/dist/reference-skin/content.d.ts +6 -0
- package/dist/reference-skin/drawing.d.ts +9 -0
- package/dist/reference-skin/end-attempt.d.ts +7 -0
- package/dist/reference-skin/extended-text.d.ts +6 -0
- package/dist/reference-skin/gap-match.d.ts +8 -0
- package/dist/reference-skin/graphic-associate.d.ts +8 -0
- package/dist/reference-skin/graphic-base.d.ts +39 -0
- package/dist/reference-skin/graphic-gap-match.d.ts +8 -0
- package/dist/reference-skin/graphic-order.d.ts +8 -0
- package/dist/reference-skin/hotspot.d.ts +8 -0
- package/dist/reference-skin/hottext.d.ts +8 -0
- package/dist/reference-skin/index.d.ts +30 -0
- package/dist/reference-skin/inline-choice.d.ts +7 -0
- package/dist/reference-skin/match.d.ts +8 -0
- package/dist/reference-skin/media.d.ts +9 -0
- package/dist/reference-skin/order.d.ts +8 -0
- package/dist/reference-skin/position-object.d.ts +9 -0
- package/dist/reference-skin/select-point.d.ts +8 -0
- package/dist/reference-skin/slider.d.ts +8 -0
- package/dist/reference-skin/text-entry.d.ts +6 -0
- package/dist/reference-skin/upload.d.ts +8 -0
- package/dist/response-processing.d.ts +48 -0
- package/dist/rp/evaluate.d.ts +35 -0
- package/dist/rp/index.d.ts +4 -0
- package/dist/rp/interpreter.d.ts +15 -0
- package/dist/rp/template-processing.d.ts +49 -0
- package/dist/rp/templates.d.ts +8 -0
- package/dist/rp/types.d.ts +158 -0
- package/dist/rp/values.d.ts +27 -0
- package/dist/runtime.d.ts +164 -0
- package/dist/store.d.ts +61 -0
- package/dist/test/controller.d.ts +11 -0
- package/dist/test/index.d.ts +3 -0
- package/dist/test/session-store.d.ts +46 -0
- package/dist/test/types.d.ts +194 -0
- package/dist/types.d.ts +58 -0
- package/package.json +8 -6
- package/src/capability.ts +24 -0
- package/src/content-model.ts +104 -5
- package/src/graphic.ts +103 -0
- package/src/index.ts +139 -3
- package/src/interactions/associate.ts +22 -0
- package/src/interactions/choice.ts +2 -2
- package/src/interactions/drawing.ts +24 -0
- package/src/interactions/end-attempt.ts +19 -0
- package/src/interactions/extended-text.ts +21 -0
- package/src/interactions/gap-match.ts +22 -0
- package/src/interactions/graphic.ts +104 -0
- package/src/interactions/hottext.ts +21 -0
- package/src/interactions/index.ts +57 -3
- package/src/interactions/inline-choice.ts +2 -2
- package/src/interactions/match.ts +27 -0
- package/src/interactions/media.ts +24 -0
- package/src/interactions/order.ts +21 -0
- package/src/interactions/slider.ts +24 -0
- package/src/interactions/text-entry.ts +2 -2
- package/src/interactions/upload.ts +19 -0
- package/src/normalized-item.ts +563 -0
- package/src/pci/index.ts +22 -0
- package/src/pci/interaction.ts +42 -0
- package/src/pci/markup.ts +102 -0
- package/src/pci/mount.ts +134 -0
- package/src/pci/registry.ts +240 -0
- package/src/pci/response.ts +138 -0
- package/src/pci/skin.ts +86 -0
- package/src/reference-skin/associate.ts +98 -0
- package/src/reference-skin/choice.ts +44 -0
- package/src/reference-skin/content.ts +30 -0
- package/src/reference-skin/drawing.ts +160 -0
- package/src/reference-skin/end-attempt.ts +27 -0
- package/src/reference-skin/extended-text.ts +35 -0
- package/src/reference-skin/gap-match.ts +69 -0
- package/src/reference-skin/graphic-associate.ts +123 -0
- package/src/reference-skin/graphic-base.ts +142 -0
- package/src/reference-skin/graphic-gap-match.ts +143 -0
- package/src/reference-skin/graphic-order.ts +76 -0
- package/src/reference-skin/hotspot.ts +43 -0
- package/src/reference-skin/hottext.ts +42 -0
- package/src/reference-skin/index.ts +74 -0
- package/src/reference-skin/inline-choice.ts +42 -0
- package/src/reference-skin/match.ts +80 -0
- package/src/reference-skin/media.ts +74 -0
- package/src/reference-skin/order.ts +79 -0
- package/src/reference-skin/position-object.ts +84 -0
- package/src/reference-skin/select-point.ts +87 -0
- package/src/reference-skin/slider.ts +41 -0
- package/src/reference-skin/text-entry.ts +31 -0
- package/src/reference-skin/upload.ts +46 -0
- package/src/response-processing.ts +178 -29
- package/src/rp/evaluate.ts +827 -0
- package/src/rp/index.ts +30 -0
- package/src/rp/interpreter.ts +254 -0
- package/src/rp/template-processing.ts +290 -0
- package/src/rp/templates.ts +190 -0
- package/src/rp/types.ts +167 -0
- package/src/rp/values.ts +211 -0
- package/src/runtime.ts +476 -28
- package/src/store.ts +161 -5
- package/src/test/controller.ts +809 -0
- package/src/test/index.ts +25 -0
- package/src/test/session-store.ts +243 -0
- package/src/test/types.ts +203 -0
- package/src/types.ts +27 -1
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PCI markup serialization: `qti-interaction-markup` content back to an HTML string
|
|
3
|
+
* for injection into the host container. PCI markup is module-owned — the module
|
|
4
|
+
* queries and mutates it directly — so unlike flow content it is not constrained to
|
|
5
|
+
* the content-model element allowlist. The serializer still strips what must never
|
|
6
|
+
* reach the DOM statically: event-handler attributes, script-scheme URLs, and
|
|
7
|
+
* `<script>` elements (modules bring behaviour through the registry, not markup).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { BodyNode, XmlContentNode } from "../runtime";
|
|
11
|
+
|
|
12
|
+
const voidElements = new Set([
|
|
13
|
+
"area",
|
|
14
|
+
"base",
|
|
15
|
+
"br",
|
|
16
|
+
"col",
|
|
17
|
+
"embed",
|
|
18
|
+
"hr",
|
|
19
|
+
"img",
|
|
20
|
+
"input",
|
|
21
|
+
"link",
|
|
22
|
+
"meta",
|
|
23
|
+
"source",
|
|
24
|
+
"track",
|
|
25
|
+
"wbr",
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
const blockedElements = new Set(["script", "iframe", "object", "embed"]);
|
|
29
|
+
|
|
30
|
+
const urlAttributes = new Set(["src", "href", "xlink:href", "poster", "data"]);
|
|
31
|
+
|
|
32
|
+
function escapeText(value: string): string {
|
|
33
|
+
return value.replace(/&/gu, "&").replace(/</gu, "<").replace(/>/gu, ">");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function escapeAttribute(value: string): string {
|
|
37
|
+
return escapeText(value).replace(/"/gu, """);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isUnsafeMarkupAttribute(name: string, value: string): boolean {
|
|
41
|
+
if (/^on/iu.test(name)) {
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return urlAttributes.has(name.toLowerCase()) && /^\s*(?:javascript|vbscript|data:text\/html)/iu.test(value);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function serializeAttributes(attributes: Record<string, unknown> | undefined): string {
|
|
49
|
+
if (!attributes) {
|
|
50
|
+
return "";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let result = "";
|
|
54
|
+
|
|
55
|
+
for (const [name, raw] of Object.entries(attributes)) {
|
|
56
|
+
// Attribute values are strings after normalization; anything else is dropped.
|
|
57
|
+
const value =
|
|
58
|
+
typeof raw === "string" ? raw : typeof raw === "number" || typeof raw === "boolean" ? String(raw) : undefined;
|
|
59
|
+
|
|
60
|
+
if (value !== undefined && !isUnsafeMarkupAttribute(name, value)) {
|
|
61
|
+
result += ` ${name}="${escapeAttribute(value)}"`;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function serializeNode(node: BodyNode | string): string {
|
|
69
|
+
if (typeof node === "string") {
|
|
70
|
+
return escapeText(node);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (node.kind === "text") {
|
|
74
|
+
return escapeText((node as { value?: string }).value ?? "");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (node.kind !== "xml") {
|
|
78
|
+
return ""; // interactions and QTI constructs cannot nest inside PCI markup
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const xmlNode = node as XmlContentNode;
|
|
82
|
+
const name = xmlNode.name.toLowerCase();
|
|
83
|
+
|
|
84
|
+
if (blockedElements.has(name)) {
|
|
85
|
+
return "";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const attributes = serializeAttributes(xmlNode.attributes);
|
|
89
|
+
|
|
90
|
+
if (voidElements.has(name)) {
|
|
91
|
+
return `<${name}${attributes}>`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const children = (xmlNode.children ?? []).map(serializeNode).join("");
|
|
95
|
+
const text = xmlNode.value !== undefined ? escapeText(xmlNode.value) : "";
|
|
96
|
+
|
|
97
|
+
return `<${name}${attributes}>${text}${children}</${name}>`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function serializePciMarkup(nodes: ReadonlyArray<BodyNode | string> | undefined): string {
|
|
101
|
+
return (nodes ?? []).map(serializeNode).join("");
|
|
102
|
+
}
|
package/src/pci/mount.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The PCI mount lifecycle, framework-free (the React skin is a thin wrapper): resolve
|
|
3
|
+
* the module through the registry (declared interaction modules with primary →
|
|
4
|
+
* fallback paths, then the bare `module` name), inject the serialized markup, call
|
|
5
|
+
* `getInstance(dom, configuration, state)`, and hand back a handle the host uses to
|
|
6
|
+
* collect the response at submit time and to tear the instance down.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { BodyNode } from "../runtime";
|
|
10
|
+
import type { ResponseDeclarationView, ResponseValue } from "../types";
|
|
11
|
+
import { serializePciMarkup } from "./markup";
|
|
12
|
+
import type { PciConfiguration, PciInstance, PciModule, PciModuleRegistry } from "./registry";
|
|
13
|
+
import { pciResponseToValue, valueToPciResponse } from "./response";
|
|
14
|
+
|
|
15
|
+
/** The adapter's shape for a `portableCustomInteraction` body node. */
|
|
16
|
+
export interface PciInteractionNode {
|
|
17
|
+
readonly kind: "portableCustomInteraction";
|
|
18
|
+
readonly responseIdentifier: string;
|
|
19
|
+
readonly customInteractionTypeIdentifier: string;
|
|
20
|
+
readonly module?: string;
|
|
21
|
+
readonly class?: readonly string[];
|
|
22
|
+
readonly properties?: Readonly<Record<string, string>>;
|
|
23
|
+
readonly interactionMarkup?: { readonly content?: ReadonlyArray<BodyNode | string> };
|
|
24
|
+
readonly interactionModules?: {
|
|
25
|
+
readonly primaryConfiguration?: string;
|
|
26
|
+
readonly modules?: ReadonlyArray<{
|
|
27
|
+
readonly id: string;
|
|
28
|
+
readonly primaryPath?: string;
|
|
29
|
+
readonly fallbackPath?: string;
|
|
30
|
+
}>;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface PciMountOptions {
|
|
35
|
+
readonly container: Element;
|
|
36
|
+
readonly node: PciInteractionNode;
|
|
37
|
+
readonly registry: PciModuleRegistry;
|
|
38
|
+
/** The bound response variable's declaration and current value (for `boundTo`). */
|
|
39
|
+
readonly declaration?: ResponseDeclarationView;
|
|
40
|
+
readonly boundValue?: ResponseValue;
|
|
41
|
+
/** A state string from a previous instance's getState() (session restore). */
|
|
42
|
+
readonly state?: string;
|
|
43
|
+
/** PCI `ondone`: the instance finished on its own and reports its response. */
|
|
44
|
+
readonly ondone?: (value: ResponseValue, state?: string) => void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface PciMountHandle {
|
|
48
|
+
readonly instance: PciInstance;
|
|
49
|
+
/** The instance's current response as a runtime ResponseValue. */
|
|
50
|
+
readonly collectResponse: () => ResponseValue | undefined;
|
|
51
|
+
readonly getState: () => string | undefined;
|
|
52
|
+
readonly unmount: () => void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function resolveModule(node: PciInteractionNode, registry: PciModuleRegistry): Promise<PciModule> {
|
|
56
|
+
const preRegistered =
|
|
57
|
+
registry.resolve(node.customInteractionTypeIdentifier) ??
|
|
58
|
+
(node.module !== undefined ? registry.resolve(node.module) : undefined);
|
|
59
|
+
|
|
60
|
+
if (preRegistered) {
|
|
61
|
+
return preRegistered;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const declared = node.interactionModules?.modules ?? [];
|
|
65
|
+
|
|
66
|
+
for (const entry of declared) {
|
|
67
|
+
const candidates = [entry.primaryPath, entry.fallbackPath].filter(
|
|
68
|
+
(candidate): candidate is string => candidate !== undefined,
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
await registry.load(entry.id, candidates);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (declared.length === 0 && node.module !== undefined) {
|
|
75
|
+
await registry.load(node.module, []);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const loaded =
|
|
79
|
+
registry.resolve(node.customInteractionTypeIdentifier) ??
|
|
80
|
+
(node.module !== undefined ? registry.resolve(node.module) : undefined) ??
|
|
81
|
+
(declared.length === 1 ? registry.resolve(declared[0]!.id) : undefined);
|
|
82
|
+
|
|
83
|
+
if (!loaded) {
|
|
84
|
+
throw new Error(`No PCI module registered for "${node.customInteractionTypeIdentifier}".`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return loaded;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function mountPci(options: PciMountOptions): Promise<PciMountHandle> {
|
|
91
|
+
const { container, node, registry } = options;
|
|
92
|
+
const module = await resolveModule(node, registry);
|
|
93
|
+
|
|
94
|
+
const markupHost = container.ownerDocument!.createElement("div");
|
|
95
|
+
markupHost.className = "qti-interaction-markup";
|
|
96
|
+
markupHost.innerHTML = serializePciMarkup(node.interactionMarkup?.content);
|
|
97
|
+
container.appendChild(markupHost);
|
|
98
|
+
|
|
99
|
+
let resolveReady!: (instance: PciInstance) => void;
|
|
100
|
+
const ready = new Promise<PciInstance>((resolve) => {
|
|
101
|
+
resolveReady = resolve;
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const configuration: PciConfiguration = {
|
|
105
|
+
properties: node.properties ?? {},
|
|
106
|
+
responseIdentifier: node.responseIdentifier,
|
|
107
|
+
...(options.declaration
|
|
108
|
+
? { boundTo: { [node.responseIdentifier]: valueToPciResponse(options.boundValue ?? null, options.declaration) } }
|
|
109
|
+
: {}),
|
|
110
|
+
status: "interacting",
|
|
111
|
+
onready: (instance) => resolveReady(instance),
|
|
112
|
+
ondone: (_instance, response, state) => options.ondone?.(pciResponseToValue(response), state),
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// The spec delivers the instance via onready; implementations commonly also return
|
|
116
|
+
// it from getInstance. Accept either, first one wins.
|
|
117
|
+
const returned = module.getInstance(container, configuration, options.state);
|
|
118
|
+
|
|
119
|
+
if (returned) {
|
|
120
|
+
resolveReady(returned);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const instance = await ready;
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
instance,
|
|
127
|
+
collectResponse: () => pciResponseToValue(instance.getResponse()),
|
|
128
|
+
getState: () => instance.getState?.(),
|
|
129
|
+
unmount: () => {
|
|
130
|
+
instance.oncompleted?.();
|
|
131
|
+
container.replaceChildren();
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The PCI Module Registry: PCI v1 modules are AMD (`define([deps], factory)`), so the
|
|
3
|
+
* registry provides a minimal AMD surface — source evaluation with a scoped `define`,
|
|
4
|
+
* dependency resolution among registered modules, the `qtiCustomInteractionContext`
|
|
5
|
+
* bridge (`register` keyed by typeIdentifier), and URL loading with primary → fallback
|
|
6
|
+
* candidates. PCI is a trust boundary: evaluating a module executes item-supplied
|
|
7
|
+
* code, which is why PCI support is opt-in and never part of `qtiCoreInteractions`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** The configuration handed to `getInstance` (IMS PCI v1). */
|
|
11
|
+
export interface PciConfiguration {
|
|
12
|
+
readonly properties: Readonly<Record<string, string>>;
|
|
13
|
+
readonly responseIdentifier?: string;
|
|
14
|
+
/** The bound response variable's current value in PCI JSON form. */
|
|
15
|
+
readonly boundTo?: Readonly<Record<string, unknown>>;
|
|
16
|
+
readonly status?: string;
|
|
17
|
+
readonly onready?: (instance: PciInstance, state?: string) => void;
|
|
18
|
+
readonly ondone?: (instance: PciInstance, response: unknown, state?: string, status?: string) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface PciInstance {
|
|
22
|
+
readonly typeIdentifier?: string;
|
|
23
|
+
getResponse(): unknown;
|
|
24
|
+
getState?(): string;
|
|
25
|
+
/** Engine-invoked before the instance is unloaded (cleanup hook). */
|
|
26
|
+
oncompleted?(): void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface PciModule {
|
|
30
|
+
readonly typeIdentifier?: string;
|
|
31
|
+
getInstance(dom: Element, configuration: PciConfiguration, state: string | undefined): PciInstance | undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface PciModuleRegistryOptions {
|
|
35
|
+
/** Base for relative module paths (typically the item package root URL). */
|
|
36
|
+
readonly baseUrl?: string;
|
|
37
|
+
/** Module id → path overrides (a parsed `module_resolution.js` paths map). */
|
|
38
|
+
readonly paths?: Readonly<Record<string, string>>;
|
|
39
|
+
/** Source fetcher for URL loading; defaults to global fetch. */
|
|
40
|
+
readonly fetchText?: (url: string) => Promise<string>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface PciModuleRegistry {
|
|
44
|
+
/** Evaluate AMD source text; its `define` registers under the given module id. */
|
|
45
|
+
readonly evaluate: (source: string, context: { readonly id: string }) => void;
|
|
46
|
+
/** Register a prebuilt module directly (bundled PCIs, tests). */
|
|
47
|
+
readonly registerModule: (id: string, module: PciModule) => void;
|
|
48
|
+
/** Resolve by module id or by PCI typeIdentifier; undefined when unknown. */
|
|
49
|
+
readonly resolve: (id: string) => PciModule | undefined;
|
|
50
|
+
/**
|
|
51
|
+
* Load a module from candidate paths in order (primary → fallback), falling back to
|
|
52
|
+
* the registry's `paths` map when no candidates are given.
|
|
53
|
+
*/
|
|
54
|
+
readonly load: (id: string, candidates: readonly string[]) => Promise<PciModule>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface AmdDefinition {
|
|
58
|
+
readonly dependencies: readonly string[];
|
|
59
|
+
readonly factory: (...resolved: unknown[]) => unknown;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function defaultFetchText(url: string): Promise<string> {
|
|
63
|
+
return fetch(url).then((response) => {
|
|
64
|
+
if (!response.ok) {
|
|
65
|
+
throw new Error(`HTTP ${response.status} for ${url}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return response.text();
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function createPciModuleRegistry(options: PciModuleRegistryOptions = {}): PciModuleRegistry {
|
|
73
|
+
const fetchText = options.fetchText ?? defaultFetchText;
|
|
74
|
+
const definitions = new Map<string, AmdDefinition>();
|
|
75
|
+
const resolved = new Map<string, unknown>();
|
|
76
|
+
const byTypeIdentifier = new Map<string, PciModule>();
|
|
77
|
+
|
|
78
|
+
/** Modules registered through qtiCustomInteractionContext.register during a resolve. */
|
|
79
|
+
let contextRegistrations: PciModule[] = [];
|
|
80
|
+
|
|
81
|
+
const qtiCustomInteractionContext = {
|
|
82
|
+
register(module: PciModule): void {
|
|
83
|
+
contextRegistrations.push(module);
|
|
84
|
+
|
|
85
|
+
if (module.typeIdentifier !== undefined) {
|
|
86
|
+
byTypeIdentifier.set(module.typeIdentifier, module);
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
function resolveDependency(id: string, dependedOnBy: string, resolving: Set<string>): unknown {
|
|
92
|
+
if (id === "qtiCustomInteractionContext") {
|
|
93
|
+
return qtiCustomInteractionContext;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (resolved.has(id)) {
|
|
97
|
+
return resolved.get(id);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const definition = definitions.get(id);
|
|
101
|
+
|
|
102
|
+
if (!definition) {
|
|
103
|
+
throw new Error(`PCI module "${dependedOnBy}" depends on "${id}", which is not registered.`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return instantiate(id, definition, resolving);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function instantiate(id: string, definition: AmdDefinition, resolving: Set<string>): unknown {
|
|
110
|
+
if (resolving.has(id)) {
|
|
111
|
+
throw new Error(`Circular PCI module dependency involving "${id}".`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
resolving.add(id);
|
|
115
|
+
|
|
116
|
+
const dependencies = definition.dependencies.map((dependency) => resolveDependency(dependency, id, resolving));
|
|
117
|
+
const beforeCount = contextRegistrations.length;
|
|
118
|
+
const value = definition.factory(...dependencies);
|
|
119
|
+
// A module that only ctx.register()s (no return value) still resolves to the
|
|
120
|
+
// registered module — the corpus modules do both.
|
|
121
|
+
const registered = contextRegistrations.length > beforeCount ? contextRegistrations.at(-1) : undefined;
|
|
122
|
+
const moduleValue = value ?? registered;
|
|
123
|
+
|
|
124
|
+
resolving.delete(id);
|
|
125
|
+
resolved.set(id, moduleValue);
|
|
126
|
+
|
|
127
|
+
const candidate = moduleValue as PciModule | undefined;
|
|
128
|
+
|
|
129
|
+
if (candidate?.typeIdentifier !== undefined && typeof candidate.getInstance === "function") {
|
|
130
|
+
byTypeIdentifier.set(candidate.typeIdentifier, candidate);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return moduleValue;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function evaluate(source: string, context: { readonly id: string }): void {
|
|
137
|
+
const define = (...args: unknown[]): void => {
|
|
138
|
+
// AMD forms: define(factory) | define(deps, factory) | define(id, deps, factory).
|
|
139
|
+
const id = typeof args[0] === "string" ? (args.shift() as string) : context.id;
|
|
140
|
+
const dependencies = Array.isArray(args[0]) ? (args.shift() as string[]) : [];
|
|
141
|
+
const factoryArg = args[0];
|
|
142
|
+
const factory =
|
|
143
|
+
typeof factoryArg === "function" ? (factoryArg as AmdDefinition["factory"]) : (): unknown => factoryArg;
|
|
144
|
+
|
|
145
|
+
definitions.set(id, { dependencies, factory });
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
(define as { amd?: object }).amd = {};
|
|
149
|
+
|
|
150
|
+
// Scoped evaluation: the module sees our `define`, nothing else is injected.
|
|
151
|
+
// Executing PCI source IS the feature — the documented trust boundary consumers
|
|
152
|
+
// accept by opting into PCI (this registry is never part of qtiCoreInteractions).
|
|
153
|
+
// oxlint-disable-next-line typescript/no-implied-eval
|
|
154
|
+
new Function("define", `"use strict";\n${source}`)(define);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function resolve(id: string): PciModule | undefined {
|
|
158
|
+
const fromType = byTypeIdentifier.get(id);
|
|
159
|
+
|
|
160
|
+
if (fromType) {
|
|
161
|
+
return fromType;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (!resolved.has(id)) {
|
|
165
|
+
const definition = definitions.get(id);
|
|
166
|
+
|
|
167
|
+
if (!definition) {
|
|
168
|
+
// Possibly a typeIdentifier lookup: typeIdentifiers only become known when a
|
|
169
|
+
// factory runs (ctx.register), so instantiate pending definitions best-effort
|
|
170
|
+
// and re-check. Broken pending modules fail on their own by-id resolution.
|
|
171
|
+
for (const [pendingId, pending] of definitions) {
|
|
172
|
+
if (!resolved.has(pendingId)) {
|
|
173
|
+
try {
|
|
174
|
+
instantiate(pendingId, pending, new Set());
|
|
175
|
+
} catch {
|
|
176
|
+
// surfaced when the broken module itself is resolved or loaded
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return byTypeIdentifier.get(id);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
instantiate(id, definition, new Set());
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const value = resolved.get(id) as PciModule | undefined;
|
|
188
|
+
|
|
189
|
+
return value && typeof value.getInstance === "function" ? value : undefined;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function toUrl(path: string): string {
|
|
193
|
+
const withExtension = /\.[a-z]+$/iu.test(path) ? path : `${path}.js`;
|
|
194
|
+
|
|
195
|
+
return options.baseUrl ? new URL(withExtension, options.baseUrl).toString() : withExtension;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function load(id: string, candidates: readonly string[]): Promise<PciModule> {
|
|
199
|
+
const existing = resolve(id);
|
|
200
|
+
|
|
201
|
+
if (existing) {
|
|
202
|
+
return existing;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const pathsEntry = options.paths?.[id];
|
|
206
|
+
const allCandidates = candidates.length > 0 ? candidates : pathsEntry !== undefined ? [pathsEntry] : [];
|
|
207
|
+
const failures: string[] = [];
|
|
208
|
+
|
|
209
|
+
for (const candidate of allCandidates) {
|
|
210
|
+
try {
|
|
211
|
+
evaluate(await fetchText(toUrl(candidate)), { id });
|
|
212
|
+
|
|
213
|
+
const module = resolve(id);
|
|
214
|
+
|
|
215
|
+
if (module) {
|
|
216
|
+
return module;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
failures.push(`${candidate}: evaluated but did not register a PCI module`);
|
|
220
|
+
} catch (error) {
|
|
221
|
+
failures.push(`${candidate}: ${error instanceof Error ? error.message : String(error)}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
throw new Error(`PCI module "${id}" could not be loaded. ${failures.join("; ") || "No candidate paths."}`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
evaluate,
|
|
230
|
+
registerModule: (id, module) => {
|
|
231
|
+
resolved.set(id, module);
|
|
232
|
+
|
|
233
|
+
if (module.typeIdentifier !== undefined) {
|
|
234
|
+
byTypeIdentifier.set(module.typeIdentifier, module);
|
|
235
|
+
}
|
|
236
|
+
},
|
|
237
|
+
resolve,
|
|
238
|
+
load,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PCI JSON ↔ ResponseValue conversion (IMS PCI v1 "JSON representation of variable
|
|
3
|
+
* values"). PCI instances exchange `{ base: { integer: 3 } }` / `{ list: ... }` /
|
|
4
|
+
* `{ record: [{ name, base }] }` shapes; the attempt store speaks string / string[] /
|
|
5
|
+
* record-object / null. Record fields keep their runtime type so `fieldValue` in
|
|
6
|
+
* response processing sees properly typed members.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { isResponseRecord } from "../types";
|
|
10
|
+
import type { ResponseDeclarationView, ResponseFieldValue, ResponseValue } from "../types";
|
|
11
|
+
|
|
12
|
+
function scalarToString(value: unknown): string {
|
|
13
|
+
if (Array.isArray(value)) {
|
|
14
|
+
// point / pair / directedPair entries: tuples join with a space, matching the
|
|
15
|
+
// runtime's response conventions ("x y", "from to").
|
|
16
|
+
return value.map(String).join(" ");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return String(value);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** First (and per spec only) entry of a base/list payload: `{ integer: 3 }` → 3. */
|
|
23
|
+
function payloadEntry(payload: unknown): unknown {
|
|
24
|
+
if (typeof payload !== "object" || payload === null) {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const values = Object.values(payload);
|
|
29
|
+
|
|
30
|
+
return values.length > 0 ? values[0] : undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** One `{ name, base }` record entry → a typed field, or null for malformed entries. */
|
|
34
|
+
function recordField(entry: unknown): [string, ResponseFieldValue] | null {
|
|
35
|
+
if (typeof entry !== "object" || entry === null) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const { name, base } = entry as { name?: unknown; base?: unknown };
|
|
40
|
+
|
|
41
|
+
if (typeof name !== "string") {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const raw = payloadEntry(base);
|
|
46
|
+
|
|
47
|
+
if (raw === undefined || raw === null) {
|
|
48
|
+
return [name, null];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return [name, typeof raw === "boolean" || typeof raw === "number" ? raw : scalarToString(raw)];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function pciResponseToValue(json: unknown): ResponseValue {
|
|
55
|
+
if (typeof json !== "object" || json === null) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const shaped = json as { base?: unknown; list?: unknown; record?: unknown };
|
|
60
|
+
|
|
61
|
+
if (shaped.base !== undefined) {
|
|
62
|
+
const entry = payloadEntry(shaped.base);
|
|
63
|
+
|
|
64
|
+
return entry === undefined || entry === null ? null : scalarToString(entry);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (shaped.list !== undefined) {
|
|
68
|
+
const entry = payloadEntry(shaped.list);
|
|
69
|
+
|
|
70
|
+
return Array.isArray(entry) ? entry.map(scalarToString) : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (Array.isArray(shaped.record)) {
|
|
74
|
+
return Object.fromEntries(
|
|
75
|
+
shaped.record.flatMap((entry) => {
|
|
76
|
+
const field = recordField(entry);
|
|
77
|
+
|
|
78
|
+
return field === null ? [] : [field];
|
|
79
|
+
}),
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return null; // unknown shapes
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** PCI JSON base key for a record field, derived from its runtime type. */
|
|
87
|
+
function fieldPciType(value: string | number | boolean): string {
|
|
88
|
+
return typeof value === "boolean" ? "boolean" : typeof value === "number" ? "float" : "string";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const numericBaseTypes = new Set(["integer", "float"]);
|
|
92
|
+
|
|
93
|
+
function bindScalar(value: string, baseType: string): unknown {
|
|
94
|
+
if (numericBaseTypes.has(baseType)) {
|
|
95
|
+
return Number(value);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (baseType === "boolean") {
|
|
99
|
+
return value === "true";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (baseType === "point") {
|
|
103
|
+
return value.split(/\s+/u).map(Number);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (baseType === "pair" || baseType === "directedPair") {
|
|
107
|
+
return value.split(/\s+/u);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return value;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The current response in PCI JSON form — fed to `getInstance` as `boundTo`. */
|
|
114
|
+
export function valueToPciResponse(value: ResponseValue, declaration: ResponseDeclarationView): unknown {
|
|
115
|
+
const baseType = declaration.baseType ?? "string";
|
|
116
|
+
|
|
117
|
+
if (value === null || value === undefined) {
|
|
118
|
+
return { base: null };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (isResponseRecord(value)) {
|
|
122
|
+
return {
|
|
123
|
+
record: Object.entries(value).map(([name, member]) =>
|
|
124
|
+
member === null ? { name, base: null } : { name, base: { [fieldPciType(member)]: member } },
|
|
125
|
+
),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (declaration.cardinality === "single") {
|
|
130
|
+
const single = Array.isArray(value) ? value[0] : value;
|
|
131
|
+
|
|
132
|
+
return single === undefined ? { base: null } : { base: { [baseType]: bindScalar(single, baseType) } };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const entries = Array.isArray(value) ? value : [value];
|
|
136
|
+
|
|
137
|
+
return { list: { [baseType]: entries.map((entry) => bindScalar(entry, baseType)) } };
|
|
138
|
+
}
|
package/src/pci/skin.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The PCI host skin: a thin React wrapper over `mountPci`. It renders the container,
|
|
3
|
+
* mounts the module instance after first paint, registers a submit-time response
|
|
4
|
+
* collector with the attempt store, and tears the instance down on unmount. Module
|
|
5
|
+
* failures render an explicit error note — never a silent drop (ADR-0003).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createElement, useEffect, useRef, useState, type ReactNode } from "react";
|
|
9
|
+
|
|
10
|
+
import type { InteractionRenderProps, InteractionSkin } from "../runtime";
|
|
11
|
+
import { mountPci, type PciInteractionNode, type PciMountHandle } from "./mount";
|
|
12
|
+
import type { PciModuleRegistry } from "./registry";
|
|
13
|
+
|
|
14
|
+
export interface PciSkinOptions {
|
|
15
|
+
readonly registry: PciModuleRegistry;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createPciSkin(options: PciSkinOptions): InteractionSkin {
|
|
19
|
+
return function PciHost(props: InteractionRenderProps): ReactNode {
|
|
20
|
+
const node = props.node as unknown as PciInteractionNode;
|
|
21
|
+
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
22
|
+
const handleRef = useRef<PciMountHandle | null>(null);
|
|
23
|
+
const propsRef = useRef(props);
|
|
24
|
+
propsRef.current = props;
|
|
25
|
+
|
|
26
|
+
const [mountError, setMountError] = useState<string | null>(null);
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
const container = containerRef.current;
|
|
30
|
+
|
|
31
|
+
if (!container) {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let cancelled = false;
|
|
36
|
+
let mounted: PciMountHandle | null = null;
|
|
37
|
+
|
|
38
|
+
mountPci({
|
|
39
|
+
container,
|
|
40
|
+
node: propsRef.current.node as unknown as PciInteractionNode,
|
|
41
|
+
registry: options.registry,
|
|
42
|
+
ondone: (value) => propsRef.current.setValue(value),
|
|
43
|
+
})
|
|
44
|
+
.then((handle) => {
|
|
45
|
+
if (cancelled) {
|
|
46
|
+
handle.unmount();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
mounted = handle;
|
|
51
|
+
handleRef.current = handle;
|
|
52
|
+
})
|
|
53
|
+
.catch((error: unknown) => {
|
|
54
|
+
if (!cancelled) {
|
|
55
|
+
setMountError(error instanceof Error ? error.message : String(error));
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
return () => {
|
|
60
|
+
cancelled = true;
|
|
61
|
+
mounted?.unmount();
|
|
62
|
+
handleRef.current = null;
|
|
63
|
+
};
|
|
64
|
+
}, []);
|
|
65
|
+
|
|
66
|
+
// The attempt store pulls the instance's response at submit time.
|
|
67
|
+
useEffect(() => propsRef.current.registerResponseCollector(() => handleRef.current?.collectResponse()), []);
|
|
68
|
+
|
|
69
|
+
return createElement(
|
|
70
|
+
"div",
|
|
71
|
+
{
|
|
72
|
+
"data-qti-interaction": "portableCustomInteraction",
|
|
73
|
+
"data-qti-pci-type": node.customInteractionTypeIdentifier,
|
|
74
|
+
className: node.class?.join(" "),
|
|
75
|
+
},
|
|
76
|
+
createElement("div", { ref: containerRef, "data-qti-pci-container": "" }),
|
|
77
|
+
mountError !== null
|
|
78
|
+
? createElement(
|
|
79
|
+
"p",
|
|
80
|
+
{ role: "note", "data-qti-pci-error": "" },
|
|
81
|
+
`Custom interaction failed to load: ${mountError}`,
|
|
82
|
+
)
|
|
83
|
+
: null,
|
|
84
|
+
);
|
|
85
|
+
};
|
|
86
|
+
}
|