@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.
Files changed (119) hide show
  1. package/README.md +41 -10
  2. package/dist/content-text.d.ts +7 -1
  3. package/dist/content-text.d.ts.map +1 -1
  4. package/dist/content-text.js +14 -0
  5. package/dist/content-text.js.map +1 -1
  6. package/dist/index.d.ts +7 -3
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +6 -2
  9. package/dist/index.js.map +1 -1
  10. package/dist/package-xml.d.ts +12 -0
  11. package/dist/package-xml.d.ts.map +1 -0
  12. package/dist/package-xml.js +17 -0
  13. package/dist/package-xml.js.map +1 -0
  14. package/dist/parser-custom-interactions.d.ts +8 -0
  15. package/dist/parser-custom-interactions.d.ts.map +1 -0
  16. package/dist/parser-custom-interactions.js +178 -0
  17. package/dist/parser-custom-interactions.js.map +1 -0
  18. package/dist/parser-declarations.d.ts +6 -0
  19. package/dist/parser-declarations.d.ts.map +1 -0
  20. package/dist/parser-declarations.js +136 -0
  21. package/dist/parser-declarations.js.map +1 -0
  22. package/dist/parser-item-metadata.d.ts +9 -0
  23. package/dist/parser-item-metadata.d.ts.map +1 -0
  24. package/dist/parser-item-metadata.js +158 -0
  25. package/dist/parser-item-metadata.js.map +1 -0
  26. package/dist/parser-processing.d.ts +5 -0
  27. package/dist/parser-processing.d.ts.map +1 -0
  28. package/dist/parser-processing.js +642 -0
  29. package/dist/parser-processing.js.map +1 -0
  30. package/dist/parser-values.d.ts +8 -0
  31. package/dist/parser-values.d.ts.map +1 -0
  32. package/dist/parser-values.js +49 -0
  33. package/dist/parser-values.js.map +1 -0
  34. package/dist/parser.d.ts.map +1 -1
  35. package/dist/parser.js +15 -1083
  36. package/dist/parser.js.map +1 -1
  37. package/dist/processing-expression-tags.d.ts +56 -0
  38. package/dist/processing-expression-tags.d.ts.map +1 -0
  39. package/dist/processing-expression-tags.js +52 -0
  40. package/dist/processing-expression-tags.js.map +1 -0
  41. package/dist/processing-rules.d.ts +3 -0
  42. package/dist/processing-rules.d.ts.map +1 -0
  43. package/dist/processing-rules.js +10 -0
  44. package/dist/processing-rules.js.map +1 -0
  45. package/dist/serializer-processing-expressions.d.ts +8 -0
  46. package/dist/serializer-processing-expressions.d.ts.map +1 -0
  47. package/dist/serializer-processing-expressions.js +198 -0
  48. package/dist/serializer-processing-expressions.js.map +1 -0
  49. package/dist/serializer-processing-xml.d.ts +15 -0
  50. package/dist/serializer-processing-xml.d.ts.map +1 -0
  51. package/dist/serializer-processing-xml.js +46 -0
  52. package/dist/serializer-processing-xml.js.map +1 -0
  53. package/dist/serializer-processing.d.ts +8 -0
  54. package/dist/serializer-processing.d.ts.map +1 -0
  55. package/dist/serializer-processing.js +106 -0
  56. package/dist/serializer-processing.js.map +1 -0
  57. package/dist/shared-vocabulary-authoring.d.ts +52 -0
  58. package/dist/shared-vocabulary-authoring.d.ts.map +1 -0
  59. package/dist/shared-vocabulary-authoring.js +327 -0
  60. package/dist/shared-vocabulary-authoring.js.map +1 -0
  61. package/dist/shared-vocabulary-interaction-sets.d.ts +6 -0
  62. package/dist/shared-vocabulary-interaction-sets.d.ts.map +1 -0
  63. package/dist/shared-vocabulary-interaction-sets.js +23 -0
  64. package/dist/shared-vocabulary-interaction-sets.js.map +1 -0
  65. package/dist/shared-vocabulary-interaction-validation.d.ts +3 -0
  66. package/dist/shared-vocabulary-interaction-validation.d.ts.map +1 -0
  67. package/dist/shared-vocabulary-interaction-validation.js +99 -0
  68. package/dist/shared-vocabulary-interaction-validation.js.map +1 -0
  69. package/dist/shared-vocabulary-registry-validation.d.ts +5 -0
  70. package/dist/shared-vocabulary-registry-validation.d.ts.map +1 -0
  71. package/dist/shared-vocabulary-registry-validation.js +147 -0
  72. package/dist/shared-vocabulary-registry-validation.js.map +1 -0
  73. package/dist/shared-vocabulary-support.d.ts.map +1 -1
  74. package/dist/shared-vocabulary-support.js +160 -67
  75. package/dist/shared-vocabulary-support.js.map +1 -1
  76. package/dist/shared-vocabulary.d.ts +6 -0
  77. package/dist/shared-vocabulary.d.ts.map +1 -1
  78. package/dist/shared-vocabulary.js +12 -0
  79. package/dist/shared-vocabulary.js.map +1 -1
  80. package/dist/support.d.ts +4 -1
  81. package/dist/support.d.ts.map +1 -1
  82. package/dist/support.js +130 -0
  83. package/dist/support.js.map +1 -1
  84. package/dist/types.d.ts +32 -7
  85. package/dist/types.d.ts.map +1 -1
  86. package/dist/validation.d.ts.map +1 -1
  87. package/dist/validation.js +29 -250
  88. package/dist/validation.js.map +1 -1
  89. package/dist/xml.d.ts +10 -0
  90. package/dist/xml.d.ts.map +1 -1
  91. package/dist/xml.js +321 -272
  92. package/dist/xml.js.map +1 -1
  93. package/package.json +1 -4
  94. package/src/content-text.ts +20 -1
  95. package/src/index.ts +68 -0
  96. package/src/interaction-test-fixtures.ts +44 -0
  97. package/src/package-xml.ts +30 -0
  98. package/src/parser-custom-interactions.ts +309 -0
  99. package/src/parser-declarations.ts +174 -0
  100. package/src/parser-item-metadata.ts +194 -0
  101. package/src/parser-processing.ts +701 -0
  102. package/src/parser-values.ts +61 -0
  103. package/src/parser.ts +36 -1227
  104. package/src/processing-expression-tags.ts +65 -0
  105. package/src/processing-rules.ts +11 -0
  106. package/src/serializer-processing-expressions.ts +456 -0
  107. package/src/serializer-processing-xml.ts +67 -0
  108. package/src/serializer-processing.fixtures.ts +469 -0
  109. package/src/serializer-processing.ts +196 -0
  110. package/src/shared-vocabulary-authoring.ts +438 -0
  111. package/src/shared-vocabulary-interaction-sets.ts +28 -0
  112. package/src/shared-vocabulary-interaction-validation.ts +143 -0
  113. package/src/shared-vocabulary-registry-validation.ts +210 -0
  114. package/src/shared-vocabulary-support.ts +200 -127
  115. package/src/shared-vocabulary.ts +18 -0
  116. package/src/support.ts +144 -0
  117. package/src/types.ts +42 -7
  118. package/src/validation.ts +33 -341
  119. 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
