@longsightgroup/qti3-core 0.7.3 → 0.8.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/README.md +41 -10
- package/dist/content-text.d.ts +7 -1
- package/dist/content-text.d.ts.map +1 -1
- package/dist/content-text.js +14 -0
- package/dist/content-text.js.map +1 -1
- package/dist/index.d.ts +7 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -2
- package/dist/index.js.map +1 -1
- package/dist/package-xml.d.ts +12 -0
- package/dist/package-xml.d.ts.map +1 -0
- package/dist/package-xml.js +17 -0
- package/dist/package-xml.js.map +1 -0
- package/dist/parser-custom-interactions.d.ts +8 -0
- package/dist/parser-custom-interactions.d.ts.map +1 -0
- package/dist/parser-custom-interactions.js +178 -0
- package/dist/parser-custom-interactions.js.map +1 -0
- package/dist/parser-declarations.d.ts +6 -0
- package/dist/parser-declarations.d.ts.map +1 -0
- package/dist/parser-declarations.js +136 -0
- package/dist/parser-declarations.js.map +1 -0
- package/dist/parser-item-metadata.d.ts +9 -0
- package/dist/parser-item-metadata.d.ts.map +1 -0
- package/dist/parser-item-metadata.js +158 -0
- package/dist/parser-item-metadata.js.map +1 -0
- package/dist/parser-processing.d.ts +5 -0
- package/dist/parser-processing.d.ts.map +1 -0
- package/dist/parser-processing.js +642 -0
- package/dist/parser-processing.js.map +1 -0
- package/dist/parser-values.d.ts +8 -0
- package/dist/parser-values.d.ts.map +1 -0
- package/dist/parser-values.js +49 -0
- package/dist/parser-values.js.map +1 -0
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +15 -1083
- package/dist/parser.js.map +1 -1
- package/dist/processing-expression-tags.d.ts +56 -0
- package/dist/processing-expression-tags.d.ts.map +1 -0
- package/dist/processing-expression-tags.js +52 -0
- package/dist/processing-expression-tags.js.map +1 -0
- package/dist/processing-rules.d.ts +3 -0
- package/dist/processing-rules.d.ts.map +1 -0
- package/dist/processing-rules.js +10 -0
- package/dist/processing-rules.js.map +1 -0
- package/dist/serializer-processing-expressions.d.ts +8 -0
- package/dist/serializer-processing-expressions.d.ts.map +1 -0
- package/dist/serializer-processing-expressions.js +198 -0
- package/dist/serializer-processing-expressions.js.map +1 -0
- package/dist/serializer-processing-xml.d.ts +15 -0
- package/dist/serializer-processing-xml.d.ts.map +1 -0
- package/dist/serializer-processing-xml.js +46 -0
- package/dist/serializer-processing-xml.js.map +1 -0
- package/dist/serializer-processing.d.ts +8 -0
- package/dist/serializer-processing.d.ts.map +1 -0
- package/dist/serializer-processing.js +106 -0
- package/dist/serializer-processing.js.map +1 -0
- package/dist/shared-vocabulary-authoring.d.ts +52 -0
- package/dist/shared-vocabulary-authoring.d.ts.map +1 -0
- package/dist/shared-vocabulary-authoring.js +327 -0
- package/dist/shared-vocabulary-authoring.js.map +1 -0
- package/dist/shared-vocabulary-interaction-sets.d.ts +6 -0
- package/dist/shared-vocabulary-interaction-sets.d.ts.map +1 -0
- package/dist/shared-vocabulary-interaction-sets.js +23 -0
- package/dist/shared-vocabulary-interaction-sets.js.map +1 -0
- package/dist/shared-vocabulary-interaction-validation.d.ts +3 -0
- package/dist/shared-vocabulary-interaction-validation.d.ts.map +1 -0
- package/dist/shared-vocabulary-interaction-validation.js +99 -0
- package/dist/shared-vocabulary-interaction-validation.js.map +1 -0
- package/dist/shared-vocabulary-registry-validation.d.ts +5 -0
- package/dist/shared-vocabulary-registry-validation.d.ts.map +1 -0
- package/dist/shared-vocabulary-registry-validation.js +147 -0
- package/dist/shared-vocabulary-registry-validation.js.map +1 -0
- package/dist/shared-vocabulary-support.d.ts.map +1 -1
- package/dist/shared-vocabulary-support.js +160 -67
- package/dist/shared-vocabulary-support.js.map +1 -1
- package/dist/shared-vocabulary.d.ts +6 -0
- package/dist/shared-vocabulary.d.ts.map +1 -1
- package/dist/shared-vocabulary.js +12 -0
- package/dist/shared-vocabulary.js.map +1 -1
- package/dist/support.d.ts +4 -1
- package/dist/support.d.ts.map +1 -1
- package/dist/support.js +130 -0
- package/dist/support.js.map +1 -1
- package/dist/types.d.ts +32 -7
- package/dist/types.d.ts.map +1 -1
- package/dist/validation.d.ts.map +1 -1
- package/dist/validation.js +29 -250
- package/dist/validation.js.map +1 -1
- package/dist/xml.d.ts +10 -0
- package/dist/xml.d.ts.map +1 -1
- package/dist/xml.js +321 -272
- package/dist/xml.js.map +1 -1
- package/package.json +1 -4
- package/src/content-text.ts +20 -1
- package/src/index.ts +68 -0
- package/src/interaction-test-fixtures.ts +44 -0
- package/src/package-xml.ts +30 -0
- package/src/parser-custom-interactions.ts +309 -0
- package/src/parser-declarations.ts +174 -0
- package/src/parser-item-metadata.ts +194 -0
- package/src/parser-processing.ts +701 -0
- package/src/parser-values.ts +61 -0
- package/src/parser.ts +36 -1227
- package/src/processing-expression-tags.ts +65 -0
- package/src/processing-rules.ts +11 -0
- package/src/serializer-processing-expressions.ts +456 -0
- package/src/serializer-processing-xml.ts +67 -0
- package/src/serializer-processing.fixtures.ts +469 -0
- package/src/serializer-processing.ts +196 -0
- package/src/shared-vocabulary-authoring.ts +438 -0
- package/src/shared-vocabulary-interaction-sets.ts +28 -0
- package/src/shared-vocabulary-interaction-validation.ts +143 -0
- package/src/shared-vocabulary-registry-validation.ts +210 -0
- package/src/shared-vocabulary-support.ts +200 -127
- package/src/shared-vocabulary.ts +18 -0
- package/src/support.ts +144 -0
- package/src/types.ts +42 -7
- package/src/validation.ts +33 -341
- package/src/xml.ts +376 -276
package/dist/xml.js
CHANGED
|
@@ -1,214 +1,112 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Minimal XML parser for QTI item/package XML.
|
|
3
|
+
*
|
|
4
|
+
* The parser intentionally does not process DTD entity declarations, resolve external entities,
|
|
5
|
+
* read from the network/filesystem, or expand custom entities. That keeps QTI parsing structurally
|
|
6
|
+
* immune to XXE and billion-laughs style expansion: numeric references decode to at most one code
|
|
7
|
+
* point, predefined entities decode once, and every other entity reference remains verbatim.
|
|
8
|
+
*/
|
|
2
9
|
export function parseXmlTree(xml) {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
33
|
-
const node = {
|
|
34
|
-
name: event.name,
|
|
35
|
-
localName: event.localName ?? event.name,
|
|
36
|
-
prefix: event.prefix,
|
|
37
|
-
uri: event.uri,
|
|
38
|
-
attributes: event.attributes,
|
|
39
|
-
children: [],
|
|
40
|
-
content: [],
|
|
41
|
-
text: "",
|
|
42
|
-
source: sourceLocation(xml, sourceRange.startOffset, path),
|
|
43
|
-
sourceRange,
|
|
44
|
-
};
|
|
45
|
-
if (parent) {
|
|
46
|
-
node.parent = parent;
|
|
47
|
-
parent.children.push(node);
|
|
48
|
-
parent.content.push(node);
|
|
49
|
-
}
|
|
50
|
-
else {
|
|
51
|
-
root = node;
|
|
52
|
-
}
|
|
53
|
-
stack.push(node);
|
|
54
|
-
continue;
|
|
10
|
+
if (xml.charCodeAt(0) === 0xfeff)
|
|
11
|
+
xml = xml.slice(1);
|
|
12
|
+
const state = {
|
|
13
|
+
xml,
|
|
14
|
+
lineStarts: buildLineStarts(xml),
|
|
15
|
+
errors: [],
|
|
16
|
+
root: undefined,
|
|
17
|
+
stack: [],
|
|
18
|
+
namespaceStack: [],
|
|
19
|
+
};
|
|
20
|
+
let offset = 0;
|
|
21
|
+
while (offset < xml.length) {
|
|
22
|
+
const markupOffset = xml.indexOf("<", offset);
|
|
23
|
+
if (markupOffset < 0) {
|
|
24
|
+
appendText(xml.slice(offset), state);
|
|
25
|
+
break;
|
|
26
|
+
}
|
|
27
|
+
if (markupOffset > offset) {
|
|
28
|
+
appendText(xml.slice(offset, markupOffset), state);
|
|
29
|
+
}
|
|
30
|
+
if (markupOffset + 1 >= xml.length) {
|
|
31
|
+
state.errors.push(new Error("Malformed XML tag at end of document."));
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
34
|
+
if (xml.startsWith("<!--", markupOffset)) {
|
|
35
|
+
const endOffset = xml.indexOf("-->", markupOffset + 4);
|
|
36
|
+
if (endOffset < 0) {
|
|
37
|
+
state.errors.push(new Error("Unterminated XML comment."));
|
|
38
|
+
break;
|
|
55
39
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
node.endSource = sourceLocation(xml, token.startOffset, node.source.path);
|
|
65
|
-
}
|
|
66
|
-
else {
|
|
67
|
-
errors.push(new Error(`XML source range alignment failed for </${event.name}>.`));
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
continue;
|
|
40
|
+
offset = endOffset + 3;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (xml.startsWith("<![CDATA[", markupOffset)) {
|
|
44
|
+
const endOffset = xml.indexOf("]]>", markupOffset + 9);
|
|
45
|
+
if (endOffset < 0) {
|
|
46
|
+
state.errors.push(new Error("Unterminated CDATA section."));
|
|
47
|
+
break;
|
|
72
48
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
49
|
+
appendCharacterData(xml.slice(markupOffset + 9, endOffset), state);
|
|
50
|
+
offset = endOffset + 3;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const next = xml.charAt(markupOffset + 1);
|
|
54
|
+
if (next === "?") {
|
|
55
|
+
const endOffset = xml.indexOf("?>", markupOffset + 2);
|
|
56
|
+
if (endOffset < 0) {
|
|
57
|
+
state.errors.push(new Error("Unterminated XML processing instruction."));
|
|
58
|
+
break;
|
|
79
59
|
}
|
|
60
|
+
offset = endOffset + 2;
|
|
61
|
+
continue;
|
|
80
62
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
if (root)
|
|
89
|
-
restoreMixedContentFromSource(xml, root);
|
|
90
|
-
return { root, errors };
|
|
91
|
-
}
|
|
92
|
-
/**
|
|
93
|
-
* stax-xml trims boundary whitespace around child elements. Re-slice mixed content from the
|
|
94
|
-
* original XML so authored spacing around inline markup (for example `<em>`) is preserved.
|
|
95
|
-
*/
|
|
96
|
-
const inlineMixedContentChildNames = new Set([
|
|
97
|
-
"a",
|
|
98
|
-
"abbr",
|
|
99
|
-
"b",
|
|
100
|
-
"bdi",
|
|
101
|
-
"bdo",
|
|
102
|
-
"cite",
|
|
103
|
-
"code",
|
|
104
|
-
"dfn",
|
|
105
|
-
"em",
|
|
106
|
-
"i",
|
|
107
|
-
"kbd",
|
|
108
|
-
"mark",
|
|
109
|
-
"math",
|
|
110
|
-
"mi",
|
|
111
|
-
"mn",
|
|
112
|
-
"mo",
|
|
113
|
-
"mrow",
|
|
114
|
-
"msup",
|
|
115
|
-
"q",
|
|
116
|
-
"rp",
|
|
117
|
-
"rt",
|
|
118
|
-
"ruby",
|
|
119
|
-
"s",
|
|
120
|
-
"samp",
|
|
121
|
-
"small",
|
|
122
|
-
"span",
|
|
123
|
-
"strong",
|
|
124
|
-
"sub",
|
|
125
|
-
"sup",
|
|
126
|
-
"var",
|
|
127
|
-
"qti-feedback-inline",
|
|
128
|
-
"qti-gap",
|
|
129
|
-
"qti-hottext",
|
|
130
|
-
"qti-inline-choice-interaction",
|
|
131
|
-
"qti-printed-variable",
|
|
132
|
-
"qti-template-inline",
|
|
133
|
-
"qti-text-entry-interaction",
|
|
134
|
-
]);
|
|
135
|
-
function shouldRestoreMixedContentWhitespace(node) {
|
|
136
|
-
return node.content.some((entry) => typeof entry !== "string" && inlineMixedContentChildNames.has(entry.localName));
|
|
137
|
-
}
|
|
138
|
-
function restoreMixedContentFromSource(xml, node) {
|
|
139
|
-
for (const entry of node.content) {
|
|
140
|
-
if (typeof entry !== "string")
|
|
141
|
-
restoreMixedContentFromSource(xml, entry);
|
|
142
|
-
}
|
|
143
|
-
restoreLeafTextFromSource(xml, node);
|
|
144
|
-
if (!shouldRestoreMixedContentWhitespace(node))
|
|
145
|
-
return;
|
|
146
|
-
const contentEndOffset = node.endSource?.offset ?? node.sourceRange.endOffset;
|
|
147
|
-
if (node.sourceRange.startTagEndOffset < 0 || contentEndOffset === undefined)
|
|
148
|
-
return;
|
|
149
|
-
const restored = [];
|
|
150
|
-
let cursor = node.sourceRange.startTagEndOffset + 1;
|
|
151
|
-
for (const entry of node.content) {
|
|
152
|
-
if (typeof entry === "string")
|
|
63
|
+
if (next === "!") {
|
|
64
|
+
const endOffset = findMarkupDeclarationEndOffset(xml, markupOffset + 2);
|
|
65
|
+
if (endOffset < 0) {
|
|
66
|
+
state.errors.push(new Error("Unterminated XML markup declaration."));
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
offset = endOffset + 1;
|
|
153
70
|
continue;
|
|
154
|
-
|
|
155
|
-
if (
|
|
71
|
+
}
|
|
72
|
+
if (next === "/") {
|
|
73
|
+
const endOffset = findTagEndOffset(xml, markupOffset + 2);
|
|
74
|
+
if (endOffset < 0) {
|
|
75
|
+
state.errors.push(new Error("Unterminated XML closing tag."));
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
const name = readTagName(xml, markupOffset + 2, endOffset);
|
|
79
|
+
if (!name) {
|
|
80
|
+
state.errors.push(new Error("Malformed XML closing tag."));
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
closeElement(name, markupOffset, endOffset, state);
|
|
84
|
+
}
|
|
85
|
+
offset = endOffset + 1;
|
|
156
86
|
continue;
|
|
157
|
-
if (childStart > cursor) {
|
|
158
|
-
appendDecodedTextSegment(restored, xml.slice(cursor, childStart));
|
|
159
87
|
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
88
|
+
const endOffset = findTagEndOffset(xml, markupOffset + 1);
|
|
89
|
+
if (endOffset < 0) {
|
|
90
|
+
state.errors.push(new Error("Unterminated XML start tag."));
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
const name = readTagName(xml, markupOffset + 1, endOffset);
|
|
94
|
+
if (!name) {
|
|
95
|
+
state.errors.push(new Error("Malformed XML start tag."));
|
|
96
|
+
offset = endOffset + 1;
|
|
163
97
|
continue;
|
|
164
|
-
|
|
98
|
+
}
|
|
99
|
+
const selfClosing = isSelfClosingStartTag(xml, endOffset);
|
|
100
|
+
const attributes = parseAttributes(xml, markupOffset + 1 + name.length, selfClosing ? trailingSlashOffset(xml, endOffset) : endOffset, state);
|
|
101
|
+
openElement(name, attributes, markupOffset, endOffset, selfClosing, state);
|
|
102
|
+
offset = endOffset + 1;
|
|
165
103
|
}
|
|
166
|
-
|
|
167
|
-
|
|
104
|
+
for (const node of [...state.stack].reverse()) {
|
|
105
|
+
state.errors.push(new Error(`Unexpected end of document. Missing closing tag for <${node.name}>.`));
|
|
168
106
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
}
|
|
172
|
-
function restoreLeafTextFromSource(xml, node) {
|
|
173
|
-
if (node.children.length > 0)
|
|
174
|
-
return;
|
|
175
|
-
const contentEndOffset = node.endSource?.offset ?? node.sourceRange.endOffset;
|
|
176
|
-
if (node.sourceRange.startTagEndOffset < 0 || contentEndOffset === undefined)
|
|
177
|
-
return;
|
|
178
|
-
const raw = xml.slice(node.sourceRange.startTagEndOffset + 1, contentEndOffset);
|
|
179
|
-
if (raw.includes("<![CDATA["))
|
|
180
|
-
return;
|
|
181
|
-
const decoded = decodeXmlCharacterData(stripNonCharacterMarkup(raw));
|
|
182
|
-
node.text = decoded;
|
|
183
|
-
node.content = decoded.length > 0 ? [decoded] : [];
|
|
184
|
-
}
|
|
185
|
-
function appendDecodedTextSegment(content, raw) {
|
|
186
|
-
const decoded = decodeXmlCharacterData(stripNonCharacterMarkup(raw));
|
|
187
|
-
if (decoded.length > 0)
|
|
188
|
-
content.push(decoded);
|
|
189
|
-
}
|
|
190
|
-
function stripNonCharacterMarkup(value) {
|
|
191
|
-
return value.replace(/<!--[\s\S]*?-->/g, "").replace(/<\?[\s\S]*?\?>/g, "");
|
|
192
|
-
}
|
|
193
|
-
const predefinedXmlEntities = {
|
|
194
|
-
amp: "&",
|
|
195
|
-
apos: "'",
|
|
196
|
-
gt: ">",
|
|
197
|
-
lt: "<",
|
|
198
|
-
quot: '"',
|
|
199
|
-
};
|
|
200
|
-
function decodeXmlCharacterData(value) {
|
|
201
|
-
return value.replace(/&(#x?[0-9a-fA-F]+|[A-Za-z]+);/g, (entity, body) => {
|
|
202
|
-
if (body.startsWith("#x") || body.startsWith("#X")) {
|
|
203
|
-
const codePoint = Number.parseInt(body.slice(2), 16);
|
|
204
|
-
return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : entity;
|
|
205
|
-
}
|
|
206
|
-
if (body.startsWith("#")) {
|
|
207
|
-
const codePoint = Number.parseInt(body.slice(1), 10);
|
|
208
|
-
return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : entity;
|
|
209
|
-
}
|
|
210
|
-
return predefinedXmlEntities[body] ?? entity;
|
|
211
|
-
});
|
|
107
|
+
if (!state.root)
|
|
108
|
+
state.errors.push(new Error("XML document does not contain a root element."));
|
|
109
|
+
return { root: state.root, errors: state.errors };
|
|
212
110
|
}
|
|
213
111
|
export function childElements(node, localName) {
|
|
214
112
|
return node.children.filter((child) => !localName || child.localName === localName);
|
|
@@ -226,71 +124,192 @@ export function textContent(node) {
|
|
|
226
124
|
const parts = node.content.map((entry) => typeof entry === "string" ? entry : textContent(entry));
|
|
227
125
|
return parts.join(" ").replace(/\s+/g, " ").trim();
|
|
228
126
|
}
|
|
229
|
-
function
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
127
|
+
function openElement(name, parsedAttributes, startOffset, startTagEndOffset, selfClosing, state) {
|
|
128
|
+
const parent = state.stack.at(-1);
|
|
129
|
+
if (!parent && state.root) {
|
|
130
|
+
state.errors.push(new Error(`XML document contains multiple root elements; found <${name}>.`));
|
|
131
|
+
}
|
|
132
|
+
const attributes = {};
|
|
133
|
+
const inheritedNamespaces = state.namespaceStack.at(-1)?.namespaces ?? {};
|
|
134
|
+
const namespaces = { ...inheritedNamespaces };
|
|
135
|
+
for (const attribute of parsedAttributes) {
|
|
136
|
+
attributes[attribute.name] = attribute.value;
|
|
137
|
+
if (attribute.name === "xmlns")
|
|
138
|
+
namespaces[""] = attribute.value;
|
|
139
|
+
else if (attribute.name.startsWith("xmlns:"))
|
|
140
|
+
namespaces[attribute.name.slice(6)] = attribute.value;
|
|
141
|
+
}
|
|
142
|
+
const { prefix, localName } = splitQualifiedName(name);
|
|
143
|
+
const path = nodePath(parent, localName);
|
|
144
|
+
const sourceRange = {
|
|
145
|
+
startOffset,
|
|
146
|
+
startTagEndOffset,
|
|
147
|
+
endOffset: selfClosing ? startTagEndOffset + 1 : undefined,
|
|
148
|
+
};
|
|
149
|
+
const node = {
|
|
150
|
+
name,
|
|
151
|
+
localName,
|
|
152
|
+
prefix,
|
|
153
|
+
uri: namespaces[prefix ?? ""],
|
|
154
|
+
attributes,
|
|
155
|
+
children: [],
|
|
156
|
+
content: [],
|
|
157
|
+
text: "",
|
|
158
|
+
source: sourceLocation(state, startOffset, path),
|
|
159
|
+
sourceRange,
|
|
160
|
+
};
|
|
161
|
+
if (parent) {
|
|
162
|
+
node.parent = parent;
|
|
163
|
+
parent.children.push(node);
|
|
164
|
+
parent.content.push(node);
|
|
165
|
+
}
|
|
166
|
+
else if (!state.root) {
|
|
167
|
+
state.root = node;
|
|
168
|
+
}
|
|
169
|
+
if (!selfClosing) {
|
|
170
|
+
state.stack.push(node);
|
|
171
|
+
state.namespaceStack.push({ namespaces });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
function closeElement(name, startOffset, tagEndOffset, state) {
|
|
175
|
+
const top = state.stack.at(-1);
|
|
176
|
+
if (!top) {
|
|
177
|
+
state.errors.push(new Error(`Unexpected closing tag </${name}>.`));
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
let matchIndex = -1;
|
|
181
|
+
for (let index = state.stack.length - 1; index >= 0; index -= 1) {
|
|
182
|
+
if (state.stack[index]?.name === name) {
|
|
183
|
+
matchIndex = index;
|
|
184
|
+
break;
|
|
239
185
|
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
186
|
+
}
|
|
187
|
+
if (matchIndex < 0) {
|
|
188
|
+
state.errors.push(new Error(`Unexpected closing tag </${name}>; expected </${top.name}>.`));
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (matchIndex !== state.stack.length - 1) {
|
|
192
|
+
state.errors.push(new Error(`Mismatched closing tag </${name}>; expected </${top.name}>.`));
|
|
193
|
+
while (state.stack.length - 1 > matchIndex) {
|
|
194
|
+
const unclosed = state.stack.pop();
|
|
195
|
+
state.namespaceStack.pop();
|
|
196
|
+
if (unclosed) {
|
|
197
|
+
state.errors.push(new Error(`Implicitly closed <${unclosed.name}> due to mismatched tag </${name}>.`));
|
|
198
|
+
}
|
|
243
199
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
200
|
+
}
|
|
201
|
+
const node = state.stack.pop();
|
|
202
|
+
state.namespaceStack.pop();
|
|
203
|
+
if (!node)
|
|
204
|
+
return;
|
|
205
|
+
node.sourceRange.endOffset = tagEndOffset + 1;
|
|
206
|
+
node.endSource = sourceLocation(state, startOffset, node.source.path);
|
|
207
|
+
}
|
|
208
|
+
function appendText(raw, state) {
|
|
209
|
+
appendCharacterData(decodeXmlCharacterData(raw, state), state);
|
|
210
|
+
}
|
|
211
|
+
function appendCharacterData(text, state) {
|
|
212
|
+
if (text.length === 0)
|
|
213
|
+
return;
|
|
214
|
+
const parent = state.stack.at(-1);
|
|
215
|
+
if (parent) {
|
|
216
|
+
parent.text += text;
|
|
217
|
+
parent.content.push(text);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (text.trim().length === 0)
|
|
221
|
+
return;
|
|
222
|
+
if (state.root)
|
|
223
|
+
state.errors.push(new Error("XML document contains content after the root element."));
|
|
224
|
+
else
|
|
225
|
+
state.errors.push(new Error("XML document contains content before the root element."));
|
|
226
|
+
}
|
|
227
|
+
function parseAttributes(xml, from, to, state) {
|
|
228
|
+
const attributes = [];
|
|
229
|
+
let offset = from;
|
|
230
|
+
while (offset < to) {
|
|
231
|
+
while (offset < to && /\s/.test(xml.charAt(offset)))
|
|
232
|
+
offset += 1;
|
|
233
|
+
if (offset >= to)
|
|
234
|
+
break;
|
|
235
|
+
const nameStart = offset;
|
|
236
|
+
while (offset < to) {
|
|
237
|
+
const char = xml.charAt(offset);
|
|
238
|
+
if (/\s/.test(char) || char === "=" || char === "/" || char === ">")
|
|
239
|
+
break;
|
|
240
|
+
offset += 1;
|
|
241
|
+
}
|
|
242
|
+
const name = xml.slice(nameStart, offset);
|
|
243
|
+
if (!name) {
|
|
244
|
+
state.errors.push(new Error("Malformed XML attribute."));
|
|
245
|
+
offset += 1;
|
|
247
246
|
continue;
|
|
248
247
|
}
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
248
|
+
while (offset < to && /\s/.test(xml.charAt(offset)))
|
|
249
|
+
offset += 1;
|
|
250
|
+
if (xml.charAt(offset) !== "=") {
|
|
251
|
+
state.errors.push(new Error(`Malformed XML attribute ${name}; expected =.`));
|
|
252
|
+
while (offset < to && !/\s/.test(xml.charAt(offset)))
|
|
253
|
+
offset += 1;
|
|
252
254
|
continue;
|
|
253
255
|
}
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
name,
|
|
263
|
-
startOffset,
|
|
264
|
-
startTagEndOffset: tagEndOffset,
|
|
265
|
-
endOffset: tagEndOffset + 1,
|
|
266
|
-
selfClosing: false,
|
|
267
|
-
});
|
|
268
|
-
}
|
|
269
|
-
offset = tagEndOffset + 1;
|
|
256
|
+
offset += 1;
|
|
257
|
+
while (offset < to && /\s/.test(xml.charAt(offset)))
|
|
258
|
+
offset += 1;
|
|
259
|
+
const quote = xml.charAt(offset);
|
|
260
|
+
if (quote !== '"' && quote !== "'") {
|
|
261
|
+
state.errors.push(new Error(`Malformed XML attribute ${name}; expected quoted value.`));
|
|
262
|
+
while (offset < to && !/\s/.test(xml.charAt(offset)))
|
|
263
|
+
offset += 1;
|
|
270
264
|
continue;
|
|
271
265
|
}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
266
|
+
offset += 1;
|
|
267
|
+
const valueStart = offset;
|
|
268
|
+
const valueEnd = xml.indexOf(quote, valueStart);
|
|
269
|
+
if (valueEnd < 0 || valueEnd > to) {
|
|
270
|
+
state.errors.push(new Error(`Unterminated XML attribute ${name}.`));
|
|
271
|
+
break;
|
|
272
|
+
}
|
|
273
|
+
attributes.push({
|
|
274
|
+
name,
|
|
275
|
+
value: decodeXmlCharacterData(xml.slice(valueStart, valueEnd), state),
|
|
276
|
+
});
|
|
277
|
+
offset = valueEnd + 1;
|
|
278
|
+
}
|
|
279
|
+
return attributes;
|
|
280
|
+
}
|
|
281
|
+
const predefinedXmlEntities = {
|
|
282
|
+
amp: "&",
|
|
283
|
+
apos: "'",
|
|
284
|
+
gt: ">",
|
|
285
|
+
lt: "<",
|
|
286
|
+
quot: '"',
|
|
287
|
+
};
|
|
288
|
+
function decodeXmlCharacterData(value, state) {
|
|
289
|
+
return value.replace(/&(#x[0-9a-fA-F]+|#X[0-9a-fA-F]+|#[0-9]+|[A-Za-z][A-Za-z0-9._:-]*);/g, (entity, body) => {
|
|
290
|
+
if (body.startsWith("#x") || body.startsWith("#X")) {
|
|
291
|
+
return decodeNumericEntity(entity, Number.parseInt(body.slice(2), 16), state);
|
|
292
|
+
}
|
|
293
|
+
if (body.startsWith("#")) {
|
|
294
|
+
return decodeNumericEntity(entity, Number.parseInt(body.slice(1), 10), state);
|
|
286
295
|
}
|
|
287
|
-
|
|
296
|
+
return predefinedXmlEntities[body] ?? entity;
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
function decodeNumericEntity(entity, codePoint, state) {
|
|
300
|
+
if (!Number.isFinite(codePoint) || !isXmlChar(codePoint)) {
|
|
301
|
+
state.errors.push(new Error(`Invalid XML character reference ${entity}.`));
|
|
302
|
+
return entity;
|
|
288
303
|
}
|
|
289
|
-
return
|
|
304
|
+
return String.fromCodePoint(codePoint);
|
|
290
305
|
}
|
|
291
|
-
function
|
|
292
|
-
|
|
293
|
-
|
|
306
|
+
function isXmlChar(codePoint) {
|
|
307
|
+
return (codePoint === 0x9 ||
|
|
308
|
+
codePoint === 0xa ||
|
|
309
|
+
codePoint === 0xd ||
|
|
310
|
+
(codePoint >= 0x20 && codePoint <= 0xd7ff) ||
|
|
311
|
+
(codePoint >= 0xe000 && codePoint <= 0xfffd) ||
|
|
312
|
+
(codePoint >= 0x10000 && codePoint <= 0x10ffff));
|
|
294
313
|
}
|
|
295
314
|
function findMarkupDeclarationEndOffset(xml, from) {
|
|
296
315
|
let quote = null;
|
|
@@ -351,29 +370,53 @@ function findTagEndOffset(xml, from) {
|
|
|
351
370
|
return -1;
|
|
352
371
|
}
|
|
353
372
|
function isSelfClosingStartTag(xml, tagEndOffset) {
|
|
373
|
+
return xml.charAt(trailingSlashOffset(xml, tagEndOffset)) === "/";
|
|
374
|
+
}
|
|
375
|
+
function trailingSlashOffset(xml, tagEndOffset) {
|
|
354
376
|
for (let index = tagEndOffset - 1; index >= 0; index -= 1) {
|
|
355
|
-
|
|
356
|
-
if (/\s/.test(char))
|
|
377
|
+
if (/\s/.test(xml.charAt(index)))
|
|
357
378
|
continue;
|
|
358
|
-
return
|
|
379
|
+
return index;
|
|
359
380
|
}
|
|
360
|
-
return
|
|
381
|
+
return tagEndOffset;
|
|
361
382
|
}
|
|
362
|
-
function
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
383
|
+
function splitQualifiedName(name) {
|
|
384
|
+
const separator = name.indexOf(":");
|
|
385
|
+
if (separator < 0)
|
|
386
|
+
return { prefix: undefined, localName: name };
|
|
387
|
+
return {
|
|
388
|
+
prefix: name.slice(0, separator),
|
|
389
|
+
localName: name.slice(separator + 1),
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
function buildLineStarts(xml) {
|
|
393
|
+
const lineStarts = [0];
|
|
394
|
+
for (let index = 0; index < xml.length; index += 1) {
|
|
395
|
+
if (xml.charAt(index) === "\n")
|
|
396
|
+
lineStarts.push(index + 1);
|
|
375
397
|
}
|
|
376
|
-
return
|
|
398
|
+
return lineStarts;
|
|
399
|
+
}
|
|
400
|
+
function sourceLocation(state, offset, path) {
|
|
401
|
+
const normalizedOffset = Math.max(0, offset);
|
|
402
|
+
let low = 0;
|
|
403
|
+
let high = state.lineStarts.length - 1;
|
|
404
|
+
while (low <= high) {
|
|
405
|
+
const middle = Math.floor((low + high) / 2);
|
|
406
|
+
const lineStart = state.lineStarts[middle] ?? 0;
|
|
407
|
+
if (lineStart <= normalizedOffset)
|
|
408
|
+
low = middle + 1;
|
|
409
|
+
else
|
|
410
|
+
high = middle - 1;
|
|
411
|
+
}
|
|
412
|
+
const lineIndex = Math.max(0, high);
|
|
413
|
+
const lineStart = state.lineStarts[lineIndex] ?? 0;
|
|
414
|
+
return {
|
|
415
|
+
line: lineIndex + 1,
|
|
416
|
+
column: normalizedOffset - lineStart + 1,
|
|
417
|
+
offset: normalizedOffset,
|
|
418
|
+
path,
|
|
419
|
+
};
|
|
377
420
|
}
|
|
378
421
|
function nodePath(parent, localName) {
|
|
379
422
|
if (!parent)
|
|
@@ -381,4 +424,10 @@ function nodePath(parent, localName) {
|
|
|
381
424
|
const index = parent.children.filter((child) => child.localName === localName).length + 1;
|
|
382
425
|
return `${parent.source.path}/${localName}[${index}]`;
|
|
383
426
|
}
|
|
427
|
+
export function escapeXmlText(value) {
|
|
428
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
429
|
+
}
|
|
430
|
+
export function escapeXmlAttribute(value) {
|
|
431
|
+
return escapeXmlText(value).replaceAll('"', """);
|
|
432
|
+
}
|
|
384
433
|
//# sourceMappingURL=xml.js.map
|