@longsightgroup/qti3-core 0.6.0 → 0.7.1
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 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/parser.js +13 -1
- package/dist/parser.js.map +1 -1
- package/dist/shared-vocabulary-coverage-policy.d.ts +10 -0
- package/dist/shared-vocabulary-coverage-policy.d.ts.map +1 -0
- package/dist/shared-vocabulary-coverage-policy.js +61 -0
- package/dist/shared-vocabulary-coverage-policy.js.map +1 -0
- package/dist/shared-vocabulary-generated-families.d.ts +21 -0
- package/dist/shared-vocabulary-generated-families.d.ts.map +1 -0
- package/dist/shared-vocabulary-generated-families.js +194 -0
- package/dist/shared-vocabulary-generated-families.js.map +1 -0
- package/dist/shared-vocabulary-levels.d.ts +4 -0
- package/dist/shared-vocabulary-levels.d.ts.map +1 -0
- package/dist/shared-vocabulary-levels.js +4 -0
- package/dist/shared-vocabulary-levels.js.map +1 -0
- package/dist/shared-vocabulary-support.d.ts +3 -0
- package/dist/shared-vocabulary-support.d.ts.map +1 -0
- package/dist/shared-vocabulary-support.js +208 -0
- package/dist/shared-vocabulary-support.js.map +1 -0
- package/dist/shared-vocabulary-validation.d.ts +40 -0
- package/dist/shared-vocabulary-validation.d.ts.map +1 -0
- package/dist/shared-vocabulary-validation.js +108 -0
- package/dist/shared-vocabulary-validation.js.map +1 -0
- package/dist/shared-vocabulary.d.ts +35 -0
- package/dist/shared-vocabulary.d.ts.map +1 -0
- package/dist/shared-vocabulary.js +128 -0
- package/dist/shared-vocabulary.js.map +1 -0
- package/dist/types.d.ts +10 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/validation.d.ts.map +1 -1
- package/dist/validation.js +364 -0
- package/dist/validation.js.map +1 -1
- package/dist/xml.d.ts.map +1 -1
- package/dist/xml.js +100 -0
- package/dist/xml.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +56 -0
- package/src/parser.ts +17 -1
- package/src/shared-vocabulary-coverage-policy.ts +79 -0
- package/src/shared-vocabulary-generated-families.ts +252 -0
- package/src/shared-vocabulary-levels.ts +7 -0
- package/src/shared-vocabulary-support.ts +340 -0
- package/src/shared-vocabulary-validation.ts +182 -0
- package/src/shared-vocabulary.ts +177 -0
- package/src/types.ts +11 -0
- package/src/validation.ts +466 -0
- package/src/xml.ts +107 -0
package/src/parser.ts
CHANGED
|
@@ -980,9 +980,16 @@ function parseChoices(node: XmlNode): QtiChoice[] {
|
|
|
980
980
|
|
|
981
981
|
return descendants(node, (child) => choiceNames.has(child.localName)).map((choice, index) => {
|
|
982
982
|
const identifier = choice.attributes.identifier ?? "";
|
|
983
|
+
const asset = parseChoiceAsset(choice);
|
|
983
984
|
return {
|
|
984
985
|
identifier,
|
|
985
|
-
text:
|
|
986
|
+
text:
|
|
987
|
+
textContent(choice) ||
|
|
988
|
+
choice.attributes["object-label"] ||
|
|
989
|
+
asset?.text ||
|
|
990
|
+
identifier ||
|
|
991
|
+
`Choice ${index + 1}`,
|
|
992
|
+
asset,
|
|
986
993
|
role: choiceRole(choice),
|
|
987
994
|
qtiName: choice.localName,
|
|
988
995
|
attributes: choice.attributes,
|
|
@@ -991,6 +998,15 @@ function parseChoices(node: XmlNode): QtiChoice[] {
|
|
|
991
998
|
});
|
|
992
999
|
}
|
|
993
1000
|
|
|
1001
|
+
function parseChoiceAsset(choice: XmlNode): QtiObjectAsset | undefined {
|
|
1002
|
+
if (choice.localName !== "qti-gap-img") return undefined;
|
|
1003
|
+
const assetNode = childElements(choice).find(
|
|
1004
|
+
(child) =>
|
|
1005
|
+
child.localName === "img" || child.localName === "object" || child.localName === "picture",
|
|
1006
|
+
);
|
|
1007
|
+
return parseObjectAsset(assetNode);
|
|
1008
|
+
}
|
|
1009
|
+
|
|
994
1010
|
function choiceRole(node: XmlNode): QtiChoiceRole {
|
|
995
1011
|
if (node.localName === "qti-simple-choice") return "simpleChoice";
|
|
996
1012
|
if (node.localName === "qti-inline-choice") return "inlineChoice";
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { SharedVocabularyClassSupport } from "./types.js";
|
|
2
|
+
import {
|
|
3
|
+
matrixCoverageFamilyForClass,
|
|
4
|
+
sharedVocabularyMatrixCoverageFamilies,
|
|
5
|
+
} from "./shared-vocabulary-generated-families.js";
|
|
6
|
+
import { isEnforcedSharedVocabularyLevel } from "./shared-vocabulary-levels.js";
|
|
7
|
+
|
|
8
|
+
export interface SharedVocabularyCoverageViolation {
|
|
9
|
+
message: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function findSharedVocabularyCoverageViolations(input: {
|
|
13
|
+
matrixClasses: ReadonlySet<string>;
|
|
14
|
+
support: readonly SharedVocabularyClassSupport[];
|
|
15
|
+
matrixTestPath: string;
|
|
16
|
+
}): SharedVocabularyCoverageViolation[] {
|
|
17
|
+
const violations: SharedVocabularyCoverageViolation[] = [];
|
|
18
|
+
const { matrixClasses, support, matrixTestPath } = input;
|
|
19
|
+
|
|
20
|
+
const exceptionIds = sharedVocabularyMatrixCoverageFamilies.map((family) => family.id);
|
|
21
|
+
if (new Set(exceptionIds).size !== exceptionIds.length) {
|
|
22
|
+
violations.push({ message: "shared vocabulary matrix coverage family ids must be unique" });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
for (const family of sharedVocabularyMatrixCoverageFamilies) {
|
|
26
|
+
if (family.rationale.trim().length === 0) {
|
|
27
|
+
violations.push({ message: `${family.id} must include a coverage rationale` });
|
|
28
|
+
}
|
|
29
|
+
if (family.coveredBy.length === 0) {
|
|
30
|
+
violations.push({ message: `${family.id} must list representative covered classes` });
|
|
31
|
+
}
|
|
32
|
+
for (const coveredClass of family.coveredBy) {
|
|
33
|
+
if (!matrixClasses.has(coveredClass)) {
|
|
34
|
+
violations.push({
|
|
35
|
+
message: `${family.id} coveredBy class ${coveredClass} must appear in the matrix manifest`,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const supportByClass = new Map<string, SharedVocabularyClassSupport[]>();
|
|
42
|
+
for (const entry of support) {
|
|
43
|
+
if (!isEnforcedSharedVocabularyLevel(entry.level)) {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
const existing = supportByClass.get(entry.className) ?? [];
|
|
47
|
+
existing.push(entry);
|
|
48
|
+
supportByClass.set(entry.className, existing);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const gatedSupportClasses = [...supportByClass.keys()].sort();
|
|
52
|
+
if (gatedSupportClasses.length === 0) {
|
|
53
|
+
violations.push({ message: "expected at least one enforced shared vocabulary class" });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
for (const className of gatedSupportClasses) {
|
|
57
|
+
const supportEntries = supportByClass.get(className) ?? [];
|
|
58
|
+
const directlyCovered = matrixClasses.has(className);
|
|
59
|
+
if (!directlyCovered) {
|
|
60
|
+
for (const entry of supportEntries) {
|
|
61
|
+
const exception = matrixCoverageFamilyForClass(className, entry.level);
|
|
62
|
+
if (!exception) {
|
|
63
|
+
violations.push({
|
|
64
|
+
message: `${className} requires matrix coverage or a documented generated-family exception`,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
for (const entry of supportEntries) {
|
|
70
|
+
if (!(entry.tests ?? []).includes(matrixTestPath)) {
|
|
71
|
+
violations.push({
|
|
72
|
+
message: `${className} must list ${matrixTestPath} in shared vocabulary test evidence`,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return violations;
|
|
79
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import type { SharedVocabularySupportLevel } from "./shared-vocabulary-levels.js";
|
|
2
|
+
|
|
3
|
+
export const SHARED_VOCABULARY_LAYOUT_COLUMN_SPAN_COUNT = 12;
|
|
4
|
+
export const SHARED_VOCABULARY_LAYOUT_OFFSET_COUNT = 11;
|
|
5
|
+
|
|
6
|
+
export const SHARED_VOCABULARY_CONTENT_ALIGNMENTS = ["left", "center", "right"] as const;
|
|
7
|
+
export const SHARED_VOCABULARY_CONTENT_VALIGNS = ["top", "middle", "baseline", "bottom"] as const;
|
|
8
|
+
export const SHARED_VOCABULARY_CONTENT_WIDTH_ALIASES = ["fullwidth", "width-full"] as const;
|
|
9
|
+
export const SHARED_VOCABULARY_CONTENT_WRITING_MODES = [
|
|
10
|
+
"vertical-rl",
|
|
11
|
+
"vertical-lr",
|
|
12
|
+
"vertical-tb",
|
|
13
|
+
"horizontal-tb",
|
|
14
|
+
] as const;
|
|
15
|
+
export const SHARED_VOCABULARY_CONTENT_FLOAT_SUFFIXES = [
|
|
16
|
+
"left",
|
|
17
|
+
"right",
|
|
18
|
+
"none",
|
|
19
|
+
"clearfix",
|
|
20
|
+
"clear-left",
|
|
21
|
+
"clear-right",
|
|
22
|
+
"clear-both",
|
|
23
|
+
] as const;
|
|
24
|
+
export const SHARED_VOCABULARY_CHOICE_WRITING_ORIENTATIONS = [
|
|
25
|
+
"vertical-rl",
|
|
26
|
+
"vertical-lr",
|
|
27
|
+
] as const;
|
|
28
|
+
|
|
29
|
+
export const SHARED_VOCABULARY_CONTENT_TEXT_INDENT_SUFFIXES = [
|
|
30
|
+
"0",
|
|
31
|
+
"px",
|
|
32
|
+
"0p5",
|
|
33
|
+
"1",
|
|
34
|
+
"1p5",
|
|
35
|
+
"2",
|
|
36
|
+
"2p5",
|
|
37
|
+
"3",
|
|
38
|
+
"3p5",
|
|
39
|
+
"4",
|
|
40
|
+
"5",
|
|
41
|
+
"6",
|
|
42
|
+
"7",
|
|
43
|
+
"8",
|
|
44
|
+
"12",
|
|
45
|
+
"16",
|
|
46
|
+
"20",
|
|
47
|
+
"24",
|
|
48
|
+
"28",
|
|
49
|
+
"32",
|
|
50
|
+
] as const;
|
|
51
|
+
|
|
52
|
+
export const SHARED_VOCABULARY_CONTENT_LIST_STYLE_TYPES = [
|
|
53
|
+
"none",
|
|
54
|
+
"arabic-indic",
|
|
55
|
+
"armenian",
|
|
56
|
+
"bengali",
|
|
57
|
+
"cambodian",
|
|
58
|
+
"circle",
|
|
59
|
+
"cjk-earthly-branch",
|
|
60
|
+
"cjk-heavenly-stem",
|
|
61
|
+
"cjk-ideographic",
|
|
62
|
+
"decimal",
|
|
63
|
+
"decimal-leading-zero",
|
|
64
|
+
"devanagari",
|
|
65
|
+
"disc",
|
|
66
|
+
"ethiopic-halehame",
|
|
67
|
+
"ethiopic-halehame-am",
|
|
68
|
+
"ethiopic-halehame-ti-er",
|
|
69
|
+
"ethiopic-halehame-ti-et",
|
|
70
|
+
"georgian",
|
|
71
|
+
"gujarati",
|
|
72
|
+
"gurmukhi",
|
|
73
|
+
"hangul",
|
|
74
|
+
"hangul-consonant",
|
|
75
|
+
"hebrew",
|
|
76
|
+
"hiragana",
|
|
77
|
+
"hiragana-iroha",
|
|
78
|
+
"kannada",
|
|
79
|
+
"katakana",
|
|
80
|
+
"katakana-iroha",
|
|
81
|
+
"khmer",
|
|
82
|
+
"korean-hangul-formal",
|
|
83
|
+
"korean-hanja-formal",
|
|
84
|
+
"korean-hanja-informal",
|
|
85
|
+
"lao",
|
|
86
|
+
"lower-alpha",
|
|
87
|
+
"lower-armenian",
|
|
88
|
+
"lower-greek",
|
|
89
|
+
"lower-latin",
|
|
90
|
+
"lower-roman",
|
|
91
|
+
"malayalam",
|
|
92
|
+
"mongolian",
|
|
93
|
+
"myanmar",
|
|
94
|
+
"oriya",
|
|
95
|
+
"persian",
|
|
96
|
+
"simp-chinese-formal",
|
|
97
|
+
"simp-chinese-informal",
|
|
98
|
+
"square",
|
|
99
|
+
"telugu",
|
|
100
|
+
"thai",
|
|
101
|
+
"tibetan",
|
|
102
|
+
"trad-chinese-formal",
|
|
103
|
+
"trad-chinese-informal",
|
|
104
|
+
"upper-alpha",
|
|
105
|
+
"upper-armenian",
|
|
106
|
+
"upper-latin",
|
|
107
|
+
"upper-roman",
|
|
108
|
+
"urdu",
|
|
109
|
+
] as const;
|
|
110
|
+
|
|
111
|
+
export interface SharedVocabularyMatrixCoverageFamily {
|
|
112
|
+
id: string;
|
|
113
|
+
levels: readonly SharedVocabularySupportLevel[];
|
|
114
|
+
coveredBy: readonly string[];
|
|
115
|
+
rationale: string;
|
|
116
|
+
matches: (className: string) => boolean;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function escapeRegExp(value: string): string {
|
|
120
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function alternationPattern(values: readonly string[]): string {
|
|
124
|
+
return [...values]
|
|
125
|
+
.sort((left, right) => right.length - left.length)
|
|
126
|
+
.map(escapeRegExp)
|
|
127
|
+
.join("|");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function layoutSpanTokens(count: number): string[] {
|
|
131
|
+
return Array.from({ length: count }, (_, index) => String(index + 1));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const layoutColumnPattern = new RegExp(
|
|
135
|
+
`^qti-layout-col-?(?:${alternationPattern(layoutSpanTokens(SHARED_VOCABULARY_LAYOUT_COLUMN_SPAN_COUNT))})$`,
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
const layoutOffsetPattern = new RegExp(
|
|
139
|
+
`^qti-layout-offset-?(?:${alternationPattern(layoutSpanTokens(SHARED_VOCABULARY_LAYOUT_OFFSET_COUNT))})$`,
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const contentAlignmentPattern = new RegExp(
|
|
143
|
+
`^qti-align-(?:${alternationPattern(SHARED_VOCABULARY_CONTENT_ALIGNMENTS)})$|^qti-valign-(?:${alternationPattern(SHARED_VOCABULARY_CONTENT_VALIGNS)})$`,
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
const contentWidthAliasPattern = new RegExp(
|
|
147
|
+
`^qti-(?:${alternationPattern(SHARED_VOCABULARY_CONTENT_WIDTH_ALIASES)})$`,
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
const contentTextIndentPattern = new RegExp(
|
|
151
|
+
`^qti-text-indent-(?:${alternationPattern(SHARED_VOCABULARY_CONTENT_TEXT_INDENT_SUFFIXES)})$`,
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
const contentWritingModePattern = new RegExp(
|
|
155
|
+
`^qti-writing-mode-(?:${alternationPattern(SHARED_VOCABULARY_CONTENT_WRITING_MODES)})$`,
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
const contentFloatPattern = new RegExp(
|
|
159
|
+
`^qti-float-(?:${alternationPattern(SHARED_VOCABULARY_CONTENT_FLOAT_SUFFIXES)})$`,
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
const contentListStylePattern = new RegExp(
|
|
163
|
+
`^qti-list-style-type-(?:${alternationPattern(SHARED_VOCABULARY_CONTENT_LIST_STYLE_TYPES)})$`,
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
const choiceWritingOrientationPattern = new RegExp(
|
|
167
|
+
`^qti-writing-orientation-(?:${alternationPattern(SHARED_VOCABULARY_CHOICE_WRITING_ORIENTATIONS)})$`,
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
export const sharedVocabularyMatrixCoverageFamilies: SharedVocabularyMatrixCoverageFamily[] = [
|
|
171
|
+
{
|
|
172
|
+
id: "content-layout-generated-column-variants",
|
|
173
|
+
levels: ["stylesheet"],
|
|
174
|
+
coveredBy: ["qti-layout-col6"],
|
|
175
|
+
rationale:
|
|
176
|
+
"Column width classes are generated from the same twelve-column CSS template; qti-layout-col6 exercises the generated percentage rule and row wrapping behavior.",
|
|
177
|
+
matches: (className) => layoutColumnPattern.test(className),
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
id: "content-layout-generated-offset-variants",
|
|
181
|
+
levels: ["stylesheet"],
|
|
182
|
+
coveredBy: ["qti-layout-offset-3"],
|
|
183
|
+
rationale:
|
|
184
|
+
"Offset classes are generated from one CSS template; qti-layout-offset-3 exercises the generated margin rule.",
|
|
185
|
+
matches: (className) => layoutOffsetPattern.test(className),
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
id: "content-alignment-variants",
|
|
189
|
+
levels: ["stylesheet"],
|
|
190
|
+
coveredBy: ["qti-align-center", "qti-valign-middle"],
|
|
191
|
+
rationale:
|
|
192
|
+
"Alignment classes map one vocabulary suffix to the corresponding CSS property value; center and middle cover the text-align and vertical-align families.",
|
|
193
|
+
matches: (className) => contentAlignmentPattern.test(className),
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
id: "content-width-aliases",
|
|
197
|
+
levels: ["stylesheet"],
|
|
198
|
+
coveredBy: ["qti-width-full"],
|
|
199
|
+
rationale:
|
|
200
|
+
"Both width aliases share the same CSS declaration block; qti-width-full covers the rendered width behavior.",
|
|
201
|
+
matches: (className) => contentWidthAliasPattern.test(className),
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
id: "content-text-indent-generated-variants",
|
|
205
|
+
levels: ["stylesheet"],
|
|
206
|
+
coveredBy: ["qti-text-indent-2"],
|
|
207
|
+
rationale:
|
|
208
|
+
"Text-indent classes are generated from the same suffix-to-length table; qti-text-indent-2 covers non-zero indentation.",
|
|
209
|
+
matches: (className) => contentTextIndentPattern.test(className),
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
id: "content-writing-mode-generated-variants",
|
|
213
|
+
levels: ["stylesheet"],
|
|
214
|
+
coveredBy: ["qti-writing-mode-vertical-rl"],
|
|
215
|
+
rationale:
|
|
216
|
+
"Writing-mode classes are direct CSS value mappings; vertical-rl covers the non-default vertical rendering path.",
|
|
217
|
+
matches: (className) => contentWritingModePattern.test(className),
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
id: "content-float-generated-variants",
|
|
221
|
+
levels: ["stylesheet"],
|
|
222
|
+
coveredBy: ["qti-float-left"],
|
|
223
|
+
rationale:
|
|
224
|
+
"Float and clear classes are direct CSS value mappings; qti-float-left covers the floated rendering path.",
|
|
225
|
+
matches: (className) => contentFloatPattern.test(className),
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
id: "content-list-style-generated-variants",
|
|
229
|
+
levels: ["stylesheet"],
|
|
230
|
+
coveredBy: ["qti-list-style-type-square"],
|
|
231
|
+
rationale:
|
|
232
|
+
"List-style classes are generated from the shared vocabulary token table; square covers the generated list-style-type rule.",
|
|
233
|
+
matches: (className) => contentListStylePattern.test(className),
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
id: "choice-writing-orientation-generated-variants",
|
|
237
|
+
levels: ["stylesheet"],
|
|
238
|
+
coveredBy: ["qti-writing-orientation-vertical-rl"],
|
|
239
|
+
rationale:
|
|
240
|
+
"Choice writing-orientation classes share the vertical choice layout path; vertical-rl covers the orientation rule and upright labels.",
|
|
241
|
+
matches: (className) => choiceWritingOrientationPattern.test(className),
|
|
242
|
+
},
|
|
243
|
+
];
|
|
244
|
+
|
|
245
|
+
export function matrixCoverageFamilyForClass(
|
|
246
|
+
className: string,
|
|
247
|
+
level: SharedVocabularySupportLevel,
|
|
248
|
+
): SharedVocabularyMatrixCoverageFamily | undefined {
|
|
249
|
+
return sharedVocabularyMatrixCoverageFamilies.find(
|
|
250
|
+
(family) => family.levels.includes(level) && family.matches(className),
|
|
251
|
+
);
|
|
252
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { SharedVocabularyClassSupport } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export type SharedVocabularySupportLevel = SharedVocabularyClassSupport["level"];
|
|
4
|
+
|
|
5
|
+
export function isEnforcedSharedVocabularyLevel(level: SharedVocabularySupportLevel): boolean {
|
|
6
|
+
return level !== "pass-through";
|
|
7
|
+
}
|