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