@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.
Files changed (50) hide show
  1. package/dist/index.d.ts +6 -1
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +5 -0
  4. package/dist/index.js.map +1 -1
  5. package/dist/parser.js +13 -1
  6. package/dist/parser.js.map +1 -1
  7. package/dist/shared-vocabulary-coverage-policy.d.ts +10 -0
  8. package/dist/shared-vocabulary-coverage-policy.d.ts.map +1 -0
  9. package/dist/shared-vocabulary-coverage-policy.js +61 -0
  10. package/dist/shared-vocabulary-coverage-policy.js.map +1 -0
  11. package/dist/shared-vocabulary-generated-families.d.ts +21 -0
  12. package/dist/shared-vocabulary-generated-families.d.ts.map +1 -0
  13. package/dist/shared-vocabulary-generated-families.js +194 -0
  14. package/dist/shared-vocabulary-generated-families.js.map +1 -0
  15. package/dist/shared-vocabulary-levels.d.ts +4 -0
  16. package/dist/shared-vocabulary-levels.d.ts.map +1 -0
  17. package/dist/shared-vocabulary-levels.js +4 -0
  18. package/dist/shared-vocabulary-levels.js.map +1 -0
  19. package/dist/shared-vocabulary-support.d.ts +3 -0
  20. package/dist/shared-vocabulary-support.d.ts.map +1 -0
  21. package/dist/shared-vocabulary-support.js +208 -0
  22. package/dist/shared-vocabulary-support.js.map +1 -0
  23. package/dist/shared-vocabulary-validation.d.ts +40 -0
  24. package/dist/shared-vocabulary-validation.d.ts.map +1 -0
  25. package/dist/shared-vocabulary-validation.js +108 -0
  26. package/dist/shared-vocabulary-validation.js.map +1 -0
  27. package/dist/shared-vocabulary.d.ts +35 -0
  28. package/dist/shared-vocabulary.d.ts.map +1 -0
  29. package/dist/shared-vocabulary.js +128 -0
  30. package/dist/shared-vocabulary.js.map +1 -0
  31. package/dist/types.d.ts +10 -0
  32. package/dist/types.d.ts.map +1 -1
  33. package/dist/validation.d.ts.map +1 -1
  34. package/dist/validation.js +364 -0
  35. package/dist/validation.js.map +1 -1
  36. package/dist/xml.d.ts.map +1 -1
  37. package/dist/xml.js +100 -0
  38. package/dist/xml.js.map +1 -1
  39. package/package.json +1 -1
  40. package/src/index.ts +56 -0
  41. package/src/parser.ts +17 -1
  42. package/src/shared-vocabulary-coverage-policy.ts +79 -0
  43. package/src/shared-vocabulary-generated-families.ts +252 -0
  44. package/src/shared-vocabulary-levels.ts +7 -0
  45. package/src/shared-vocabulary-support.ts +340 -0
  46. package/src/shared-vocabulary-validation.ts +182 -0
  47. package/src/shared-vocabulary.ts +177 -0
  48. package/src/types.ts +11 -0
  49. package/src/validation.ts +466 -0
  50. 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: textContent(choice) || identifier || `Choice ${index + 1}`,
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
+ }