- export function parseXmlTree(xml: string): { root: XmlNode | undefined; errors: Error[] } {
35
- const parser = new StaxXmlParserSync(xml, {
36
- autoDecodeEntities: true,
37
- });
38
- const tagTokens = scanXmlTagTokens(xml);
39
- const stack: XmlNode[] = [];
40
- const errors: Error[] = [];
41
- let root: XmlNode | undefined;
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
- if (root) restoreMixedContentFromSource(xml, root);
41
+ interface ParsedAttribute {
42
+ name: string;
43
+ value: string;
44
+ }
124
45
 
125
- return { root, errors };
46
+ interface NamespaceFrame {
47
+ namespaces: Record<string, string>;
126
48
  }
127
49
 
128
50
  /**
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.
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
- 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
- "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
- function restoreMixedContentFromSource(xml: string, node: XmlNode): void {
179
- for (const entry of node.content) {
180
- if (typeof entry !== "string") restoreMixedContentFromSource(xml, entry);
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
- restoreLeafTextFromSource(xml, node);
78
+ if (markupOffset > offset) {
79
+ appendText(xml.slice(offset, markupOffset), state);
80
+ }
184
81
 
185
- if (!shouldRestoreMixedContentWhitespace(node)) return;
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
- const contentEndOffset = node.endSource?.offset ?? node.sourceRange.endOffset;
188
- if (node.sourceRange.startTagEndOffset < 0 || contentEndOffset === undefined) return;
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
- const restored: Array<string | XmlNode> = [];
191
- let cursor = node.sourceRange.startTagEndOffset + 1;
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
- for (const entry of node.content) {
194
- if (typeof entry === "string") continue;
195
- const childStart = entry.sourceRange.startOffset;
196
- if (childStart < 0) continue;
197
- if (childStart > cursor) {
198
- appendDecodedTextSegment(restored, xml.slice(cursor, childStart));
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
- if (contentEndOffset > cursor) {
207
- appendDecodedTextSegment(restored, xml.slice(cursor, contentEndOffset));
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
- node.content = restored;
211
- node.text = restored.filter((entry): entry is string => typeof entry === "string").join("");
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
- function restoreLeafTextFromSource(xml: string, node: XmlNode): void {
215
- if (node.children.length > 0) return;
216
- const contentEndOffset = node.endSource?.offset ?? node.sourceRange.endOffset;
217
- if (node.sourceRange.startTagEndOffset < 0 || contentEndOffset === undefined) return;
218
- const raw = xml.slice(node.sourceRange.startTagEndOffset + 1, contentEndOffset);
219
- if (raw.includes("<![CDATA[")) return;
220
- const decoded = decodeXmlCharacterData(stripNonCharacterMarkup(raw));
221
- node.text = decoded;
222
- node.content = decoded.length > 0 ? [decoded] : [];
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
- function appendDecodedTextSegment(content: Array<string | XmlNode>, raw: string): void {
226
- const decoded = decodeXmlCharacterData(stripNonCharacterMarkup(raw));
227
- if (decoded.length > 0) content.push(decoded);
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
- function stripNonCharacterMarkup(value: string): string {
231
- return value.replace(/<!--[\s\S]*?-->/g, "").replace(/<\?[\s\S]*?\?>/g, "");
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
- const predefinedXmlEntities: Record<string, string> = {
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
- function decodeXmlCharacterData(value: string): string {
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
- interface XmlTagToken {
277
- kind: "start" | "end";
278
- name: string;
279
- startOffset: number;
280
- startTagEndOffset: number;
281
- endOffset?: number | undefined;
282
- selfClosing: boolean;
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 scanXmlTagTokens(xml: string): XmlTagToken[] {
286
- const tokens: XmlTagToken[] = [];
287
- let offset = 0;
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
- while (offset < xml.length) {
290
- const startOffset = xml.indexOf("<", offset);
291
- if (startOffset === -1 || startOffset + 1 >= xml.length) return tokens;
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
- if (xml.startsWith("<!--", startOffset)) {
294
- offset = skipPastSequence(xml, "-->", startOffset + 4);
295
- continue;
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
- if (xml.startsWith("<![CDATA[", startOffset)) {
299
- offset = skipPastSequence(xml, "]]>", startOffset + 9);
300
- continue;
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 next = xml.charAt(startOffset + 1);
304
- if (next === "?") {
305
- offset = skipPastSequence(xml, "?>", startOffset + 2);
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
- if (next === "!") {
310
- const declarationEndOffset = findMarkupDeclarationEndOffset(xml, startOffset + 2);
311
- offset = declarationEndOffset >= 0 ? declarationEndOffset + 1 : xml.length;
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
- if (next === "/") {
316
- const tagEndOffset = findTagEndOffset(xml, startOffset + 2);
317
- if (tagEndOffset < 0) return tokens;
318
- const name = readTagName(xml, startOffset + 2, tagEndOffset);
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 tagEndOffset = findTagEndOffset(xml, startOffset + 1);
334
- if (tagEndOffset < 0) return tokens;
335
- const name = readTagName(xml, startOffset + 1, tagEndOffset);
336
- if (name) {
337
- const selfClosing = isSelfClosingStartTag(xml, tagEndOffset);
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
- offset = tagEndOffset + 1;
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 tokens;
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 skipPastSequence(xml: string, sequence: string, from: number): number {
354
- const endOffset = xml.indexOf(sequence, from);
355
- return endOffset >= 0 ? endOffset + sequence.length : xml.length;
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
- const char = xml.charAt(index);
416
- if (/\s/.test(char)) continue;
417
- return char === "/";
483
+ if (/\s/.test(xml.charAt(index))) continue;
484
+ return index;
418
485
  }
419
- return false;
486
+ return tagEndOffset;
420
487
  }
421
488
 
422
- function sourceLocation(xml: string, offset: number, path: string): XmlSourceLocation {
423
- if (offset < 0) return { line: 1, column: 1, offset: 0, path };
424
- let line = 1;
425
- let column = 1;
426
- for (let index = 0; index < offset; index += 1) {
427
- if (xml.charAt(index) === "\n") {
428
- line += 1;
429
- column = 1;
430
- } else {
431
- column += 1;
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
- return { line, column, offset, path };
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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
537
+ }
538
+
539
+ export function escapeXmlAttribute(value: string): string {
540
+ return escapeXmlText(value).replaceAll('"', "&quot;");
541
+ }