@conform-ed/qti-react 0.0.14 → 0.0.16
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.d.ts +6 -4
- package/dist/index.js +2492 -408
- package/dist/normalized-item.d.ts +7 -5
- package/dist/pnp.d.ts +115 -0
- package/dist/response-validity.d.ts +28 -0
- package/dist/rp/evaluate.d.ts +6 -1
- package/dist/rp/index.d.ts +2 -2
- package/dist/rp/interpreter.d.ts +7 -1
- package/dist/rp/lookup-table.d.ts +17 -0
- package/dist/rp/template-processing.d.ts +5 -0
- package/dist/rp/types.d.ts +71 -7
- package/dist/runtime.d.ts +95 -0
- package/dist/store.d.ts +47 -0
- package/dist/test/controller.d.ts +22 -0
- package/dist/test/index.d.ts +2 -1
- package/dist/test/results.d.ts +102 -0
- package/dist/test/session-store.d.ts +32 -0
- package/dist/test/types.d.ts +173 -5
- package/dist/types.d.ts +5 -0
- package/package.json +5 -1
- package/src/content-model.ts +44 -4
- package/src/index.ts +43 -1
- package/src/normalized-item.ts +106 -4
- package/src/pci/mount.ts +11 -3
- package/src/pnp.ts +333 -0
- package/src/reference-skin/choice.ts +3 -0
- package/src/response-validity.ts +163 -0
- package/src/rp/evaluate.ts +280 -32
- package/src/rp/index.ts +5 -0
- package/src/rp/interpreter.ts +81 -1
- package/src/rp/lookup-table.ts +46 -0
- package/src/rp/template-processing.ts +41 -0
- package/src/rp/types.ts +75 -7
- package/src/runtime.ts +397 -20
- package/src/store.ts +146 -8
- package/src/test/controller.ts +856 -82
- package/src/test/index.ts +23 -0
- package/src/test/results.ts +378 -0
- package/src/test/session-store.ts +109 -1
- package/src/test/types.ts +172 -5
- package/src/types.ts +1 -0
- package/src/xspattern.d.ts +11 -0
package/src/normalized-item.ts
CHANGED
|
@@ -11,13 +11,15 @@
|
|
|
11
11
|
* - `gapChoices` split into the runtime's `gapTexts` (gapMatch) / `gapImgs` (graphic)
|
|
12
12
|
* - media/upload/positionObjectStage flatten to the descriptor fields
|
|
13
13
|
* - processing trees: `children` → `expressions`, `actions` → `rules`,
|
|
14
|
-
* `responseElseIf`/`templateElseIf` pluralize
|
|
14
|
+
* `responseElseIf`/`templateElseIf` pluralize; fragment rules keep their
|
|
15
|
+
* nested `rules` verbatim
|
|
15
16
|
*
|
|
16
17
|
* Used by the corpus delivery meter (ADR-0002) and by any consumer ingesting
|
|
17
18
|
* normalized XML.
|
|
18
19
|
*/
|
|
19
20
|
|
|
20
21
|
import { parseCoords } from "./graphic";
|
|
22
|
+
import type { CatalogView } from "./pnp";
|
|
21
23
|
import type {
|
|
22
24
|
OutcomeDeclarationView,
|
|
23
25
|
ResponseProcessingView,
|
|
@@ -27,7 +29,13 @@ import type {
|
|
|
27
29
|
TemplateProcessingView,
|
|
28
30
|
TemplateRuleView,
|
|
29
31
|
} from "./rp";
|
|
30
|
-
import type {
|
|
32
|
+
import type {
|
|
33
|
+
AssessmentItemView,
|
|
34
|
+
BodyNode,
|
|
35
|
+
CompanionMaterialsView,
|
|
36
|
+
FeedbackView,
|
|
37
|
+
StimulusContentView,
|
|
38
|
+
} from "./runtime";
|
|
31
39
|
import type {
|
|
32
40
|
AssessmentItemRefView,
|
|
33
41
|
AssessmentSectionView,
|
|
@@ -303,6 +311,11 @@ function convertRpRule(rule: unknown): Record<string, unknown> {
|
|
|
303
311
|
};
|
|
304
312
|
}
|
|
305
313
|
|
|
314
|
+
// Fragments nest their rules under `rules` (not the branches' `actions`).
|
|
315
|
+
if (kind === "responseProcessingFragment") {
|
|
316
|
+
return { kind, rules: (Array.isArray(record["rules"]) ? record["rules"] : []).map(convertRpRule) };
|
|
317
|
+
}
|
|
318
|
+
|
|
306
319
|
return {
|
|
307
320
|
kind,
|
|
308
321
|
...(typeof record["identifier"] === "string" ? { identifier: record["identifier"] } : {}),
|
|
@@ -369,6 +382,47 @@ function convertResponseDeclaration(declaration: Record<string, unknown>): Respo
|
|
|
369
382
|
* Reshape a normalized QTI document (the `normalizedDocument` from qti-xml validation)
|
|
370
383
|
* into an `AssessmentItemView`, or null when it is not an assessment item.
|
|
371
384
|
*/
|
|
385
|
+
// ---------- catalogs (CatalogInfo, §5.29) ----------
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Collect every catalog reachable from a converted node tree — catalog ids are
|
|
389
|
+
* document-unique (xs:ID), so item-level and nested (rubric/feedback block) catalogs
|
|
390
|
+
* pool into one list for idref resolution.
|
|
391
|
+
*/
|
|
392
|
+
function appendCatalogViews(target: CatalogView[], value: unknown): void {
|
|
393
|
+
if (Array.isArray(value)) {
|
|
394
|
+
for (const entry of value) {
|
|
395
|
+
appendCatalogViews(target, entry);
|
|
396
|
+
}
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (!isRecord(value)) {
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const catalogInfo = value["catalogInfo"];
|
|
405
|
+
if (isRecord(catalogInfo) && Array.isArray(catalogInfo["catalogs"])) {
|
|
406
|
+
target.push(...(catalogInfo["catalogs"] as CatalogView[]));
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
appendCatalogViews(target, value["content"]);
|
|
410
|
+
appendCatalogViews(target, value["children"]);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Item/stimulus-level catalogInfo, converted so card content is renderer-ready. */
|
|
414
|
+
function documentCatalogViews(root: Record<string, unknown>, convertedContent: unknown): CatalogView[] {
|
|
415
|
+
const catalogs: CatalogView[] = [];
|
|
416
|
+
|
|
417
|
+
appendCatalogViews(
|
|
418
|
+
catalogs,
|
|
419
|
+
isRecord(root["catalogInfo"]) ? { catalogInfo: convertContentValue(root["catalogInfo"]) } : undefined,
|
|
420
|
+
);
|
|
421
|
+
appendCatalogViews(catalogs, convertedContent);
|
|
422
|
+
|
|
423
|
+
return catalogs;
|
|
424
|
+
}
|
|
425
|
+
|
|
372
426
|
export function assessmentItemViewFromNormalized(document: unknown): AssessmentItemView | null {
|
|
373
427
|
if (!isRecord(document) || !isRecord(document["assessmentItem"])) {
|
|
374
428
|
return null;
|
|
@@ -378,6 +432,10 @@ export function assessmentItemViewFromNormalized(document: unknown): AssessmentI
|
|
|
378
432
|
const itemBody = isRecord(item["itemBody"]) ? item["itemBody"] : {};
|
|
379
433
|
const content = Array.isArray(itemBody["content"]) ? itemBody["content"].map(convertContentEntry) : [];
|
|
380
434
|
const templateRules = isRecord(item["templateProcessing"]) ? item["templateProcessing"]["rules"] : undefined;
|
|
435
|
+
const convertedModalFeedbacks = Array.isArray(item["modalFeedbacks"])
|
|
436
|
+
? item["modalFeedbacks"].map(convertContentValue)
|
|
437
|
+
: undefined;
|
|
438
|
+
const catalogs = documentCatalogViews(item, [content, convertedModalFeedbacks]);
|
|
381
439
|
|
|
382
440
|
return {
|
|
383
441
|
responseDeclarations: asRecords(item["responseDeclarations"]).map(convertResponseDeclaration),
|
|
@@ -396,13 +454,43 @@ export function assessmentItemViewFromNormalized(document: unknown): AssessmentI
|
|
|
396
454
|
}
|
|
397
455
|
: {}),
|
|
398
456
|
...(typeof item["adaptive"] === "boolean" ? { adaptive: item["adaptive"] } : {}),
|
|
399
|
-
...(
|
|
400
|
-
? { modalFeedbacks:
|
|
457
|
+
...(convertedModalFeedbacks
|
|
458
|
+
? { modalFeedbacks: convertedModalFeedbacks as unknown as readonly FeedbackView[] }
|
|
459
|
+
: {}),
|
|
460
|
+
...(catalogs.length ? { catalogs } : {}),
|
|
461
|
+
...(isRecord(item["companionMaterialsInfo"])
|
|
462
|
+
? { companionMaterials: item["companionMaterialsInfo"] as CompanionMaterialsView }
|
|
463
|
+
: {}),
|
|
464
|
+
...(Array.isArray(item["assessmentStimulusRefs"])
|
|
465
|
+
? {
|
|
466
|
+
assessmentStimulusRefs: asRecords(item["assessmentStimulusRefs"]).map((ref) => ({
|
|
467
|
+
identifier: typeof ref["identifier"] === "string" ? ref["identifier"] : "",
|
|
468
|
+
href: typeof ref["href"] === "string" ? ref["href"] : "",
|
|
469
|
+
...(typeof ref["title"] === "string" ? { title: ref["title"] } : {}),
|
|
470
|
+
})),
|
|
471
|
+
}
|
|
401
472
|
: {}),
|
|
402
473
|
itemBody: { content: content as BodyNode[] },
|
|
403
474
|
};
|
|
404
475
|
}
|
|
405
476
|
|
|
477
|
+
/**
|
|
478
|
+
* The renderable body of a normalized AssessmentStimulus document, for
|
|
479
|
+
* `QtiRuntimeConfig.resolveStimulus`. Returns null for non-stimulus documents.
|
|
480
|
+
*/
|
|
481
|
+
export function stimulusContentFromNormalized(document: unknown): StimulusContentView | null {
|
|
482
|
+
if (!isRecord(document) || !isRecord(document["assessmentStimulus"])) {
|
|
483
|
+
return null;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const stimulus = document["assessmentStimulus"];
|
|
487
|
+
const body = isRecord(stimulus["stimulusBody"]) ? stimulus["stimulusBody"] : {};
|
|
488
|
+
const content = Array.isArray(body["content"]) ? body["content"].map(convertContentEntry) : [];
|
|
489
|
+
const catalogs = documentCatalogViews(stimulus, content);
|
|
490
|
+
|
|
491
|
+
return { content: content as BodyNode[], ...(catalogs.length ? { catalogs } : {}) };
|
|
492
|
+
}
|
|
493
|
+
|
|
406
494
|
// ---------- assessment tests (ADR-0005) ----------
|
|
407
495
|
|
|
408
496
|
/** Normalized `{kind: "preCondition", expression}` wrappers as bare expressions. */
|
|
@@ -442,6 +530,11 @@ function convertOutcomeRule(rule: unknown): Record<string, unknown> {
|
|
|
442
530
|
};
|
|
443
531
|
}
|
|
444
532
|
|
|
533
|
+
// Fragments nest their rules under `rules` (not the branches' `actions`).
|
|
534
|
+
if (kind === "outcomeProcessingFragment") {
|
|
535
|
+
return { kind, rules: (Array.isArray(record["rules"]) ? record["rules"] : []).map(convertOutcomeRule) };
|
|
536
|
+
}
|
|
537
|
+
|
|
445
538
|
return {
|
|
446
539
|
kind,
|
|
447
540
|
...(typeof record["identifier"] === "string" ? { identifier: record["identifier"] } : {}),
|
|
@@ -475,6 +568,14 @@ function convertItemRef(ref: Record<string, unknown>): AssessmentItemRefView {
|
|
|
475
568
|
...(Array.isArray(ref["weights"])
|
|
476
569
|
? { weights: ref["weights"] as NonNullable<AssessmentItemRefView["weights"]> }
|
|
477
570
|
: {}),
|
|
571
|
+
...(Array.isArray(ref["templateDefaults"])
|
|
572
|
+
? {
|
|
573
|
+
templateDefaults: asRecords(ref["templateDefaults"]).map((entry) => ({
|
|
574
|
+
templateIdentifier: typeof entry["templateIdentifier"] === "string" ? entry["templateIdentifier"] : "",
|
|
575
|
+
expression: convertExpression(entry["expression"]),
|
|
576
|
+
})),
|
|
577
|
+
}
|
|
578
|
+
: {}),
|
|
478
579
|
...sessionControlAndTimeLimits(ref),
|
|
479
580
|
};
|
|
480
581
|
}
|
|
@@ -494,6 +595,7 @@ function convertSection(section: Record<string, unknown>): AssessmentSectionView
|
|
|
494
595
|
...(typeof section["visible"] === "boolean" ? { visible: section["visible"] } : {}),
|
|
495
596
|
...(typeof section["fixed"] === "boolean" ? { fixed: section["fixed"] } : {}),
|
|
496
597
|
...(typeof section["required"] === "boolean" ? { required: section["required"] } : {}),
|
|
598
|
+
...(typeof section["keepTogether"] === "boolean" ? { keepTogether: section["keepTogether"] } : {}),
|
|
497
599
|
...(isRecord(section["selection"])
|
|
498
600
|
? { selection: section["selection"] as unknown as NonNullable<AssessmentSectionView["selection"]> }
|
|
499
601
|
: {}),
|
package/src/pci/mount.ts
CHANGED
|
@@ -91,10 +91,18 @@ export async function mountPci(options: PciMountOptions): Promise<PciMountHandle
|
|
|
91
91
|
const { container, node, registry } = options;
|
|
92
92
|
const module = await resolveModule(node, registry);
|
|
93
93
|
|
|
94
|
+
// Each mount owns a root element inside the container, and teardown removes only
|
|
95
|
+
// that root: mounts on a shared container must stay independent because React
|
|
96
|
+
// StrictMode double-invokes the host effect — the cancelled first mount's teardown
|
|
97
|
+
// ran concurrently with the second mount and must not destroy its DOM.
|
|
98
|
+
const mountRoot = container.ownerDocument!.createElement("div");
|
|
99
|
+
mountRoot.setAttribute("data-qti-pci-mount", "");
|
|
100
|
+
container.appendChild(mountRoot);
|
|
101
|
+
|
|
94
102
|
const markupHost = container.ownerDocument!.createElement("div");
|
|
95
103
|
markupHost.className = "qti-interaction-markup";
|
|
96
104
|
markupHost.innerHTML = serializePciMarkup(node.interactionMarkup?.content);
|
|
97
|
-
|
|
105
|
+
mountRoot.appendChild(markupHost);
|
|
98
106
|
|
|
99
107
|
let resolveReady!: (instance: PciInstance) => void;
|
|
100
108
|
const ready = new Promise<PciInstance>((resolve) => {
|
|
@@ -114,7 +122,7 @@ export async function mountPci(options: PciMountOptions): Promise<PciMountHandle
|
|
|
114
122
|
|
|
115
123
|
// The spec delivers the instance via onready; implementations commonly also return
|
|
116
124
|
// it from getInstance. Accept either, first one wins.
|
|
117
|
-
const returned = module.getInstance(
|
|
125
|
+
const returned = module.getInstance(mountRoot, configuration, options.state);
|
|
118
126
|
|
|
119
127
|
if (returned) {
|
|
120
128
|
resolveReady(returned);
|
|
@@ -128,7 +136,7 @@ export async function mountPci(options: PciMountOptions): Promise<PciMountHandle
|
|
|
128
136
|
getState: () => instance.getState?.(),
|
|
129
137
|
unmount: () => {
|
|
130
138
|
instance.oncompleted?.();
|
|
131
|
-
|
|
139
|
+
mountRoot.remove();
|
|
132
140
|
},
|
|
133
141
|
};
|
|
134
142
|
}
|
package/src/pnp.ts
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AfA PNP (QTI 3.0 profile) views and catalog support resolution.
|
|
3
|
+
*
|
|
4
|
+
* The catalog holds "support-specific dormant content that can be made active … based
|
|
5
|
+
* on the candidate's PNP information (or an assessment program's settings)" (§5.28).
|
|
6
|
+
* This module owns the two halves of that sentence: which supports are active for a
|
|
7
|
+
* candidate (activation), and which card content realises an active support
|
|
8
|
+
* (matching). Rendering is the runtime's job; time-limit adjustments are the test
|
|
9
|
+
* controller's.
|
|
10
|
+
*
|
|
11
|
+
* Views are structural mirrors of the contracts AfA PNP and catalog schemas — the
|
|
12
|
+
* package depends on contracts only in tests, like the rest of qti-react.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { BodyNode } from "./runtime";
|
|
16
|
+
|
|
17
|
+
// ---------- PNP views ----------
|
|
18
|
+
|
|
19
|
+
export interface PnpReplaceAccessModeView {
|
|
20
|
+
readonly replaceAccessModes?: readonly string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface PnpLanguageModeView extends PnpReplaceAccessModeView {
|
|
24
|
+
readonly xmlLang: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** additional-testing-time: "Only one of the available options can be selected." */
|
|
28
|
+
export interface PnpAdditionalTestingTimeView extends PnpReplaceAccessModeView {
|
|
29
|
+
readonly timeMultiplier?: number;
|
|
30
|
+
readonly fixedMinutes?: number;
|
|
31
|
+
readonly unlimited?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface PnpFeatureSetView {
|
|
35
|
+
readonly features?: readonly string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The candidate's preferences, shaped like the normalized access-for-all-pnp
|
|
40
|
+
* document. Feature preference objects carry the fields card-entry discriminators
|
|
41
|
+
* match against (xmlLang, readingType, …); unknown fields are preserved.
|
|
42
|
+
*/
|
|
43
|
+
export interface PnpView {
|
|
44
|
+
readonly [feature: string]: unknown;
|
|
45
|
+
readonly languageOfInterface?: readonly PnpLanguageModeView[];
|
|
46
|
+
readonly keywordTranslation?: PnpLanguageModeView;
|
|
47
|
+
readonly itemTranslation?: PnpLanguageModeView;
|
|
48
|
+
readonly signLanguage?: PnpLanguageModeView;
|
|
49
|
+
readonly additionalTestingTime?: PnpAdditionalTestingTimeView;
|
|
50
|
+
readonly activateAtInitializationSet?: PnpFeatureSetView;
|
|
51
|
+
readonly activateAsOptionSet?: PnpFeatureSetView;
|
|
52
|
+
readonly prohibitSet?: PnpFeatureSetView;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---------- Catalog views ----------
|
|
56
|
+
|
|
57
|
+
export interface CatalogFileHrefView {
|
|
58
|
+
readonly href: string;
|
|
59
|
+
readonly mimeType: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface CatalogContentView {
|
|
63
|
+
readonly xmlLang?: string;
|
|
64
|
+
readonly dataAttributes?: Readonly<Record<string, string>>;
|
|
65
|
+
readonly content?: readonly BodyNode[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface CatalogCardEntryView {
|
|
69
|
+
readonly xmlLang?: string;
|
|
70
|
+
readonly default?: boolean;
|
|
71
|
+
readonly dataAttributes?: Readonly<Record<string, string>>;
|
|
72
|
+
readonly htmlContent?: CatalogContentView;
|
|
73
|
+
readonly fileHrefs?: readonly CatalogFileHrefView[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface CatalogCardView {
|
|
77
|
+
readonly support: string;
|
|
78
|
+
readonly xmlLang?: string;
|
|
79
|
+
readonly htmlContent?: CatalogContentView;
|
|
80
|
+
readonly fileHrefs?: readonly CatalogFileHrefView[];
|
|
81
|
+
readonly cardEntries?: readonly CatalogCardEntryView[];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface CatalogView {
|
|
85
|
+
readonly id: string;
|
|
86
|
+
readonly cards: readonly CatalogCardView[];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ---------- Activation ----------
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* PNP feature names (the FeatureSet vocabulary) to their preference fields on the
|
|
93
|
+
* normalized PNP document. Presence of the field states the preference.
|
|
94
|
+
*/
|
|
95
|
+
const pnpFeatureFields: Readonly<Record<string, string>> = {
|
|
96
|
+
"linguistic-guidance": "linguisticGuidance",
|
|
97
|
+
"keyword-emphasis": "keywordEmphasis",
|
|
98
|
+
"keyword-translation": "keywordTranslation",
|
|
99
|
+
"simplified-language-portions": "simplifiedLanguagePortions",
|
|
100
|
+
"simplified-graphics": "simplifiedGraphics",
|
|
101
|
+
"item-translation": "itemTranslation",
|
|
102
|
+
"sign-language": "signLanguage",
|
|
103
|
+
encouragement: "encouragement",
|
|
104
|
+
"additional-testing-time": "additionalTestingTime",
|
|
105
|
+
"line-reader": "lineReader",
|
|
106
|
+
"invert-display-polarity": "invertDisplayPolarity",
|
|
107
|
+
magnification: "magnification",
|
|
108
|
+
spoken: "spoken",
|
|
109
|
+
tactile: "tactile",
|
|
110
|
+
braille: "braille",
|
|
111
|
+
"answer-masking": "answerMasking",
|
|
112
|
+
"keyboard-directions": "keyboardDirections",
|
|
113
|
+
"additional-directions": "additionalDirections",
|
|
114
|
+
"long-description": "longDescription",
|
|
115
|
+
captions: "captions",
|
|
116
|
+
transcript: "transcript",
|
|
117
|
+
"alternative-text": "alternativeText",
|
|
118
|
+
"audio-description": "audioDescription",
|
|
119
|
+
"high-contrast": "highContrast",
|
|
120
|
+
"input-requirements": "inputRequirements",
|
|
121
|
+
"language-of-interface": "languageOfInterface",
|
|
122
|
+
"layout-single-column": "layoutSingleColumn",
|
|
123
|
+
"text-appearance": "textAppearance",
|
|
124
|
+
"calculator-on-screen": "calculatorOnScreen",
|
|
125
|
+
"dictionary-on-screen": "dictionaryOnScreen",
|
|
126
|
+
"glossary-on-screen": "glossaryOnScreen",
|
|
127
|
+
"thesaurus-on-screen": "thesaurusOnScreen",
|
|
128
|
+
"homophone-checker-on-screen": "homophoneCheckerOnScreen",
|
|
129
|
+
"note-taking-on-screen": "noteTakingOnScreen",
|
|
130
|
+
"visual-organizer-on-screen": "visualOrganizerOnScreen",
|
|
131
|
+
"outliner-on-screen": "outlinerOnScreen",
|
|
132
|
+
"peer-interaction-on-screen": "peerInteractionOnScreen",
|
|
133
|
+
"spell-checker-on-screen": "spellCheckerOnScreen",
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
export interface PnpActivation {
|
|
137
|
+
/** Supports in effect from the start of the session. */
|
|
138
|
+
readonly active: ReadonlySet<string>;
|
|
139
|
+
/** Supports the candidate may turn on during the session (activate-as-option-set). */
|
|
140
|
+
readonly optional: ReadonlySet<string>;
|
|
141
|
+
/** Supports that must not be offered (prohibit-set) — wins over everything. */
|
|
142
|
+
readonly prohibited: ReadonlySet<string>;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Resolve the activation policy: prohibit-set wins; activate-at-initialization-set is
|
|
147
|
+
* active; activate-as-option-set is offered but off. A preference stated outside any
|
|
148
|
+
* set (e.g. a bare keyword-translation) is honored from the start — the PNP records
|
|
149
|
+
* the need, and without an activation policy there is nothing to defer to (designed
|
|
150
|
+
* policy, see the ADR).
|
|
151
|
+
*/
|
|
152
|
+
export function resolvePnpActivation(pnp: PnpView | undefined): PnpActivation {
|
|
153
|
+
const prohibited = new Set(pnp?.prohibitSet?.features ?? []);
|
|
154
|
+
const active = new Set<string>();
|
|
155
|
+
const optional = new Set<string>();
|
|
156
|
+
|
|
157
|
+
if (!pnp) {
|
|
158
|
+
return { active, optional, prohibited };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const optedIn = new Set(pnp.activateAsOptionSet?.features ?? []);
|
|
162
|
+
|
|
163
|
+
for (const feature of pnp.activateAtInitializationSet?.features ?? []) {
|
|
164
|
+
active.add(feature);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
for (const [feature, field] of Object.entries(pnpFeatureFields)) {
|
|
168
|
+
if (pnp[field] === undefined || active.has(feature) || optedIn.has(feature)) {
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
active.add(feature);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
for (const feature of optedIn) {
|
|
175
|
+
if (!active.has(feature)) {
|
|
176
|
+
optional.add(feature);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
for (const feature of prohibited) {
|
|
181
|
+
active.delete(feature);
|
|
182
|
+
optional.delete(feature);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return { active, optional, prohibited };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ---------- Matching ----------
|
|
189
|
+
|
|
190
|
+
/** BCP 47, pragmatically: case-insensitive exact match or equal primary subtags. */
|
|
191
|
+
function languagesMatch(left: string, right: string): boolean {
|
|
192
|
+
const a = left.toLowerCase();
|
|
193
|
+
const b = right.toLowerCase();
|
|
194
|
+
|
|
195
|
+
return a === b || a.split("-")[0] === b.split("-")[0];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function camelCase(name: string): string {
|
|
199
|
+
return name.replace(/-([a-z])/gu, (_, letter: string) => letter.toUpperCase());
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The PNP preference object stated for a feature — what card entries discriminate
|
|
204
|
+
* against, and what results reporting reads detail (language, time values) from.
|
|
205
|
+
*/
|
|
206
|
+
export function pnpFeaturePreference(pnp: PnpView | undefined, feature: string): Record<string, unknown> | undefined {
|
|
207
|
+
const field = pnpFeatureFields[feature];
|
|
208
|
+
if (!pnp || !field) {
|
|
209
|
+
return undefined;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const value = pnp[field];
|
|
213
|
+
const first = Array.isArray(value) ? value[0] : value;
|
|
214
|
+
|
|
215
|
+
return typeof first === "object" && first !== null ? (first as Record<string, unknown>) : undefined;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* An entry matches when every discriminator it declares (xml:lang, data-*) agrees
|
|
220
|
+
* with the candidate's preference for the card's support. Entries declaring nothing
|
|
221
|
+
* match unconditionally.
|
|
222
|
+
*/
|
|
223
|
+
function entryMatches(entry: CatalogCardEntryView, preference: Record<string, unknown> | undefined): boolean {
|
|
224
|
+
if (entry.xmlLang !== undefined) {
|
|
225
|
+
const preferred = preference?.["xmlLang"];
|
|
226
|
+
if (typeof preferred !== "string" || !languagesMatch(entry.xmlLang, preferred)) {
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
for (const [name, value] of Object.entries(entry.dataAttributes ?? {})) {
|
|
232
|
+
const preferred = preference?.[camelCase(name)];
|
|
233
|
+
// Preference fields are scalars (strings, numbers, booleans) after normalization.
|
|
234
|
+
const comparable =
|
|
235
|
+
typeof preferred === "string" || typeof preferred === "number" || typeof preferred === "boolean"
|
|
236
|
+
? `${preferred}`
|
|
237
|
+
: undefined;
|
|
238
|
+
if (comparable === undefined || comparable !== value) {
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** A support's resolved alternative content for one catalog. */
|
|
247
|
+
export interface ResolvedCatalogSupport {
|
|
248
|
+
readonly support: string;
|
|
249
|
+
readonly xmlLang?: string;
|
|
250
|
+
readonly content?: readonly BodyNode[];
|
|
251
|
+
readonly fileHrefs?: readonly CatalogFileHrefView[];
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export interface CatalogResolution {
|
|
255
|
+
readonly activation: PnpActivation;
|
|
256
|
+
readonly byCatalogId: ReadonlyMap<string, readonly ResolvedCatalogSupport[]>;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function resolveCard(card: CatalogCardView, pnp: PnpView | undefined): ResolvedCatalogSupport | undefined {
|
|
260
|
+
if (!card.cardEntries) {
|
|
261
|
+
// Direct content is unconditional once the support is active.
|
|
262
|
+
const cardLang = card.xmlLang ?? card.htmlContent?.xmlLang;
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
support: card.support,
|
|
266
|
+
...(cardLang !== undefined ? { xmlLang: cardLang } : {}),
|
|
267
|
+
...(card.htmlContent?.content ? { content: card.htmlContent.content } : {}),
|
|
268
|
+
...(card.fileHrefs ? { fileHrefs: card.fileHrefs } : {}),
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const preference = pnpFeaturePreference(pnp, card.support);
|
|
273
|
+
// "If the CardEntry attribute values do not identify the proper content for a
|
|
274
|
+
// candidate, use the content designated as default." (§5.27.2)
|
|
275
|
+
const entry =
|
|
276
|
+
card.cardEntries.find((candidate) => entryMatches(candidate, preference)) ??
|
|
277
|
+
card.cardEntries.find((candidate) => candidate.default === true);
|
|
278
|
+
|
|
279
|
+
if (!entry) {
|
|
280
|
+
return undefined;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const xmlLang = entry.xmlLang ?? entry.htmlContent?.xmlLang ?? card.xmlLang;
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
support: card.support,
|
|
287
|
+
...(xmlLang !== undefined ? { xmlLang } : {}),
|
|
288
|
+
...(entry.htmlContent?.content ? { content: entry.htmlContent.content } : {}),
|
|
289
|
+
...(entry.fileHrefs ? { fileHrefs: entry.fileHrefs } : {}),
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Resolve every catalog's active alternative content for a candidate. `activeSupports`
|
|
295
|
+
* is the delivery-engine channel — program settings and candidate-toggled options
|
|
296
|
+
* ("or an assessment program's settings", §5.28); prohibited supports stay out even
|
|
297
|
+
* when named there.
|
|
298
|
+
*/
|
|
299
|
+
export function resolveCatalogSupports(options: {
|
|
300
|
+
readonly catalogs?: readonly CatalogView[] | undefined;
|
|
301
|
+
readonly pnp?: PnpView | undefined;
|
|
302
|
+
readonly activeSupports?: readonly string[] | undefined;
|
|
303
|
+
}): CatalogResolution {
|
|
304
|
+
const activation = resolvePnpActivation(options.pnp);
|
|
305
|
+
const effective = new Set(activation.active);
|
|
306
|
+
|
|
307
|
+
for (const support of options.activeSupports ?? []) {
|
|
308
|
+
if (!activation.prohibited.has(support)) {
|
|
309
|
+
effective.add(support);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const byCatalogId = new Map<string, readonly ResolvedCatalogSupport[]>();
|
|
314
|
+
|
|
315
|
+
for (const catalog of options.catalogs ?? []) {
|
|
316
|
+
const resolved: ResolvedCatalogSupport[] = [];
|
|
317
|
+
|
|
318
|
+
for (const card of catalog.cards) {
|
|
319
|
+
if (!effective.has(card.support)) {
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const support = resolveCard(card, options.pnp);
|
|
324
|
+
if (support) {
|
|
325
|
+
resolved.push(support);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
byCatalogId.set(catalog.id, resolved);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return { activation, byCatalogId };
|
|
333
|
+
}
|
|
@@ -11,6 +11,8 @@ import type { BodyNode, InteractionRenderProps } from "../runtime";
|
|
|
11
11
|
interface SimpleChoiceView {
|
|
12
12
|
identifier: string;
|
|
13
13
|
content?: readonly BodyNode[];
|
|
14
|
+
/** Catalog reference for dormant alternative content on this choice (§5.28). */
|
|
15
|
+
dataCatalogIdref?: string;
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
interface ChoiceNodeView {
|
|
@@ -38,6 +40,7 @@ export function ChoiceReferenceSkin(props: InteractionRenderProps): ReactNode {
|
|
|
38
40
|
"button",
|
|
39
41
|
{ key: choice.identifier, type: "button", disabled: props.disabled, ...optionProps },
|
|
40
42
|
props.renderContent(choice.content) ?? choice.identifier,
|
|
43
|
+
props.renderCatalogSupports(choice.dataCatalogIdref),
|
|
41
44
|
);
|
|
42
45
|
}),
|
|
43
46
|
);
|