@longsightgroup/qti3-core 0.7.2 → 0.8.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/README.md +41 -10
- package/dist/content-text.d.ts +14 -0
- package/dist/content-text.d.ts.map +1 -0
- package/dist/content-text.js +58 -0
- package/dist/content-text.js.map +1 -0
- 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 +32 -1086
- 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 +50 -0
- package/dist/shared-vocabulary-authoring.d.ts.map +1 -0
- package/dist/shared-vocabulary-authoring.js +302 -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 +96 -56
- 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 +159 -10
- package/dist/support.js.map +1 -1
- package/dist/types.d.ts +38 -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 -249
- package/dist/xml.js.map +1 -1
- package/package.json +1 -4
- package/src/content-text.ts +80 -0
- package/src/index.ts +67 -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 +59 -1230
- 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 +409 -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 +207 -0
- package/src/shared-vocabulary-support.ts +127 -101
- package/src/shared-vocabulary.ts +18 -0
- package/src/support.ts +174 -14
- package/src/types.ts +48 -7
- package/src/validation.ts +33 -341
- package/src/xml.ts +378 -255
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,203 +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
|
-
"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
|
-
}
|
|
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;
|
|
176
70
|
|
|
177
|
-
|
|
71
|
+
while (offset < xml.length) {
|
|
72
|
+
const markupOffset = xml.indexOf("<", offset);
|
|
73
|
+
if (markupOffset < 0) {
|
|
74
|
+
appendText(xml.slice(offset), state);
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
178
77
|
|
|
179
|
-
|
|
180
|
-
|
|
78
|
+
if (markupOffset > offset) {
|
|
79
|
+
appendText(xml.slice(offset, markupOffset), state);
|
|
80
|
+
}
|
|
181
81
|
|
|
182
|
-
|
|
183
|
-
|
|
82
|
+
if (markupOffset + 1 >= xml.length) {
|
|
83
|
+
state.errors.push(new Error("Malformed XML tag at end of document."));
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
184
86
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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;
|
|
191
95
|
}
|
|
192
|
-
restored.push(entry);
|
|
193
|
-
const childEnd = entry.sourceRange.endOffset;
|
|
194
|
-
if (childEnd === undefined || childEnd < cursor) continue;
|
|
195
|
-
cursor = childEnd;
|
|
196
|
-
}
|
|
197
96
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
+
}
|
|
201
107
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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;
|
|
117
|
+
}
|
|
205
118
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
+
}
|
|
210
128
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
+
}
|
|
218
144
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : entity;
|
|
145
|
+
const endOffset = findTagEndOffset(xml, markupOffset + 1);
|
|
146
|
+
if (endOffset < 0) {
|
|
147
|
+
state.errors.push(new Error("Unterminated XML start tag."));
|
|
148
|
+
break;
|
|
224
149
|
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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;
|
|
228
155
|
}
|
|
229
|
-
|
|
230
|
-
|
|
156
|
+
|
|
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
|
+
}
|
|
167
|
+
|
|
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
|
+
}
|
|
173
|
+
|
|
174
|
+
if (!state.root) state.errors.push(new Error("XML document does not contain a root element."));
|
|
175
|
+
|
|
176
|
+
return { root: state.root, errors: state.errors };
|
|
231
177
|
}
|
|
232
178
|
|
|
233
179
|
export function childElements(node: XmlNode, localName?: string): XmlNode[] {
|
|
@@ -250,86 +196,227 @@ export function textContent(node: XmlNode): string {
|
|
|
250
196
|
return parts.join(" ").replace(/\s+/g, " ").trim();
|
|
251
197
|
}
|
|
252
198
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
startOffset: number
|
|
257
|
-
startTagEndOffset: number
|
|
258
|
-
|
|
259
|
-
|
|
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
|
+
}
|
|
260
254
|
}
|
|
261
255
|
|
|
262
|
-
function
|
|
263
|
-
|
|
264
|
-
|
|
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
|
+
}
|
|
265
267
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
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
|
+
}
|
|
269
275
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
+
}
|
|
273
291
|
}
|
|
292
|
+
}
|
|
274
293
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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;
|
|
278
337
|
}
|
|
279
338
|
|
|
280
|
-
const
|
|
281
|
-
if (
|
|
282
|
-
|
|
339
|
+
const name = xml.slice(nameStart, offset);
|
|
340
|
+
if (!name) {
|
|
341
|
+
state.errors.push(new Error("Malformed XML attribute."));
|
|
342
|
+
offset += 1;
|
|
283
343
|
continue;
|
|
284
344
|
}
|
|
285
345
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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;
|
|
289
350
|
continue;
|
|
290
351
|
}
|
|
352
|
+
offset += 1;
|
|
353
|
+
while (offset < to && /\s/.test(xml.charAt(offset))) offset += 1;
|
|
291
354
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
if (name) {
|
|
297
|
-
tokens.push({
|
|
298
|
-
kind: "end",
|
|
299
|
-
name,
|
|
300
|
-
startOffset,
|
|
301
|
-
startTagEndOffset: tagEndOffset,
|
|
302
|
-
endOffset: tagEndOffset + 1,
|
|
303
|
-
selfClosing: false,
|
|
304
|
-
});
|
|
305
|
-
}
|
|
306
|
-
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;
|
|
307
359
|
continue;
|
|
308
360
|
}
|
|
361
|
+
offset += 1;
|
|
309
362
|
|
|
310
|
-
const
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
tokens.push({
|
|
316
|
-
kind: "start",
|
|
317
|
-
name,
|
|
318
|
-
startOffset,
|
|
319
|
-
startTagEndOffset: tagEndOffset,
|
|
320
|
-
endOffset: selfClosing ? tagEndOffset + 1 : undefined,
|
|
321
|
-
selfClosing,
|
|
322
|
-
});
|
|
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;
|
|
323
368
|
}
|
|
324
|
-
|
|
369
|
+
|
|
370
|
+
attributes.push({
|
|
371
|
+
name,
|
|
372
|
+
value: decodeXmlCharacterData(xml.slice(valueStart, valueEnd), state),
|
|
373
|
+
});
|
|
374
|
+
offset = valueEnd + 1;
|
|
325
375
|
}
|
|
326
376
|
|
|
327
|
-
return
|
|
377
|
+
return attributes;
|
|
328
378
|
}
|
|
329
379
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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);
|
|
409
|
+
}
|
|
410
|
+
|
|
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
|
+
);
|
|
333
420
|
}
|
|
334
421
|
|
|
335
422
|
function findMarkupDeclarationEndOffset(xml: string, from: number): number {
|
|
@@ -388,27 +475,55 @@ function findTagEndOffset(xml: string, from: number): number {
|
|
|
388
475
|
}
|
|
389
476
|
|
|
390
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 {
|
|
391
482
|
for (let index = tagEndOffset - 1; index >= 0; index -= 1) {
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
return char === "/";
|
|
483
|
+
if (/\s/.test(xml.charAt(index))) continue;
|
|
484
|
+
return index;
|
|
395
485
|
}
|
|
396
|
-
return
|
|
486
|
+
return tagEndOffset;
|
|
397
487
|
}
|
|
398
488
|
|
|
399
|
-
function
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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);
|
|
410
505
|
}
|
|
411
|
-
return
|
|
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;
|
|
518
|
+
}
|
|
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
|
+
};
|
|
412
527
|
}
|
|
413
528
|
|
|
414
529
|
function nodePath(parent: XmlNode | undefined, localName: string): string {
|
|
@@ -416,3 +531,11 @@ function nodePath(parent: XmlNode | undefined, localName: string): string {
|
|
|
416
531
|
const index = parent.children.filter((child) => child.localName === localName).length + 1;
|
|
417
532
|
return `${parent.source.path}/${localName}[${index}]`;
|
|
418
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
|
+
}
|