@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/src/xml.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { StaxXmlParserSync, XmlEventType } from "stax-xml";
|
|
2
|
-
|
|
3
1
|
export interface XmlNode {
|
|
4
2
|
name: string;
|
|
5
3
|
localName: string;
|
|
@@ -31,226 +29,151 @@ export interface XmlSourceRange {
|
|
|
31
29
|
endOffset?: number | undefined;
|
|
32
30
|
}
|
|
33
31
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
let tagTokenIndex = 0;
|
|
43
|
-
|
|
44
|
-
try {
|
|
45
|
-
for (const event of parser) {
|
|
46
|
-
if (event.type === XmlEventType.ERROR) {
|
|
47
|
-
errors.push(event.error);
|
|
48
|
-
continue;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
if (event.type === XmlEventType.START_ELEMENT) {
|
|
52
|
-
const parent = stack.at(-1);
|
|
53
|
-
const path = nodePath(parent, event.localName ?? event.name);
|
|
54
|
-
const sourceRange: XmlSourceRange = { startOffset: -1, startTagEndOffset: -1 };
|
|
55
|
-
const token = tagTokens[tagTokenIndex];
|
|
56
|
-
if (token?.kind === "start" && token.name === event.name) {
|
|
57
|
-
tagTokenIndex += 1;
|
|
58
|
-
sourceRange.startOffset = token.startOffset;
|
|
59
|
-
sourceRange.startTagEndOffset = token.startTagEndOffset;
|
|
60
|
-
if (token.endOffset !== undefined) {
|
|
61
|
-
sourceRange.endOffset = token.endOffset;
|
|
62
|
-
}
|
|
63
|
-
} else {
|
|
64
|
-
errors.push(new Error(`XML source range alignment failed for <${event.name}>.`));
|
|
65
|
-
}
|
|
66
|
-
const node: XmlNode = {
|
|
67
|
-
name: event.name,
|
|
68
|
-
localName: event.localName ?? event.name,
|
|
69
|
-
prefix: event.prefix,
|
|
70
|
-
uri: event.uri,
|
|
71
|
-
attributes: event.attributes,
|
|
72
|
-
children: [],
|
|
73
|
-
content: [],
|
|
74
|
-
text: "",
|
|
75
|
-
source: sourceLocation(xml, sourceRange.startOffset, path),
|
|
76
|
-
sourceRange,
|
|
77
|
-
};
|
|
78
|
-
|
|
79
|
-
if (parent) {
|
|
80
|
-
node.parent = parent;
|
|
81
|
-
parent.children.push(node);
|
|
82
|
-
parent.content.push(node);
|
|
83
|
-
} else {
|
|
84
|
-
root = node;
|
|
85
|
-
}
|
|
86
|
-
stack.push(node);
|
|
87
|
-
continue;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
if (event.type === XmlEventType.END_ELEMENT) {
|
|
91
|
-
const node = stack.pop();
|
|
92
|
-
if (node) {
|
|
93
|
-
if (node.sourceRange.endOffset === undefined) {
|
|
94
|
-
const token = tagTokens[tagTokenIndex];
|
|
95
|
-
if (token?.kind === "end" && token.name === event.name) {
|
|
96
|
-
tagTokenIndex += 1;
|
|
97
|
-
node.sourceRange.endOffset = token.endOffset;
|
|
98
|
-
node.endSource = sourceLocation(xml, token.startOffset, node.source.path);
|
|
99
|
-
} else {
|
|
100
|
-
errors.push(new Error(`XML source range alignment failed for </${event.name}>.`));
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
continue;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
if (event.type === XmlEventType.CHARACTERS || event.type === XmlEventType.CDATA) {
|
|
108
|
-
const node = stack.at(-1);
|
|
109
|
-
if (node) {
|
|
110
|
-
node.text += event.value;
|
|
111
|
-
node.content.push(event.value);
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
} catch (error) {
|
|
116
|
-
errors.push(error instanceof Error ? error : new Error(String(error)));
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
for (const node of [...stack].reverse()) {
|
|
120
|
-
errors.push(new Error(`Unexpected end of document. Missing closing tag for <${node.name}>.`));
|
|
121
|
-
}
|
|
32
|
+
interface ParserState {
|
|
33
|
+
xml: string;
|
|
34
|
+
lineStarts: number[];
|
|
35
|
+
errors: Error[];
|
|
36
|
+
root: XmlNode | undefined;
|
|
37
|
+
stack: XmlNode[];
|
|
38
|
+
namespaceStack: NamespaceFrame[];
|
|
39
|
+
}
|
|
122
40
|
|
|
123
|
-
|
|
41
|
+
interface ParsedAttribute {
|
|
42
|
+
name: string;
|
|
43
|
+
value: string;
|
|
44
|
+
}
|
|
124
45
|
|
|
125
|
-
|
|
46
|
+
interface NamespaceFrame {
|
|
47
|
+
namespaces: Record<string, string>;
|
|
126
48
|
}
|
|
127
49
|
|
|
128
50
|
/**
|
|
129
|
-
*
|
|
130
|
-
*
|
|
51
|
+
* Minimal XML parser for QTI item/package XML.
|
|
52
|
+
*
|
|
53
|
+
* The parser intentionally does not process DTD entity declarations, resolve external entities,
|
|
54
|
+
* read from the network/filesystem, or expand custom entities. That keeps QTI parsing structurally
|
|
55
|
+
* immune to XXE and billion-laughs style expansion: numeric references decode to at most one code
|
|
56
|
+
* point, predefined entities decode once, and every other entity reference remains verbatim.
|
|
131
57
|
*/
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
"mark",
|
|
145
|
-
"math",
|
|
146
|
-
"mi",
|
|
147
|
-
"mn",
|
|
148
|
-
"mo",
|
|
149
|
-
"mrow",
|
|
150
|
-
"msup",
|
|
151
|
-
"q",
|
|
152
|
-
"rp",
|
|
153
|
-
"rt",
|
|
154
|
-
"ruby",
|
|
155
|
-
"s",
|
|
156
|
-
"samp",
|
|
157
|
-
"small",
|
|
158
|
-
"span",
|
|
159
|
-
"strong",
|
|
160
|
-
"sub",
|
|
161
|
-
"sup",
|
|
162
|
-
"var",
|
|
163
|
-
"qti-feedback-inline",
|
|
164
|
-
"qti-gap",
|
|
165
|
-
"qti-hottext",
|
|
166
|
-
"qti-inline-choice-interaction",
|
|
167
|
-
"qti-printed-variable",
|
|
168
|
-
"qti-template-inline",
|
|
169
|
-
"qti-text-entry-interaction",
|
|
170
|
-
]);
|
|
171
|
-
|
|
172
|
-
function shouldRestoreMixedContentWhitespace(node: XmlNode): boolean {
|
|
173
|
-
return node.content.some(
|
|
174
|
-
(entry) => typeof entry !== "string" && inlineMixedContentChildNames.has(entry.localName),
|
|
175
|
-
);
|
|
176
|
-
}
|
|
58
|
+
export function parseXmlTree(xml: string): { root: XmlNode | undefined; errors: Error[] } {
|
|
59
|
+
if (xml.charCodeAt(0) === 0xfeff) xml = xml.slice(1);
|
|
60
|
+
|
|
61
|
+
const state: ParserState = {
|
|
62
|
+
xml,
|
|
63
|
+
lineStarts: buildLineStarts(xml),
|
|
64
|
+
errors: [],
|
|
65
|
+
root: undefined,
|
|
66
|
+
stack: [],
|
|
67
|
+
namespaceStack: [],
|
|
68
|
+
};
|
|
69
|
+
let offset = 0;
|
|
177
70
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
if (
|
|
181
|
-
|
|
71
|
+
while (offset < xml.length) {
|
|
72
|
+
const markupOffset = xml.indexOf("<", offset);
|
|
73
|
+
if (markupOffset < 0) {
|
|
74
|
+
appendText(xml.slice(offset), state);
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
182
77
|
|
|
183
|
-
|
|
78
|
+
if (markupOffset > offset) {
|
|
79
|
+
appendText(xml.slice(offset, markupOffset), state);
|
|
80
|
+
}
|
|
184
81
|
|
|
185
|
-
|
|
82
|
+
if (markupOffset + 1 >= xml.length) {
|
|
83
|
+
state.errors.push(new Error("Malformed XML tag at end of document."));
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
186
86
|
|
|
187
|
-
|
|
188
|
-
|
|
87
|
+
if (xml.startsWith("<!--", markupOffset)) {
|
|
88
|
+
const endOffset = xml.indexOf("-->", markupOffset + 4);
|
|
89
|
+
if (endOffset < 0) {
|
|
90
|
+
state.errors.push(new Error("Unterminated XML comment."));
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
offset = endOffset + 3;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
189
96
|
|
|
190
|
-
|
|
191
|
-
|
|
97
|
+
if (xml.startsWith("<![CDATA[", markupOffset)) {
|
|
98
|
+
const endOffset = xml.indexOf("]]>", markupOffset + 9);
|
|
99
|
+
if (endOffset < 0) {
|
|
100
|
+
state.errors.push(new Error("Unterminated CDATA section."));
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
appendCharacterData(xml.slice(markupOffset + 9, endOffset), state);
|
|
104
|
+
offset = endOffset + 3;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
192
107
|
|
|
193
|
-
|
|
194
|
-
if (
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
108
|
+
const next = xml.charAt(markupOffset + 1);
|
|
109
|
+
if (next === "?") {
|
|
110
|
+
const endOffset = xml.indexOf("?>", markupOffset + 2);
|
|
111
|
+
if (endOffset < 0) {
|
|
112
|
+
state.errors.push(new Error("Unterminated XML processing instruction."));
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
offset = endOffset + 2;
|
|
116
|
+
continue;
|
|
199
117
|
}
|
|
200
|
-
restored.push(entry);
|
|
201
|
-
const childEnd = entry.sourceRange.endOffset;
|
|
202
|
-
if (childEnd === undefined || childEnd < cursor) continue;
|
|
203
|
-
cursor = childEnd;
|
|
204
|
-
}
|
|
205
118
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
119
|
+
if (next === "!") {
|
|
120
|
+
const endOffset = findMarkupDeclarationEndOffset(xml, markupOffset + 2);
|
|
121
|
+
if (endOffset < 0) {
|
|
122
|
+
state.errors.push(new Error("Unterminated XML markup declaration."));
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
offset = endOffset + 1;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
209
128
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
129
|
+
if (next === "/") {
|
|
130
|
+
const endOffset = findTagEndOffset(xml, markupOffset + 2);
|
|
131
|
+
if (endOffset < 0) {
|
|
132
|
+
state.errors.push(new Error("Unterminated XML closing tag."));
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
const name = readTagName(xml, markupOffset + 2, endOffset);
|
|
136
|
+
if (!name) {
|
|
137
|
+
state.errors.push(new Error("Malformed XML closing tag."));
|
|
138
|
+
} else {
|
|
139
|
+
closeElement(name, markupOffset, endOffset, state);
|
|
140
|
+
}
|
|
141
|
+
offset = endOffset + 1;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
213
144
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
145
|
+
const endOffset = findTagEndOffset(xml, markupOffset + 1);
|
|
146
|
+
if (endOffset < 0) {
|
|
147
|
+
state.errors.push(new Error("Unterminated XML start tag."));
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
const name = readTagName(xml, markupOffset + 1, endOffset);
|
|
151
|
+
if (!name) {
|
|
152
|
+
state.errors.push(new Error("Malformed XML start tag."));
|
|
153
|
+
offset = endOffset + 1;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
224
156
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
157
|
+
const selfClosing = isSelfClosingStartTag(xml, endOffset);
|
|
158
|
+
const attributes = parseAttributes(
|
|
159
|
+
xml,
|
|
160
|
+
markupOffset + 1 + name.length,
|
|
161
|
+
selfClosing ? trailingSlashOffset(xml, endOffset) : endOffset,
|
|
162
|
+
state,
|
|
163
|
+
);
|
|
164
|
+
openElement(name, attributes, markupOffset, endOffset, selfClosing, state);
|
|
165
|
+
offset = endOffset + 1;
|
|
166
|
+
}
|
|
229
167
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
}
|
|
168
|
+
for (const node of [...state.stack].reverse()) {
|
|
169
|
+
state.errors.push(
|
|
170
|
+
new Error(`Unexpected end of document. Missing closing tag for <${node.name}>.`),
|
|
171
|
+
);
|
|
172
|
+
}
|
|
233
173
|
|
|
234
|
-
|
|
235
|
-
amp: "&",
|
|
236
|
-
apos: "'",
|
|
237
|
-
gt: ">",
|
|
238
|
-
lt: "<",
|
|
239
|
-
quot: '"',
|
|
240
|
-
};
|
|
174
|
+
if (!state.root) state.errors.push(new Error("XML document does not contain a root element."));
|
|
241
175
|
|
|
242
|
-
|
|
243
|
-
return value.replace(/&(#x?[0-9a-fA-F]+|[A-Za-z]+);/g, (entity, body: string) => {
|
|
244
|
-
if (body.startsWith("#x") || body.startsWith("#X")) {
|
|
245
|
-
const codePoint = Number.parseInt(body.slice(2), 16);
|
|
246
|
-
return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : entity;
|
|
247
|
-
}
|
|
248
|
-
if (body.startsWith("#")) {
|
|
249
|
-
const codePoint = Number.parseInt(body.slice(1), 10);
|
|
250
|
-
return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : entity;
|
|
251
|
-
}
|
|
252
|
-
return predefinedXmlEntities[body] ?? entity;
|
|
253
|
-
});
|
|
176
|
+
return { root: state.root, errors: state.errors };
|
|
254
177
|
}
|
|
255
178
|
|
|
256
179
|
export function childElements(node: XmlNode, localName?: string): XmlNode[] {
|
|
@@ -273,86 +196,227 @@ export function textContent(node: XmlNode): string {
|
|
|
273
196
|
return parts.join(" ").replace(/\s+/g, " ").trim();
|
|
274
197
|
}
|
|
275
198
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
startOffset: number
|
|
280
|
-
startTagEndOffset: number
|
|
281
|
-
|
|
282
|
-
|
|
199
|
+
function openElement(
|
|
200
|
+
name: string,
|
|
201
|
+
parsedAttributes: ParsedAttribute[],
|
|
202
|
+
startOffset: number,
|
|
203
|
+
startTagEndOffset: number,
|
|
204
|
+
selfClosing: boolean,
|
|
205
|
+
state: ParserState,
|
|
206
|
+
): void {
|
|
207
|
+
const parent = state.stack.at(-1);
|
|
208
|
+
if (!parent && state.root) {
|
|
209
|
+
state.errors.push(new Error(`XML document contains multiple root elements; found <${name}>.`));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const attributes: Record<string, string> = {};
|
|
213
|
+
const inheritedNamespaces = state.namespaceStack.at(-1)?.namespaces ?? {};
|
|
214
|
+
const namespaces: Record<string, string> = { ...inheritedNamespaces };
|
|
215
|
+
for (const attribute of parsedAttributes) {
|
|
216
|
+
attributes[attribute.name] = attribute.value;
|
|
217
|
+
if (attribute.name === "xmlns") namespaces[""] = attribute.value;
|
|
218
|
+
else if (attribute.name.startsWith("xmlns:"))
|
|
219
|
+
namespaces[attribute.name.slice(6)] = attribute.value;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const { prefix, localName } = splitQualifiedName(name);
|
|
223
|
+
const path = nodePath(parent, localName);
|
|
224
|
+
const sourceRange: XmlSourceRange = {
|
|
225
|
+
startOffset,
|
|
226
|
+
startTagEndOffset,
|
|
227
|
+
endOffset: selfClosing ? startTagEndOffset + 1 : undefined,
|
|
228
|
+
};
|
|
229
|
+
const node: XmlNode = {
|
|
230
|
+
name,
|
|
231
|
+
localName,
|
|
232
|
+
prefix,
|
|
233
|
+
uri: namespaces[prefix ?? ""],
|
|
234
|
+
attributes,
|
|
235
|
+
children: [],
|
|
236
|
+
content: [],
|
|
237
|
+
text: "",
|
|
238
|
+
source: sourceLocation(state, startOffset, path),
|
|
239
|
+
sourceRange,
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
if (parent) {
|
|
243
|
+
node.parent = parent;
|
|
244
|
+
parent.children.push(node);
|
|
245
|
+
parent.content.push(node);
|
|
246
|
+
} else if (!state.root) {
|
|
247
|
+
state.root = node;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (!selfClosing) {
|
|
251
|
+
state.stack.push(node);
|
|
252
|
+
state.namespaceStack.push({ namespaces });
|
|
253
|
+
}
|
|
283
254
|
}
|
|
284
255
|
|
|
285
|
-
function
|
|
286
|
-
|
|
287
|
-
|
|
256
|
+
function closeElement(
|
|
257
|
+
name: string,
|
|
258
|
+
startOffset: number,
|
|
259
|
+
tagEndOffset: number,
|
|
260
|
+
state: ParserState,
|
|
261
|
+
): void {
|
|
262
|
+
const top = state.stack.at(-1);
|
|
263
|
+
if (!top) {
|
|
264
|
+
state.errors.push(new Error(`Unexpected closing tag </${name}>.`));
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
288
267
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
if (
|
|
268
|
+
let matchIndex = -1;
|
|
269
|
+
for (let index = state.stack.length - 1; index >= 0; index -= 1) {
|
|
270
|
+
if (state.stack[index]?.name === name) {
|
|
271
|
+
matchIndex = index;
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
292
275
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
276
|
+
if (matchIndex < 0) {
|
|
277
|
+
state.errors.push(new Error(`Unexpected closing tag </${name}>; expected </${top.name}>.`));
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (matchIndex !== state.stack.length - 1) {
|
|
282
|
+
state.errors.push(new Error(`Mismatched closing tag </${name}>; expected </${top.name}>.`));
|
|
283
|
+
while (state.stack.length - 1 > matchIndex) {
|
|
284
|
+
const unclosed = state.stack.pop();
|
|
285
|
+
state.namespaceStack.pop();
|
|
286
|
+
if (unclosed) {
|
|
287
|
+
state.errors.push(
|
|
288
|
+
new Error(`Implicitly closed <${unclosed.name}> due to mismatched tag </${name}>.`),
|
|
289
|
+
);
|
|
290
|
+
}
|
|
296
291
|
}
|
|
292
|
+
}
|
|
297
293
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
294
|
+
const node = state.stack.pop();
|
|
295
|
+
state.namespaceStack.pop();
|
|
296
|
+
if (!node) return;
|
|
297
|
+
node.sourceRange.endOffset = tagEndOffset + 1;
|
|
298
|
+
node.endSource = sourceLocation(state, startOffset, node.source.path);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function appendText(raw: string, state: ParserState): void {
|
|
302
|
+
appendCharacterData(decodeXmlCharacterData(raw, state), state);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function appendCharacterData(text: string, state: ParserState): void {
|
|
306
|
+
if (text.length === 0) return;
|
|
307
|
+
const parent = state.stack.at(-1);
|
|
308
|
+
if (parent) {
|
|
309
|
+
parent.text += text;
|
|
310
|
+
parent.content.push(text);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (text.trim().length === 0) return;
|
|
314
|
+
if (state.root)
|
|
315
|
+
state.errors.push(new Error("XML document contains content after the root element."));
|
|
316
|
+
else state.errors.push(new Error("XML document contains content before the root element."));
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function parseAttributes(
|
|
320
|
+
xml: string,
|
|
321
|
+
from: number,
|
|
322
|
+
to: number,
|
|
323
|
+
state: ParserState,
|
|
324
|
+
): ParsedAttribute[] {
|
|
325
|
+
const attributes: ParsedAttribute[] = [];
|
|
326
|
+
let offset = from;
|
|
327
|
+
|
|
328
|
+
while (offset < to) {
|
|
329
|
+
while (offset < to && /\s/.test(xml.charAt(offset))) offset += 1;
|
|
330
|
+
if (offset >= to) break;
|
|
331
|
+
|
|
332
|
+
const nameStart = offset;
|
|
333
|
+
while (offset < to) {
|
|
334
|
+
const char = xml.charAt(offset);
|
|
335
|
+
if (/\s/.test(char) || char === "=" || char === "/" || char === ">") break;
|
|
336
|
+
offset += 1;
|
|
301
337
|
}
|
|
302
338
|
|
|
303
|
-
const
|
|
304
|
-
if (
|
|
305
|
-
|
|
339
|
+
const name = xml.slice(nameStart, offset);
|
|
340
|
+
if (!name) {
|
|
341
|
+
state.errors.push(new Error("Malformed XML attribute."));
|
|
342
|
+
offset += 1;
|
|
306
343
|
continue;
|
|
307
344
|
}
|
|
308
345
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
346
|
+
while (offset < to && /\s/.test(xml.charAt(offset))) offset += 1;
|
|
347
|
+
if (xml.charAt(offset) !== "=") {
|
|
348
|
+
state.errors.push(new Error(`Malformed XML attribute ${name}; expected =.`));
|
|
349
|
+
while (offset < to && !/\s/.test(xml.charAt(offset))) offset += 1;
|
|
312
350
|
continue;
|
|
313
351
|
}
|
|
352
|
+
offset += 1;
|
|
353
|
+
while (offset < to && /\s/.test(xml.charAt(offset))) offset += 1;
|
|
314
354
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
if (name) {
|
|
320
|
-
tokens.push({
|
|
321
|
-
kind: "end",
|
|
322
|
-
name,
|
|
323
|
-
startOffset,
|
|
324
|
-
startTagEndOffset: tagEndOffset,
|
|
325
|
-
endOffset: tagEndOffset + 1,
|
|
326
|
-
selfClosing: false,
|
|
327
|
-
});
|
|
328
|
-
}
|
|
329
|
-
offset = tagEndOffset + 1;
|
|
355
|
+
const quote = xml.charAt(offset);
|
|
356
|
+
if (quote !== '"' && quote !== "'") {
|
|
357
|
+
state.errors.push(new Error(`Malformed XML attribute ${name}; expected quoted value.`));
|
|
358
|
+
while (offset < to && !/\s/.test(xml.charAt(offset))) offset += 1;
|
|
330
359
|
continue;
|
|
331
360
|
}
|
|
361
|
+
offset += 1;
|
|
332
362
|
|
|
333
|
-
const
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
tokens.push({
|
|
339
|
-
kind: "start",
|
|
340
|
-
name,
|
|
341
|
-
startOffset,
|
|
342
|
-
startTagEndOffset: tagEndOffset,
|
|
343
|
-
endOffset: selfClosing ? tagEndOffset + 1 : undefined,
|
|
344
|
-
selfClosing,
|
|
345
|
-
});
|
|
363
|
+
const valueStart = offset;
|
|
364
|
+
const valueEnd = xml.indexOf(quote, valueStart);
|
|
365
|
+
if (valueEnd < 0 || valueEnd > to) {
|
|
366
|
+
state.errors.push(new Error(`Unterminated XML attribute ${name}.`));
|
|
367
|
+
break;
|
|
346
368
|
}
|
|
347
|
-
|
|
369
|
+
|
|
370
|
+
attributes.push({
|
|
371
|
+
name,
|
|
372
|
+
value: decodeXmlCharacterData(xml.slice(valueStart, valueEnd), state),
|
|
373
|
+
});
|
|
374
|
+
offset = valueEnd + 1;
|
|
348
375
|
}
|
|
349
376
|
|
|
350
|
-
return
|
|
377
|
+
return attributes;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const predefinedXmlEntities: Record<string, string> = {
|
|
381
|
+
amp: "&",
|
|
382
|
+
apos: "'",
|
|
383
|
+
gt: ">",
|
|
384
|
+
lt: "<",
|
|
385
|
+
quot: '"',
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
function decodeXmlCharacterData(value: string, state: ParserState): string {
|
|
389
|
+
return value.replace(
|
|
390
|
+
/&(#x[0-9a-fA-F]+|#X[0-9a-fA-F]+|#[0-9]+|[A-Za-z][A-Za-z0-9._:-]*);/g,
|
|
391
|
+
(entity, body: string) => {
|
|
392
|
+
if (body.startsWith("#x") || body.startsWith("#X")) {
|
|
393
|
+
return decodeNumericEntity(entity, Number.parseInt(body.slice(2), 16), state);
|
|
394
|
+
}
|
|
395
|
+
if (body.startsWith("#")) {
|
|
396
|
+
return decodeNumericEntity(entity, Number.parseInt(body.slice(1), 10), state);
|
|
397
|
+
}
|
|
398
|
+
return predefinedXmlEntities[body] ?? entity;
|
|
399
|
+
},
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function decodeNumericEntity(entity: string, codePoint: number, state: ParserState): string {
|
|
404
|
+
if (!Number.isFinite(codePoint) || !isXmlChar(codePoint)) {
|
|
405
|
+
state.errors.push(new Error(`Invalid XML character reference ${entity}.`));
|
|
406
|
+
return entity;
|
|
407
|
+
}
|
|
408
|
+
return String.fromCodePoint(codePoint);
|
|
351
409
|
}
|
|
352
410
|
|
|
353
|
-
function
|
|
354
|
-
|
|
355
|
-
|
|
411
|
+
function isXmlChar(codePoint: number): boolean {
|
|
412
|
+
return (
|
|
413
|
+
codePoint === 0x9 ||
|
|
414
|
+
codePoint === 0xa ||
|
|
415
|
+
codePoint === 0xd ||
|
|
416
|
+
(codePoint >= 0x20 && codePoint <= 0xd7ff) ||
|
|
417
|
+
(codePoint >= 0xe000 && codePoint <= 0xfffd) ||
|
|
418
|
+
(codePoint >= 0x10000 && codePoint <= 0x10ffff)
|
|
419
|
+
);
|
|
356
420
|
}
|
|
357
421
|
|
|
358
422
|
function findMarkupDeclarationEndOffset(xml: string, from: number): number {
|
|
@@ -411,27 +475,55 @@ function findTagEndOffset(xml: string, from: number): number {
|
|
|
411
475
|
}
|
|
412
476
|
|
|
413
477
|
function isSelfClosingStartTag(xml: string, tagEndOffset: number): boolean {
|
|
478
|
+
return xml.charAt(trailingSlashOffset(xml, tagEndOffset)) === "/";
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function trailingSlashOffset(xml: string, tagEndOffset: number): number {
|
|
414
482
|
for (let index = tagEndOffset - 1; index >= 0; index -= 1) {
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
return char === "/";
|
|
483
|
+
if (/\s/.test(xml.charAt(index))) continue;
|
|
484
|
+
return index;
|
|
418
485
|
}
|
|
419
|
-
return
|
|
486
|
+
return tagEndOffset;
|
|
420
487
|
}
|
|
421
488
|
|
|
422
|
-
function
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
489
|
+
function splitQualifiedName(name: string): {
|
|
490
|
+
prefix: string | undefined;
|
|
491
|
+
localName: string;
|
|
492
|
+
} {
|
|
493
|
+
const separator = name.indexOf(":");
|
|
494
|
+
if (separator < 0) return { prefix: undefined, localName: name };
|
|
495
|
+
return {
|
|
496
|
+
prefix: name.slice(0, separator),
|
|
497
|
+
localName: name.slice(separator + 1),
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function buildLineStarts(xml: string): number[] {
|
|
502
|
+
const lineStarts = [0];
|
|
503
|
+
for (let index = 0; index < xml.length; index += 1) {
|
|
504
|
+
if (xml.charAt(index) === "\n") lineStarts.push(index + 1);
|
|
505
|
+
}
|
|
506
|
+
return lineStarts;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function sourceLocation(state: ParserState, offset: number, path: string): XmlSourceLocation {
|
|
510
|
+
const normalizedOffset = Math.max(0, offset);
|
|
511
|
+
let low = 0;
|
|
512
|
+
let high = state.lineStarts.length - 1;
|
|
513
|
+
while (low <= high) {
|
|
514
|
+
const middle = Math.floor((low + high) / 2);
|
|
515
|
+
const lineStart = state.lineStarts[middle] ?? 0;
|
|
516
|
+
if (lineStart <= normalizedOffset) low = middle + 1;
|
|
517
|
+
else high = middle - 1;
|
|
433
518
|
}
|
|
434
|
-
|
|
519
|
+
const lineIndex = Math.max(0, high);
|
|
520
|
+
const lineStart = state.lineStarts[lineIndex] ?? 0;
|
|
521
|
+
return {
|
|
522
|
+
line: lineIndex + 1,
|
|
523
|
+
column: normalizedOffset - lineStart + 1,
|
|
524
|
+
offset: normalizedOffset,
|
|
525
|
+
path,
|
|
526
|
+
};
|
|
435
527
|
}
|
|
436
528
|
|
|
437
529
|
function nodePath(parent: XmlNode | undefined, localName: string): string {
|
|
@@ -439,3 +531,11 @@ function nodePath(parent: XmlNode | undefined, localName: string): string {
|
|
|
439
531
|
const index = parent.children.filter((child) => child.localName === localName).length + 1;
|
|
440
532
|
return `${parent.source.path}/${localName}[${index}]`;
|
|
441
533
|
}
|
|
534
|
+
|
|
535
|
+
export function escapeXmlText(value: string): string {
|
|
536
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
export function escapeXmlAttribute(value: string): string {
|
|
540
|
+
return escapeXmlText(value).replaceAll('"', """);
|
|
541
|
+
}
|