@longsightgroup/qti3-core 0.5.4 → 0.7.0
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 +2 -2
- 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/types.ts
CHANGED
|
@@ -139,6 +139,7 @@ export interface QtiTemplateDeclaration extends QtiVariableDeclaration {
|
|
|
139
139
|
export interface QtiChoice {
|
|
140
140
|
identifier: string;
|
|
141
141
|
text: string;
|
|
142
|
+
asset?: QtiObjectAsset | undefined;
|
|
142
143
|
role: QtiChoiceRole;
|
|
143
144
|
qtiName: string;
|
|
144
145
|
attributes: Record<string, string>;
|
|
@@ -704,3 +705,13 @@ export interface QtiInteractionElementSupport extends QtiElementSupportBase {
|
|
|
704
705
|
export interface QtiProcessingElementSupport extends QtiElementSupportBase {
|
|
705
706
|
category: "processing";
|
|
706
707
|
}
|
|
708
|
+
|
|
709
|
+
export interface SharedVocabularyClassSupport {
|
|
710
|
+
className: string;
|
|
711
|
+
scope: "interaction" | "content" | "gap";
|
|
712
|
+
interactions?: QtiInteractionType[] | undefined;
|
|
713
|
+
level: "full" | "stylesheet" | "pass-through" | "conditional";
|
|
714
|
+
fixtures?: string[] | undefined;
|
|
715
|
+
tests?: string[] | undefined;
|
|
716
|
+
notes?: string | undefined;
|
|
717
|
+
}
|
package/src/validation.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
QtiCatalogCardEntry,
|
|
6
6
|
QtiCardinality,
|
|
7
7
|
QtiChoice,
|
|
8
|
+
QtiElementContent,
|
|
8
9
|
QtiContentNode,
|
|
9
10
|
QtiDiagnostic,
|
|
10
11
|
QtiDocument,
|
|
@@ -20,6 +21,11 @@ import type {
|
|
|
20
21
|
QtiValue,
|
|
21
22
|
QtiValidationResult,
|
|
22
23
|
} from "./types.js";
|
|
24
|
+
import {
|
|
25
|
+
validateSharedVocabularyExtendedText,
|
|
26
|
+
validateSharedVocabularyInputWidth,
|
|
27
|
+
validateSharedVocabularyMediaPlayerControls,
|
|
28
|
+
} from "./shared-vocabulary-validation.js";
|
|
23
29
|
import { validateQtiDataSsmlMetadata } from "./tts.js";
|
|
24
30
|
import { qtiValueToStringList } from "./value-format.js";
|
|
25
31
|
|
|
@@ -32,6 +38,7 @@ export function validateAssessmentItem(document: QtiDocument): QtiValidationResu
|
|
|
32
38
|
requireIdentifier("qti-assessment-item", item.identifier, diagnostics, item.source);
|
|
33
39
|
validateAssessmentItemRoot(item, diagnostics);
|
|
34
40
|
validateItemBody(item, diagnostics);
|
|
41
|
+
validateItemBodySharedVocabulary(item, diagnostics);
|
|
35
42
|
validateDeclarationIdentifiers(item, diagnostics);
|
|
36
43
|
validateOutcomeLookupTables(item, diagnostics);
|
|
37
44
|
validateInteractions(item, diagnostics);
|
|
@@ -194,6 +201,73 @@ function validateItemBody(item: QtiAssessmentItem, diagnostics: QtiDiagnostic[])
|
|
|
194
201
|
});
|
|
195
202
|
}
|
|
196
203
|
|
|
204
|
+
function validateItemBodySharedVocabulary(
|
|
205
|
+
item: QtiAssessmentItem,
|
|
206
|
+
diagnostics: QtiDiagnostic[],
|
|
207
|
+
): void {
|
|
208
|
+
validateContentSharedVocabulary(item.body, diagnostics);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function validateContentSharedVocabulary(
|
|
212
|
+
nodes: QtiContentNode[],
|
|
213
|
+
diagnostics: QtiDiagnostic[],
|
|
214
|
+
): void {
|
|
215
|
+
for (const node of nodes) {
|
|
216
|
+
if (node.kind !== "element") continue;
|
|
217
|
+
validateLayoutVocabularyClasses(node, diagnostics);
|
|
218
|
+
if (sharedClassNames(node.attributes).includes("qti-layout-row")) {
|
|
219
|
+
validateLayoutRow(node, diagnostics);
|
|
220
|
+
}
|
|
221
|
+
validateContentSharedVocabulary(node.children, diagnostics);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function validateLayoutVocabularyClasses(
|
|
226
|
+
node: QtiElementContent,
|
|
227
|
+
diagnostics: QtiDiagnostic[],
|
|
228
|
+
): void {
|
|
229
|
+
const classNames = sharedClassNames(node.attributes);
|
|
230
|
+
for (const className of classNames) {
|
|
231
|
+
if (isLayoutColumnClassName(className) && layoutColumnValue(className) === undefined) {
|
|
232
|
+
diagnostics.push({
|
|
233
|
+
code: "item.sharedVocabulary.layoutColumnInvalid",
|
|
234
|
+
severity: "warning",
|
|
235
|
+
message: `Shared vocabulary class ${className} is not supported; expected qti-layout-col1 through qti-layout-col12, or dashed qti-layout-col-1 through qti-layout-col-12.`,
|
|
236
|
+
path: node.source?.path,
|
|
237
|
+
source: node.source,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
if (isLayoutOffsetClassName(className) && layoutOffsetValue(className) === undefined) {
|
|
241
|
+
diagnostics.push({
|
|
242
|
+
code: "item.sharedVocabulary.layoutOffsetInvalid",
|
|
243
|
+
severity: "warning",
|
|
244
|
+
message: `Shared vocabulary class ${className} is not supported; expected qti-layout-offset1 through qti-layout-offset11, or dashed qti-layout-offset-1 through qti-layout-offset-11.`,
|
|
245
|
+
path: node.source?.path,
|
|
246
|
+
source: node.source,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function validateLayoutRow(node: QtiElementContent, diagnostics: QtiDiagnostic[]): void {
|
|
253
|
+
let totalColumns = 0;
|
|
254
|
+
for (const child of node.children) {
|
|
255
|
+
if (child.kind !== "element") continue;
|
|
256
|
+
const classNames = sharedClassNames(child.attributes);
|
|
257
|
+
const column = firstLayoutColumnValue(classNames);
|
|
258
|
+
if (column === undefined) continue;
|
|
259
|
+
totalColumns += (firstLayoutOffsetValue(classNames) ?? 0) + column;
|
|
260
|
+
}
|
|
261
|
+
if (totalColumns <= 12) return;
|
|
262
|
+
diagnostics.push({
|
|
263
|
+
code: "item.sharedVocabulary.layoutRowOverflow",
|
|
264
|
+
severity: "warning",
|
|
265
|
+
message: `qti-layout-row column groupings plus offsets total ${totalColumns}; the QTI shared vocabulary grid allows at most twelve columns per row.`,
|
|
266
|
+
path: node.source?.path,
|
|
267
|
+
source: node.source,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
197
271
|
function requireIdentifier(
|
|
198
272
|
elementName: string,
|
|
199
273
|
identifier: string | undefined,
|
|
@@ -1592,6 +1666,7 @@ function validateInteractions(item: QtiAssessmentItem, diagnostics: QtiDiagnosti
|
|
|
1592
1666
|
for (const interaction of item.interactions) {
|
|
1593
1667
|
validateInteractionResponseReference(interaction, responseIdentifiers, diagnostics);
|
|
1594
1668
|
validateInteractionResponseShape(interaction, diagnostics);
|
|
1669
|
+
validateInteractionSharedVocabulary(interaction, diagnostics);
|
|
1595
1670
|
validateInteractionChoices(interaction, diagnostics);
|
|
1596
1671
|
validateInteractionChildren(interaction, diagnostics);
|
|
1597
1672
|
validateInteractionRequiredAttributes(interaction, diagnostics);
|
|
@@ -1615,6 +1690,385 @@ function validateInteractions(item: QtiAssessmentItem, diagnostics: QtiDiagnosti
|
|
|
1615
1690
|
}
|
|
1616
1691
|
}
|
|
1617
1692
|
|
|
1693
|
+
function validateInteractionSharedVocabulary(
|
|
1694
|
+
interaction: QtiInteraction,
|
|
1695
|
+
diagnostics: QtiDiagnostic[],
|
|
1696
|
+
): void {
|
|
1697
|
+
if (
|
|
1698
|
+
interaction.type !== "choice" &&
|
|
1699
|
+
interaction.type !== "match" &&
|
|
1700
|
+
interaction.type !== "gapMatch" &&
|
|
1701
|
+
interaction.type !== "graphicGapMatch" &&
|
|
1702
|
+
interaction.type !== "inlineChoice" &&
|
|
1703
|
+
interaction.type !== "textEntry" &&
|
|
1704
|
+
interaction.type !== "extendedText" &&
|
|
1705
|
+
interaction.type !== "media" &&
|
|
1706
|
+
interaction.type !== "order"
|
|
1707
|
+
) {
|
|
1708
|
+
return;
|
|
1709
|
+
}
|
|
1710
|
+
const classNames = sharedClassNames(interaction.attributes);
|
|
1711
|
+
const classNameSet = new Set(classNames);
|
|
1712
|
+
if (interaction.type === "media") {
|
|
1713
|
+
validateMediaInteractionSharedVocabulary(interaction, diagnostics);
|
|
1714
|
+
return;
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
if (interaction.type === "inlineChoice" || interaction.type === "textEntry") {
|
|
1718
|
+
validateInteractionInputWidthSharedVocabulary(interaction, classNames, diagnostics);
|
|
1719
|
+
return;
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
if (interaction.type === "extendedText") {
|
|
1723
|
+
diagnostics.push(
|
|
1724
|
+
...validateSharedVocabularyExtendedText({
|
|
1725
|
+
classNames,
|
|
1726
|
+
subjectQtiName: interaction.qtiName,
|
|
1727
|
+
path: interaction.source?.path,
|
|
1728
|
+
source: interaction.source,
|
|
1729
|
+
}),
|
|
1730
|
+
);
|
|
1731
|
+
return;
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
if (interaction.type === "choice" || interaction.type === "order") {
|
|
1735
|
+
validateSharedVocabularyLabelClasses(interaction, classNames, diagnostics);
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
if (
|
|
1739
|
+
interaction.type === "match" ||
|
|
1740
|
+
interaction.type === "gapMatch" ||
|
|
1741
|
+
interaction.type === "graphicGapMatch"
|
|
1742
|
+
) {
|
|
1743
|
+
if (interaction.type === "match") {
|
|
1744
|
+
validateMatchInteractionSharedVocabulary(interaction, classNames, diagnostics);
|
|
1745
|
+
}
|
|
1746
|
+
validateChoicesPositionSharedVocabulary(interaction, classNames, diagnostics);
|
|
1747
|
+
validateChoicesContainerWidthSharedVocabulary(interaction, diagnostics);
|
|
1748
|
+
if (interaction.type === "gapMatch") {
|
|
1749
|
+
validateGapInputWidthSharedVocabulary(interaction, diagnostics);
|
|
1750
|
+
}
|
|
1751
|
+
return;
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
if (interaction.type === "order") {
|
|
1755
|
+
validateOrderInteractionSharedVocabulary(interaction, classNames, classNameSet, diagnostics);
|
|
1756
|
+
return;
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
validateOrientationSharedVocabulary(interaction, classNameSet, diagnostics);
|
|
1760
|
+
|
|
1761
|
+
const validStackingClasses = new Set<string>();
|
|
1762
|
+
const invalidStackingClasses = new Set<string>();
|
|
1763
|
+
for (const className of classNames) {
|
|
1764
|
+
const stacking = /^qti-choices-stacking-(\d+)$/.exec(className)?.[1];
|
|
1765
|
+
if (stacking === undefined) continue;
|
|
1766
|
+
const count = Number(stacking);
|
|
1767
|
+
if (count >= 1 && count <= 5) validStackingClasses.add(className);
|
|
1768
|
+
else invalidStackingClasses.add(className);
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
if (validStackingClasses.size > 1) {
|
|
1772
|
+
diagnostics.push({
|
|
1773
|
+
code: "interaction.sharedVocabulary.stackingConflict",
|
|
1774
|
+
severity: "warning",
|
|
1775
|
+
message: `qti-choice-interaction should not include multiple qti-choices-stacking-* classes: ${[...validStackingClasses].join(", ")}. The first valid stacking class in class attribute order takes precedence at runtime.`,
|
|
1776
|
+
path: interaction.source?.path,
|
|
1777
|
+
source: interaction.source,
|
|
1778
|
+
});
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
for (const className of invalidStackingClasses) {
|
|
1782
|
+
diagnostics.push({
|
|
1783
|
+
code: "interaction.sharedVocabulary.stackingInvalid",
|
|
1784
|
+
severity: "warning",
|
|
1785
|
+
message: `qti-choice-interaction shared vocabulary class ${className} is not supported; expected qti-choices-stacking-1 through qti-choices-stacking-5.`,
|
|
1786
|
+
path: interaction.source?.path,
|
|
1787
|
+
source: interaction.source,
|
|
1788
|
+
});
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
function validateSharedVocabularyLabelClasses(
|
|
1793
|
+
interaction: QtiInteraction,
|
|
1794
|
+
classNames: string[],
|
|
1795
|
+
diagnostics: QtiDiagnostic[],
|
|
1796
|
+
): void {
|
|
1797
|
+
const labelClasses = classNames.filter((className) =>
|
|
1798
|
+
[
|
|
1799
|
+
"qti-labels-decimal",
|
|
1800
|
+
"qti-labels-cjk-ideographic",
|
|
1801
|
+
"qti-labels-lower-alpha",
|
|
1802
|
+
"qti-labels-upper-alpha",
|
|
1803
|
+
].includes(className),
|
|
1804
|
+
);
|
|
1805
|
+
if (new Set(labelClasses).size > 1) {
|
|
1806
|
+
diagnostics.push({
|
|
1807
|
+
code: "interaction.sharedVocabulary.labelsConflict",
|
|
1808
|
+
severity: "warning",
|
|
1809
|
+
message: `${interaction.qtiName} should not include multiple qti-labels-* style classes: ${[...new Set(labelClasses)].join(", ")}. qti-labels-decimal takes precedence over qti-labels-cjk-ideographic, then qti-labels-lower-alpha, then qti-labels-upper-alpha at runtime.`,
|
|
1810
|
+
path: interaction.source?.path,
|
|
1811
|
+
source: interaction.source,
|
|
1812
|
+
});
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
const suffixClasses = classNames.filter((className) =>
|
|
1816
|
+
[
|
|
1817
|
+
"qti-labels-suffix-none",
|
|
1818
|
+
"qti-labels-suffix-period",
|
|
1819
|
+
"qti-labels-suffix-parenthesis",
|
|
1820
|
+
].includes(className),
|
|
1821
|
+
);
|
|
1822
|
+
if (new Set(suffixClasses).size <= 1) return;
|
|
1823
|
+
diagnostics.push({
|
|
1824
|
+
code: "interaction.sharedVocabulary.labelSuffixConflict",
|
|
1825
|
+
severity: "warning",
|
|
1826
|
+
message: `${interaction.qtiName} should not include multiple qti-labels-suffix-* classes: ${[...new Set(suffixClasses)].join(", ")}. qti-labels-suffix-none takes precedence over qti-labels-suffix-period, then qti-labels-suffix-parenthesis at runtime.`,
|
|
1827
|
+
path: interaction.source?.path,
|
|
1828
|
+
source: interaction.source,
|
|
1829
|
+
});
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
function validateOrientationSharedVocabulary(
|
|
1833
|
+
interaction: QtiInteraction,
|
|
1834
|
+
classNameSet: Set<string>,
|
|
1835
|
+
diagnostics: QtiDiagnostic[],
|
|
1836
|
+
): void {
|
|
1837
|
+
if (
|
|
1838
|
+
!classNameSet.has("qti-orientation-horizontal") ||
|
|
1839
|
+
!classNameSet.has("qti-orientation-vertical")
|
|
1840
|
+
) {
|
|
1841
|
+
return;
|
|
1842
|
+
}
|
|
1843
|
+
diagnostics.push({
|
|
1844
|
+
code: "interaction.sharedVocabulary.orientationConflict",
|
|
1845
|
+
severity: "warning",
|
|
1846
|
+
message: `${interaction.qtiName} should not include both qti-orientation-horizontal and qti-orientation-vertical; qti-orientation-horizontal takes precedence at runtime.`,
|
|
1847
|
+
path: interaction.source?.path,
|
|
1848
|
+
source: interaction.source,
|
|
1849
|
+
});
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
function validateOrderInteractionSharedVocabulary(
|
|
1853
|
+
interaction: QtiInteraction,
|
|
1854
|
+
classNames: string[],
|
|
1855
|
+
classNameSet: Set<string>,
|
|
1856
|
+
diagnostics: QtiDiagnostic[],
|
|
1857
|
+
): void {
|
|
1858
|
+
validateOrientationSharedVocabulary(interaction, classNameSet, diagnostics);
|
|
1859
|
+
validateChoicesPositionSharedVocabulary(interaction, classNames, diagnostics);
|
|
1860
|
+
validateChoicesContainerWidthSharedVocabulary(interaction, diagnostics);
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
const SHARED_VOCABULARY_CHOICES_POSITION_CLASSES = [
|
|
1864
|
+
"qti-choices-top",
|
|
1865
|
+
"qti-choices-bottom",
|
|
1866
|
+
"qti-choices-left",
|
|
1867
|
+
"qti-choices-right",
|
|
1868
|
+
] as const;
|
|
1869
|
+
|
|
1870
|
+
function validateMatchInteractionSharedVocabulary(
|
|
1871
|
+
interaction: QtiInteraction,
|
|
1872
|
+
classNames: string[],
|
|
1873
|
+
diagnostics: QtiDiagnostic[],
|
|
1874
|
+
): void {
|
|
1875
|
+
const hasTabular = classNames.includes("qti-match-tabular");
|
|
1876
|
+
const hasHeaderHidden = classNames.includes("qti-header-hidden");
|
|
1877
|
+
const firstColumnHeader = interaction.attributes["data-first-column-header"];
|
|
1878
|
+
if (!hasTabular && (hasHeaderHidden || firstColumnHeader !== undefined)) {
|
|
1879
|
+
diagnostics.push({
|
|
1880
|
+
code: "interaction.sharedVocabulary.matchTabularContext",
|
|
1881
|
+
severity: "warning",
|
|
1882
|
+
message:
|
|
1883
|
+
"qti-match-interaction shared vocabulary class qti-header-hidden and data-first-column-header are only relevant when qti-match-tabular is specified; they are ignored at runtime.",
|
|
1884
|
+
path: interaction.source?.path,
|
|
1885
|
+
source: interaction.source,
|
|
1886
|
+
});
|
|
1887
|
+
}
|
|
1888
|
+
if (hasTabular && hasHeaderHidden && firstColumnHeader !== undefined) {
|
|
1889
|
+
diagnostics.push({
|
|
1890
|
+
code: "interaction.sharedVocabulary.matchTabularHeaderHidden",
|
|
1891
|
+
severity: "warning",
|
|
1892
|
+
message:
|
|
1893
|
+
"qti-match-interaction data-first-column-header is ignored when qti-header-hidden suppresses the tabular header row.",
|
|
1894
|
+
path: interaction.source?.path,
|
|
1895
|
+
source: interaction.source,
|
|
1896
|
+
});
|
|
1897
|
+
}
|
|
1898
|
+
if (!hasTabular) return;
|
|
1899
|
+
|
|
1900
|
+
const choicesPositionClasses = classNames.filter((className) =>
|
|
1901
|
+
SHARED_VOCABULARY_CHOICES_POSITION_CLASSES.includes(
|
|
1902
|
+
className as (typeof SHARED_VOCABULARY_CHOICES_POSITION_CLASSES)[number],
|
|
1903
|
+
),
|
|
1904
|
+
);
|
|
1905
|
+
if (
|
|
1906
|
+
choicesPositionClasses.length > 0 ||
|
|
1907
|
+
interaction.attributes["data-choices-container-width"] !== undefined
|
|
1908
|
+
) {
|
|
1909
|
+
diagnostics.push({
|
|
1910
|
+
code: "interaction.sharedVocabulary.matchTabularChoicesConflict",
|
|
1911
|
+
severity: "warning",
|
|
1912
|
+
message:
|
|
1913
|
+
"qti-match-interaction qti-match-tabular uses a matrix layout; qti-choices-* position classes and data-choices-container-width are ignored at runtime.",
|
|
1914
|
+
path: interaction.source?.path,
|
|
1915
|
+
source: interaction.source,
|
|
1916
|
+
});
|
|
1917
|
+
}
|
|
1918
|
+
if (!hasHeaderHidden && (firstColumnHeader === undefined || firstColumnHeader === "")) {
|
|
1919
|
+
diagnostics.push({
|
|
1920
|
+
code: "interaction.sharedVocabulary.matchTabularFirstColumnHeader",
|
|
1921
|
+
severity: "warning",
|
|
1922
|
+
message:
|
|
1923
|
+
"qti-match-interaction with qti-match-tabular should specify data-first-column-header for the top-left table header when the tabular header row is shown.",
|
|
1924
|
+
path: interaction.source?.path,
|
|
1925
|
+
source: interaction.source,
|
|
1926
|
+
});
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
function validateChoicesPositionSharedVocabulary(
|
|
1931
|
+
interaction: QtiInteraction,
|
|
1932
|
+
classNames: string[],
|
|
1933
|
+
diagnostics: QtiDiagnostic[],
|
|
1934
|
+
): void {
|
|
1935
|
+
const choicesPositionClasses = classNames.filter((className) =>
|
|
1936
|
+
SHARED_VOCABULARY_CHOICES_POSITION_CLASSES.includes(
|
|
1937
|
+
className as (typeof SHARED_VOCABULARY_CHOICES_POSITION_CLASSES)[number],
|
|
1938
|
+
),
|
|
1939
|
+
);
|
|
1940
|
+
if (new Set(choicesPositionClasses).size > 1) {
|
|
1941
|
+
diagnostics.push({
|
|
1942
|
+
code: "interaction.sharedVocabulary.orderChoicesPositionConflict",
|
|
1943
|
+
severity: "warning",
|
|
1944
|
+
message: `${interaction.qtiName} should not include multiple qti-choices-* position classes: ${[...new Set(choicesPositionClasses)].join(", ")}. The first position class in class attribute order takes precedence at runtime.`,
|
|
1945
|
+
path: interaction.source?.path,
|
|
1946
|
+
source: interaction.source,
|
|
1947
|
+
});
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
function validateChoicesContainerWidthSharedVocabulary(
|
|
1952
|
+
interaction: QtiInteraction,
|
|
1953
|
+
diagnostics: QtiDiagnostic[],
|
|
1954
|
+
): void {
|
|
1955
|
+
const width = interaction.attributes["data-choices-container-width"];
|
|
1956
|
+
if (width === undefined) return;
|
|
1957
|
+
const parsed = Number(width);
|
|
1958
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
1959
|
+
diagnostics.push({
|
|
1960
|
+
code: "interaction.sharedVocabulary.orderChoicesContainerWidth",
|
|
1961
|
+
severity: "warning",
|
|
1962
|
+
message: `${interaction.qtiName} data-choices-container-width must be a positive pixel value; the invalid value is ignored at runtime.`,
|
|
1963
|
+
path: interaction.source?.path,
|
|
1964
|
+
source: interaction.source,
|
|
1965
|
+
});
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
function validateGapInputWidthSharedVocabulary(
|
|
1970
|
+
interaction: QtiInteraction,
|
|
1971
|
+
diagnostics: QtiDiagnostic[],
|
|
1972
|
+
): void {
|
|
1973
|
+
for (const gap of interaction.choices.filter((choice) => choice.qtiName === "qti-gap")) {
|
|
1974
|
+
diagnostics.push(
|
|
1975
|
+
...validateSharedVocabularyInputWidth({
|
|
1976
|
+
classNames: sharedClassNames(gap.attributes),
|
|
1977
|
+
subjectQtiName: "qti-gap",
|
|
1978
|
+
path: gap.source?.path ?? interaction.source?.path,
|
|
1979
|
+
source: gap.source ?? interaction.source,
|
|
1980
|
+
conflictCode: "interaction.sharedVocabulary.gapInputWidthConflict",
|
|
1981
|
+
invalidCode: "interaction.sharedVocabulary.gapInputWidthInvalid",
|
|
1982
|
+
}),
|
|
1983
|
+
);
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
function validateInteractionInputWidthSharedVocabulary(
|
|
1988
|
+
interaction: QtiInteraction,
|
|
1989
|
+
classNames: string[],
|
|
1990
|
+
diagnostics: QtiDiagnostic[],
|
|
1991
|
+
): void {
|
|
1992
|
+
diagnostics.push(
|
|
1993
|
+
...validateSharedVocabularyInputWidth({
|
|
1994
|
+
classNames,
|
|
1995
|
+
subjectQtiName: interaction.qtiName,
|
|
1996
|
+
path: interaction.source?.path,
|
|
1997
|
+
source: interaction.source,
|
|
1998
|
+
conflictCode: "interaction.sharedVocabulary.inputWidthConflict",
|
|
1999
|
+
invalidCode: "interaction.sharedVocabulary.inputWidthInvalid",
|
|
2000
|
+
}),
|
|
2001
|
+
);
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
function validateMediaInteractionSharedVocabulary(
|
|
2005
|
+
interaction: QtiInteraction,
|
|
2006
|
+
diagnostics: QtiDiagnostic[],
|
|
2007
|
+
): void {
|
|
2008
|
+
diagnostics.push(
|
|
2009
|
+
...validateSharedVocabularyMediaPlayerControls({
|
|
2010
|
+
value: interaction.attributes["data-qti-media-player-controls"],
|
|
2011
|
+
subjectQtiName: interaction.qtiName,
|
|
2012
|
+
path: interaction.source?.path,
|
|
2013
|
+
source: interaction.source,
|
|
2014
|
+
}),
|
|
2015
|
+
);
|
|
2016
|
+
|
|
2017
|
+
if (!interaction.object) return;
|
|
2018
|
+
diagnostics.push(
|
|
2019
|
+
...validateSharedVocabularyMediaPlayerControls({
|
|
2020
|
+
value: interaction.object.attributes["data-qti-media-player-controls"],
|
|
2021
|
+
subjectQtiName: `${interaction.qtiName} media object`,
|
|
2022
|
+
path: interaction.object.source?.path ?? interaction.source?.path,
|
|
2023
|
+
source: interaction.object.source ?? interaction.source,
|
|
2024
|
+
}),
|
|
2025
|
+
);
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
function sharedClassNames(attributes: Record<string, string>): string[] {
|
|
2029
|
+
return (attributes.class ?? "").split(/\s+/).filter(Boolean);
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
function isLayoutColumnClassName(className: string): boolean {
|
|
2033
|
+
return /^qti-layout-col-?\w+$/.test(className);
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
function layoutColumnValue(className: string): number | undefined {
|
|
2037
|
+
const rawValue = /^qti-layout-col-?(\d+)$/.exec(className)?.[1];
|
|
2038
|
+
if (rawValue === undefined) return undefined;
|
|
2039
|
+
const value = Number(rawValue);
|
|
2040
|
+
if (value < 1 || value > 12) return undefined;
|
|
2041
|
+
return value;
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
function firstLayoutColumnValue(classNames: string[]): number | undefined {
|
|
2045
|
+
for (const className of classNames) {
|
|
2046
|
+
const value = layoutColumnValue(className);
|
|
2047
|
+
if (value !== undefined) return value;
|
|
2048
|
+
}
|
|
2049
|
+
return undefined;
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
function isLayoutOffsetClassName(className: string): boolean {
|
|
2053
|
+
return /^qti-layout-offset-?\w+$/.test(className);
|
|
2054
|
+
}
|
|
2055
|
+
|
|
2056
|
+
function layoutOffsetValue(className: string): number | undefined {
|
|
2057
|
+
const rawValue = /^qti-layout-offset-?(\d+)$/.exec(className)?.[1];
|
|
2058
|
+
if (rawValue === undefined) return undefined;
|
|
2059
|
+
const value = Number(rawValue);
|
|
2060
|
+
if (value < 1 || value > 11) return undefined;
|
|
2061
|
+
return value;
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
function firstLayoutOffsetValue(classNames: string[]): number | undefined {
|
|
2065
|
+
for (const className of classNames) {
|
|
2066
|
+
const value = layoutOffsetValue(className);
|
|
2067
|
+
if (value !== undefined) return value;
|
|
2068
|
+
}
|
|
2069
|
+
return undefined;
|
|
2070
|
+
}
|
|
2071
|
+
|
|
1618
2072
|
function validateInteractionResponseReference(
|
|
1619
2073
|
interaction: QtiInteraction,
|
|
1620
2074
|
responseIdentifiers: Set<string>,
|
|
@@ -2108,9 +2562,21 @@ function validateChoiceLimitAttributes(choice: QtiChoice, diagnostics: QtiDiagno
|
|
|
2108
2562
|
validateChoiceNonNegativeIntegerAttribute(choice, "match-max", diagnostics);
|
|
2109
2563
|
validateChoiceNonNegativeIntegerAttribute(choice, "match-min", diagnostics);
|
|
2110
2564
|
validateChoiceMinMaxPair(choice, "match-min", "match-max", diagnostics);
|
|
2565
|
+
validateGapImageAsset(choice, diagnostics);
|
|
2111
2566
|
validateHotspotGeometry(choice, diagnostics);
|
|
2112
2567
|
}
|
|
2113
2568
|
|
|
2569
|
+
function validateGapImageAsset(choice: QtiChoice, diagnostics: QtiDiagnostic[]): void {
|
|
2570
|
+
if (choice.qtiName !== "qti-gap-img" || choice.asset?.data) return;
|
|
2571
|
+
diagnostics.push({
|
|
2572
|
+
code: "choice.gapImg.media.required",
|
|
2573
|
+
severity: "error",
|
|
2574
|
+
message: `qti-gap-img ${choice.identifier} requires an img, object, or picture child with a usable src or data attribute.`,
|
|
2575
|
+
path: choice.source?.path,
|
|
2576
|
+
source: choice.source,
|
|
2577
|
+
});
|
|
2578
|
+
}
|
|
2579
|
+
|
|
2114
2580
|
function requiresMatchMax(choice: QtiChoice): boolean {
|
|
2115
2581
|
return (
|
|
2116
2582
|
choice.qtiName === "qti-simple-associable-choice" ||
|
package/src/xml.ts
CHANGED
|
@@ -120,9 +120,116 @@ export function parseXmlTree(xml: string): { root: XmlNode | undefined; errors:
|
|
|
120
120
|
errors.push(new Error(`Unexpected end of document. Missing closing tag for <${node.name}>.`));
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
if (root) restoreMixedContentFromSource(xml, root);
|
|
124
|
+
|
|
123
125
|
return { root, errors };
|
|
124
126
|
}
|
|
125
127
|
|
|
128
|
+
/**
|
|
129
|
+
* stax-xml trims boundary whitespace around child elements. Re-slice mixed content from the
|
|
130
|
+
* original XML so authored spacing around inline markup (for example `<em>`) is preserved.
|
|
131
|
+
*/
|
|
132
|
+
const inlineMixedContentChildNames = new Set([
|
|
133
|
+
"a",
|
|
134
|
+
"abbr",
|
|
135
|
+
"b",
|
|
136
|
+
"bdi",
|
|
137
|
+
"bdo",
|
|
138
|
+
"cite",
|
|
139
|
+
"code",
|
|
140
|
+
"dfn",
|
|
141
|
+
"em",
|
|
142
|
+
"i",
|
|
143
|
+
"kbd",
|
|
144
|
+
"mark",
|
|
145
|
+
"q",
|
|
146
|
+
"rp",
|
|
147
|
+
"rt",
|
|
148
|
+
"ruby",
|
|
149
|
+
"s",
|
|
150
|
+
"samp",
|
|
151
|
+
"small",
|
|
152
|
+
"span",
|
|
153
|
+
"strong",
|
|
154
|
+
"sub",
|
|
155
|
+
"sup",
|
|
156
|
+
"var",
|
|
157
|
+
"qti-feedback-inline",
|
|
158
|
+
"qti-gap",
|
|
159
|
+
"qti-hottext",
|
|
160
|
+
"qti-inline-choice-interaction",
|
|
161
|
+
"qti-printed-variable",
|
|
162
|
+
"qti-template-inline",
|
|
163
|
+
"qti-text-entry-interaction",
|
|
164
|
+
]);
|
|
165
|
+
|
|
166
|
+
function shouldRestoreMixedContentWhitespace(node: XmlNode): boolean {
|
|
167
|
+
return node.content.some(
|
|
168
|
+
(entry) => typeof entry !== "string" && inlineMixedContentChildNames.has(entry.localName),
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function restoreMixedContentFromSource(xml: string, node: XmlNode): void {
|
|
173
|
+
for (const entry of node.content) {
|
|
174
|
+
if (typeof entry !== "string") restoreMixedContentFromSource(xml, entry);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (!shouldRestoreMixedContentWhitespace(node)) return;
|
|
178
|
+
|
|
179
|
+
const contentEndOffset = node.endSource?.offset ?? node.sourceRange.endOffset;
|
|
180
|
+
if (node.sourceRange.startTagEndOffset < 0 || contentEndOffset === undefined) return;
|
|
181
|
+
|
|
182
|
+
const restored: Array<string | XmlNode> = [];
|
|
183
|
+
let cursor = node.sourceRange.startTagEndOffset + 1;
|
|
184
|
+
|
|
185
|
+
for (const entry of node.content) {
|
|
186
|
+
if (typeof entry === "string") continue;
|
|
187
|
+
const childStart = entry.sourceRange.startOffset;
|
|
188
|
+
if (childStart < 0) continue;
|
|
189
|
+
if (childStart > cursor) {
|
|
190
|
+
appendDecodedTextSegment(restored, xml.slice(cursor, childStart));
|
|
191
|
+
}
|
|
192
|
+
restored.push(entry);
|
|
193
|
+
const childEnd = entry.sourceRange.endOffset;
|
|
194
|
+
if (childEnd === undefined || childEnd < cursor) continue;
|
|
195
|
+
cursor = childEnd;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (contentEndOffset > cursor) {
|
|
199
|
+
appendDecodedTextSegment(restored, xml.slice(cursor, contentEndOffset));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
node.content = restored;
|
|
203
|
+
node.text = restored.filter((entry): entry is string => typeof entry === "string").join("");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function appendDecodedTextSegment(content: Array<string | XmlNode>, raw: string): void {
|
|
207
|
+
const decoded = decodeXmlCharacterData(raw);
|
|
208
|
+
if (decoded.length > 0) content.push(decoded);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const predefinedXmlEntities: Record<string, string> = {
|
|
212
|
+
amp: "&",
|
|
213
|
+
apos: "'",
|
|
214
|
+
gt: ">",
|
|
215
|
+
lt: "<",
|
|
216
|
+
quot: '"',
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
function decodeXmlCharacterData(value: string): string {
|
|
220
|
+
return value.replace(/&(#x?[0-9a-fA-F]+|[A-Za-z]+);/g, (entity, body: string) => {
|
|
221
|
+
if (body.startsWith("#x") || body.startsWith("#X")) {
|
|
222
|
+
const codePoint = Number.parseInt(body.slice(2), 16);
|
|
223
|
+
return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : entity;
|
|
224
|
+
}
|
|
225
|
+
if (body.startsWith("#")) {
|
|
226
|
+
const codePoint = Number.parseInt(body.slice(1), 10);
|
|
227
|
+
return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : entity;
|
|
228
|
+
}
|
|
229
|
+
return predefinedXmlEntities[body] ?? entity;
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
126
233
|
export function childElements(node: XmlNode, localName?: string): XmlNode[] {
|
|
127
234
|
return node.children.filter((child) => !localName || child.localName === localName);
|
|
128
235
|
}
|