@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.
Files changed (119) hide show
  1. package/README.md +41 -10
  2. package/dist/content-text.d.ts +14 -0
  3. package/dist/content-text.d.ts.map +1 -0
  4. package/dist/content-text.js +58 -0
  5. package/dist/content-text.js.map +1 -0
  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 +32 -1086
  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 +50 -0
  58. package/dist/shared-vocabulary-authoring.d.ts.map +1 -0
  59. package/dist/shared-vocabulary-authoring.js +302 -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 +96 -56
  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 +159 -10
  83. package/dist/support.js.map +1 -1
  84. package/dist/types.d.ts +38 -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 -249
  92. package/dist/xml.js.map +1 -1
  93. package/package.json +1 -4
  94. package/src/content-text.ts +80 -0
  95. package/src/index.ts +67 -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 +59 -1230
  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 +409 -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 +207 -0
  114. package/src/shared-vocabulary-support.ts +127 -101
  115. package/src/shared-vocabulary.ts +18 -0
  116. package/src/support.ts +174 -14
  117. package/src/types.ts +48 -7
  118. package/src/validation.ts +33 -341
  119. 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
- 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
- "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
- if (!shouldRestoreMixedContentWhitespace(node)) return;
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
- const contentEndOffset = node.endSource?.offset ?? node.sourceRange.endOffset;
180
- if (node.sourceRange.startTagEndOffset < 0 || contentEndOffset === undefined) return;
78
+ if (markupOffset > offset) {
79
+ appendText(xml.slice(offset, markupOffset), state);
80
+ }
181
81
 
182
- const restored: Array<string | XmlNode> = [];
183
- let cursor = node.sourceRange.startTagEndOffset + 1;
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
- for (const entry of node.content) {
186
- if (typeof entry === "string") continue;
187
- const childStart = entry.sourceRange.startOffset;
188
- if (childStart < 0) continue;
189
- if (childStart > cursor) {
190
- appendDecodedTextSegment(restored, xml.slice(cursor, childStart));
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
- if (contentEndOffset > cursor) {
199
- appendDecodedTextSegment(restored, xml.slice(cursor, contentEndOffset));
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
- node.content = restored;
203
- node.text = restored.filter((entry): entry is string => typeof entry === "string").join("");
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
- function appendDecodedTextSegment(content: Array<string | XmlNode>, raw: string): void {
207
- const decoded = decodeXmlCharacterData(raw);
208
- if (decoded.length > 0) content.push(decoded);
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
- const predefinedXmlEntities: Record<string, string> = {
212
- amp: "&",
213
- apos: "'",
214
- gt: ">",
215
- lt: "<",
216
- quot: '"',
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
- function decodeXmlCharacterData(value: string): string {
220
- return value.replace(/&(#x?[0-9a-fA-F]+|[A-Za-z]+);/g, (entity, body: string) => {
221
- if (body.startsWith("#x") || body.startsWith("#X")) {
222
- const codePoint = Number.parseInt(body.slice(2), 16);
223
- return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : entity;
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
- if (body.startsWith("#")) {
226
- const codePoint = Number.parseInt(body.slice(1), 10);
227
- return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : entity;
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
- return predefinedXmlEntities[body] ?? entity;
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
- interface XmlTagToken {
254
- kind: "start" | "end";
255
- name: string;
256
- startOffset: number;
257
- startTagEndOffset: number;
258
- endOffset?: number | undefined;
259
- 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
+ }
260
254
  }
261
255
 
262
- function scanXmlTagTokens(xml: string): XmlTagToken[] {
263
- const tokens: XmlTagToken[] = [];
264
- 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
+ }
265
267
 
266
- while (offset < xml.length) {
267
- const startOffset = xml.indexOf("<", offset);
268
- 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
+ }
269
275
 
270
- if (xml.startsWith("<!--", startOffset)) {
271
- offset = skipPastSequence(xml, "-->", startOffset + 4);
272
- 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
+ }
273
291
  }
292
+ }
274
293
 
275
- if (xml.startsWith("<![CDATA[", startOffset)) {
276
- offset = skipPastSequence(xml, "]]>", startOffset + 9);
277
- 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;
278
337
  }
279
338
 
280
- const next = xml.charAt(startOffset + 1);
281
- if (next === "?") {
282
- 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;
283
343
  continue;
284
344
  }
285
345
 
286
- if (next === "!") {
287
- const declarationEndOffset = findMarkupDeclarationEndOffset(xml, startOffset + 2);
288
- 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;
289
350
  continue;
290
351
  }
352
+ offset += 1;
353
+ while (offset < to && /\s/.test(xml.charAt(offset))) offset += 1;
291
354
 
292
- if (next === "/") {
293
- const tagEndOffset = findTagEndOffset(xml, startOffset + 2);
294
- if (tagEndOffset < 0) return tokens;
295
- const name = readTagName(xml, startOffset + 2, tagEndOffset);
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 tagEndOffset = findTagEndOffset(xml, startOffset + 1);
311
- if (tagEndOffset < 0) return tokens;
312
- const name = readTagName(xml, startOffset + 1, tagEndOffset);
313
- if (name) {
314
- const selfClosing = isSelfClosingStartTag(xml, tagEndOffset);
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
- offset = tagEndOffset + 1;
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 tokens;
377
+ return attributes;
328
378
  }
329
379
 
330
- function skipPastSequence(xml: string, sequence: string, from: number): number {
331
- const endOffset = xml.indexOf(sequence, from);
332
- return endOffset >= 0 ? endOffset + sequence.length : xml.length;
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
- const char = xml.charAt(index);
393
- if (/\s/.test(char)) continue;
394
- return char === "/";
483
+ if (/\s/.test(xml.charAt(index))) continue;
484
+ return index;
395
485
  }
396
- return false;
486
+ return tagEndOffset;
397
487
  }
398
488
 
399
- function sourceLocation(xml: string, offset: number, path: string): XmlSourceLocation {
400
- if (offset < 0) return { line: 1, column: 1, offset: 0, path };
401
- let line = 1;
402
- let column = 1;
403
- for (let index = 0; index < offset; index += 1) {
404
- if (xml.charAt(index) === "\n") {
405
- line += 1;
406
- column = 1;
407
- } else {
408
- column += 1;
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 { line, column, offset, path };
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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
537
+ }
538
+
539
+ export function escapeXmlAttribute(value: string): string {
540
+ return escapeXmlText(value).replaceAll('"', "&quot;");
541
+ }