@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/dist/xml.js CHANGED
@@ -1,214 +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
- "math",
110
- "mi",
111
- "mn",
112
- "mo",
113
- "mrow",
114
- "msup",
115
- "q",
116
- "rp",
117
- "rt",
118
- "ruby",
119
- "s",
120
- "samp",
121
- "small",
122
- "span",
123
- "strong",
124
- "sub",
125
- "sup",
126
- "var",
127
- "qti-feedback-inline",
128
- "qti-gap",
129
- "qti-hottext",
130
- "qti-inline-choice-interaction",
131
- "qti-printed-variable",
132
- "qti-template-inline",
133
- "qti-text-entry-interaction",
134
- ]);
135
- function shouldRestoreMixedContentWhitespace(node) {
136
- return node.content.some((entry) => typeof entry !== "string" && inlineMixedContentChildNames.has(entry.localName));
137
- }
138
- function restoreMixedContentFromSource(xml, node) {
139
- for (const entry of node.content) {
140
- if (typeof entry !== "string")
141
- restoreMixedContentFromSource(xml, entry);
142
- }
143
- restoreLeafTextFromSource(xml, node);
144
- if (!shouldRestoreMixedContentWhitespace(node))
145
- return;
146
- const contentEndOffset = node.endSource?.offset ?? node.sourceRange.endOffset;
147
- if (node.sourceRange.startTagEndOffset < 0 || contentEndOffset === undefined)
148
- return;
149
- const restored = [];
150
- let cursor = node.sourceRange.startTagEndOffset + 1;
151
- for (const entry of node.content) {
152
- 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;
153
70
  continue;
154
- const childStart = entry.sourceRange.startOffset;
155
- 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;
156
86
  continue;
157
- if (childStart > cursor) {
158
- appendDecodedTextSegment(restored, xml.slice(cursor, childStart));
159
87
  }
160
- restored.push(entry);
161
- const childEnd = entry.sourceRange.endOffset;
162
- 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;
163
97
  continue;
164
- 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;
165
103
  }
166
- if (contentEndOffset > cursor) {
167
- 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}>.`));
168
106
  }
169
- node.content = restored;
170
- node.text = restored.filter((entry) => typeof entry === "string").join("");
171
- }
172
- function restoreLeafTextFromSource(xml, node) {
173
- if (node.children.length > 0)
174
- return;
175
- const contentEndOffset = node.endSource?.offset ?? node.sourceRange.endOffset;
176
- if (node.sourceRange.startTagEndOffset < 0 || contentEndOffset === undefined)
177
- return;
178
- const raw = xml.slice(node.sourceRange.startTagEndOffset + 1, contentEndOffset);
179
- if (raw.includes("<![CDATA["))
180
- return;
181
- const decoded = decodeXmlCharacterData(stripNonCharacterMarkup(raw));
182
- node.text = decoded;
183
- node.content = decoded.length > 0 ? [decoded] : [];
184
- }
185
- function appendDecodedTextSegment(content, raw) {
186
- const decoded = decodeXmlCharacterData(stripNonCharacterMarkup(raw));
187
- if (decoded.length > 0)
188
- content.push(decoded);
189
- }
190
- function stripNonCharacterMarkup(value) {
191
- return value.replace(/<!--[\s\S]*?-->/g, "").replace(/<\?[\s\S]*?\?>/g, "");
192
- }
193
- const predefinedXmlEntities = {
194
- amp: "&",
195
- apos: "'",
196
- gt: ">",
197
- lt: "<",
198
- quot: '"',
199
- };
200
- function decodeXmlCharacterData(value) {
201
- return value.replace(/&(#x?[0-9a-fA-F]+|[A-Za-z]+);/g, (entity, body) => {
202
- if (body.startsWith("#x") || body.startsWith("#X")) {
203
- const codePoint = Number.parseInt(body.slice(2), 16);
204
- return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : entity;
205
- }
206
- if (body.startsWith("#")) {
207
- const codePoint = Number.parseInt(body.slice(1), 10);
208
- return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : entity;
209
- }
210
- return predefinedXmlEntities[body] ?? entity;
211
- });
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 };
212
110
  }
213
111
  export function childElements(node, localName) {
214
112
  return node.children.filter((child) => !localName || child.localName === localName);
@@ -226,71 +124,192 @@ export function textContent(node) {
226
124
  const parts = node.content.map((entry) => typeof entry === "string" ? entry : textContent(entry));
227
125
  return parts.join(" ").replace(/\s+/g, " ").trim();
228
126
  }
229
- function scanXmlTagTokens(xml) {
230
- const tokens = [];
231
- let offset = 0;
232
- while (offset < xml.length) {
233
- const startOffset = xml.indexOf("<", offset);
234
- if (startOffset === -1 || startOffset + 1 >= xml.length)
235
- return tokens;
236
- if (xml.startsWith("<!--", startOffset)) {
237
- offset = skipPastSequence(xml, "-->", startOffset + 4);
238
- 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;
239
185
  }
240
- if (xml.startsWith("<![CDATA[", startOffset)) {
241
- offset = skipPastSequence(xml, "]]>", startOffset + 9);
242
- 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
+ }
243
199
  }
244
- const next = xml.charAt(startOffset + 1);
245
- if (next === "?") {
246
- 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;
247
246
  continue;
248
247
  }
249
- if (next === "!") {
250
- const declarationEndOffset = findMarkupDeclarationEndOffset(xml, startOffset + 2);
251
- 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;
252
254
  continue;
253
255
  }
254
- if (next === "/") {
255
- const tagEndOffset = findTagEndOffset(xml, startOffset + 2);
256
- if (tagEndOffset < 0)
257
- return tokens;
258
- const name = readTagName(xml, startOffset + 2, tagEndOffset);
259
- if (name) {
260
- tokens.push({
261
- kind: "end",
262
- name,
263
- startOffset,
264
- startTagEndOffset: tagEndOffset,
265
- endOffset: tagEndOffset + 1,
266
- selfClosing: false,
267
- });
268
- }
269
- 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;
270
264
  continue;
271
265
  }
272
- const tagEndOffset = findTagEndOffset(xml, startOffset + 1);
273
- if (tagEndOffset < 0)
274
- return tokens;
275
- const name = readTagName(xml, startOffset + 1, tagEndOffset);
276
- if (name) {
277
- const selfClosing = isSelfClosingStartTag(xml, tagEndOffset);
278
- tokens.push({
279
- kind: "start",
280
- name,
281
- startOffset,
282
- startTagEndOffset: tagEndOffset,
283
- endOffset: selfClosing ? tagEndOffset + 1 : undefined,
284
- selfClosing,
285
- });
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;
272
+ }
273
+ attributes.push({
274
+ name,
275
+ value: decodeXmlCharacterData(xml.slice(valueStart, valueEnd), state),
276
+ });
277
+ offset = valueEnd + 1;
278
+ }
279
+ return attributes;
280
+ }
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);
286
295
  }
287
- offset = tagEndOffset + 1;
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;
288
303
  }
289
- return tokens;
304
+ return String.fromCodePoint(codePoint);
290
305
  }
291
- function skipPastSequence(xml, sequence, from) {
292
- const endOffset = xml.indexOf(sequence, from);
293
- return endOffset >= 0 ? endOffset + sequence.length : xml.length;
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));
294
313
  }
295
314
  function findMarkupDeclarationEndOffset(xml, from) {
296
315
  let quote = null;
@@ -351,29 +370,53 @@ function findTagEndOffset(xml, from) {
351
370
  return -1;
352
371
  }
353
372
  function isSelfClosingStartTag(xml, tagEndOffset) {
373
+ return xml.charAt(trailingSlashOffset(xml, tagEndOffset)) === "/";
374
+ }
375
+ function trailingSlashOffset(xml, tagEndOffset) {
354
376
  for (let index = tagEndOffset - 1; index >= 0; index -= 1) {
355
- const char = xml.charAt(index);
356
- if (/\s/.test(char))
377
+ if (/\s/.test(xml.charAt(index)))
357
378
  continue;
358
- return char === "/";
379
+ return index;
359
380
  }
360
- return false;
381
+ return tagEndOffset;
361
382
  }
362
- function sourceLocation(xml, offset, path) {
363
- if (offset < 0)
364
- return { line: 1, column: 1, offset: 0, path };
365
- let line = 1;
366
- let column = 1;
367
- for (let index = 0; index < offset; index += 1) {
368
- if (xml.charAt(index) === "\n") {
369
- line += 1;
370
- column = 1;
371
- }
372
- else {
373
- column += 1;
374
- }
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);
375
397
  }
376
- return { line, column, offset, path };
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;
411
+ }
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
+ };
377
420
  }
378
421
  function nodePath(parent, localName) {
379
422
  if (!parent)
@@ -381,4 +424,10 @@ function nodePath(parent, localName) {
381
424
  const index = parent.children.filter((child) => child.localName === localName).length + 1;
382
425
  return `${parent.source.path}/${localName}[${index}]`;
383
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
+ }
384
433
  //# sourceMappingURL=xml.js.map