@sdxc/xml 0.0.0-pre.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.
- package/LICENSE.md +21 -0
- package/README.md +250 -0
- package/dist/index.d.ts +130 -0
- package/dist/index.js +167 -0
- package/dist/lib/clone-declaration.d.ts +14 -0
- package/dist/lib/clone-declaration.js +17 -0
- package/dist/lib/clone-element.d.ts +14 -0
- package/dist/lib/clone-element.js +21 -0
- package/dist/lib/decode-entities.d.ts +16 -0
- package/dist/lib/decode-entities.js +90 -0
- package/dist/lib/escape-xml.d.ts +25 -0
- package/dist/lib/escape-xml.js +44 -0
- package/dist/lib/html-entities.d.ts +17 -0
- package/dist/lib/html-entities.js +269 -0
- package/dist/lib/parse-document.d.ts +17 -0
- package/dist/lib/parse-document.js +296 -0
- package/dist/lib/stringify-document.d.ts +17 -0
- package/dist/lib/stringify-document.js +120 -0
- package/dist/lib/traversal.d.ts +47 -0
- package/dist/lib/traversal.js +90 -0
- package/dist/lib/xml-names.d.ts +25 -0
- package/dist/lib/xml-names.js +38 -0
- package/package.json +22 -0
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parses XML text into plain document data by scanning the source directly, so
|
|
3
|
+
* the package runs anywhere JavaScript does, workerd included. Covers the subset
|
|
4
|
+
* RSS and similar feeds use: one root, attributes, text, CDATA, prefixed names.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import { failure, success } from "@sdxc/result";
|
|
10
|
+
import { decodeEntities } from "./decode-entities.js";
|
|
11
|
+
import { matchName } from "./xml-names.js";
|
|
12
|
+
const XML_DECLARATION_PATTERN = /^\s*<\?xml\s+([^?]+)\?>/i;
|
|
13
|
+
const XML_DECLARATION_ATTRIBUTE_PATTERN = /([a-zA-Z_:][\w:.-]*)\s*=\s*(["'])(.*?)\2/g;
|
|
14
|
+
/**
|
|
15
|
+
* Literal tabs and line breaks inside an attribute value are collapsed to spaces
|
|
16
|
+
* before references are resolved, so ` ` still survives as a line break.
|
|
17
|
+
*/
|
|
18
|
+
const ATTRIBUTE_WHITESPACE_PATTERN = /[\t\n\r]/g;
|
|
19
|
+
const WHITESPACE_PATTERN = /\s/;
|
|
20
|
+
/**
|
|
21
|
+
* Parses XML into plain document data.
|
|
22
|
+
*
|
|
23
|
+
* @param source - Raw XML text to parse
|
|
24
|
+
* @returns A Result containing XML document data or an error
|
|
25
|
+
*/
|
|
26
|
+
export function parseDocument(source) {
|
|
27
|
+
let root = parseRoot(source);
|
|
28
|
+
if (root.status === "failure")
|
|
29
|
+
return root;
|
|
30
|
+
return success({ declaration: parseDeclaration(source), root: root.data });
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Walks the source once, building the element tree on a stack of open elements.
|
|
34
|
+
* Text and CDATA that hold only whitespace are dropped, which keeps indentation
|
|
35
|
+
* out of the tree and leaves feed traversal working on elements alone.
|
|
36
|
+
*/
|
|
37
|
+
function parseRoot(source) {
|
|
38
|
+
let stack = [];
|
|
39
|
+
let root;
|
|
40
|
+
let index = 0;
|
|
41
|
+
while (index < source.length) {
|
|
42
|
+
if (source[index] !== "<") {
|
|
43
|
+
let text = readText(source, index);
|
|
44
|
+
if (text.status === "failure")
|
|
45
|
+
return text;
|
|
46
|
+
let parent = stack.at(-1);
|
|
47
|
+
if (parent)
|
|
48
|
+
parent.children?.push(...text.data.value);
|
|
49
|
+
else if (text.data.value[0])
|
|
50
|
+
return failure(strayContent(root, text.data.value[0]));
|
|
51
|
+
index = text.data.next;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (source.startsWith("<!--", index)) {
|
|
55
|
+
let skipped = skipUntil(source, index, "-->", "comment");
|
|
56
|
+
if (skipped.status === "failure")
|
|
57
|
+
return skipped;
|
|
58
|
+
index = skipped.data;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (source.startsWith("<![CDATA[", index)) {
|
|
62
|
+
let section = readCDATA(source, index);
|
|
63
|
+
if (section.status === "failure")
|
|
64
|
+
return section;
|
|
65
|
+
stack.at(-1)?.children?.push(...section.data.value);
|
|
66
|
+
index = section.data.next;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (source.startsWith("<?", index)) {
|
|
70
|
+
let skipped = skipUntil(source, index, "?>", "processing instruction");
|
|
71
|
+
if (skipped.status === "failure")
|
|
72
|
+
return skipped;
|
|
73
|
+
index = skipped.data;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (source.startsWith("<!", index)) {
|
|
77
|
+
let skipped = skipDoctype(source, index);
|
|
78
|
+
if (skipped.status === "failure")
|
|
79
|
+
return skipped;
|
|
80
|
+
index = skipped.data;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (source.startsWith("</", index)) {
|
|
84
|
+
let closing = readClosingTag(source, index);
|
|
85
|
+
if (closing.status === "failure")
|
|
86
|
+
return closing;
|
|
87
|
+
let open = stack.at(-1);
|
|
88
|
+
if (!open)
|
|
89
|
+
return failure(new Error(`Unexpected closing tag "${closing.data.value}".`));
|
|
90
|
+
if (open.name !== closing.data.value) {
|
|
91
|
+
return failure(new Error(`Opening and ending tag mismatch: "${open.name}" != "${closing.data.value}"`));
|
|
92
|
+
}
|
|
93
|
+
stack.pop();
|
|
94
|
+
index = closing.data.next;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
let opening = readOpeningTag(source, index);
|
|
98
|
+
if (opening.status === "failure")
|
|
99
|
+
return opening;
|
|
100
|
+
let element = {
|
|
101
|
+
name: opening.data.value.name,
|
|
102
|
+
attributes: opening.data.value.attributes,
|
|
103
|
+
children: [],
|
|
104
|
+
};
|
|
105
|
+
let parent = stack.at(-1);
|
|
106
|
+
if (parent)
|
|
107
|
+
parent.children?.push(element);
|
|
108
|
+
else if (root)
|
|
109
|
+
return failure(new Error("Extra content at the end of the document"));
|
|
110
|
+
else
|
|
111
|
+
root = element;
|
|
112
|
+
if (!opening.data.value.selfClosing)
|
|
113
|
+
stack.push(element);
|
|
114
|
+
index = opening.data.next;
|
|
115
|
+
}
|
|
116
|
+
if (stack.length > 0) {
|
|
117
|
+
return failure(new Error(`unclosed xml tag(s): ${stack.map((open) => open.name).join(", ")}`));
|
|
118
|
+
}
|
|
119
|
+
if (!root)
|
|
120
|
+
return failure(new Error("missing root element"));
|
|
121
|
+
return success(root);
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Reads the character data up to the next `<`, resolving references first so a
|
|
125
|
+
* run that decodes to nothing but whitespace is dropped along with plain indentation.
|
|
126
|
+
*/
|
|
127
|
+
function readText(source, index) {
|
|
128
|
+
let end = source.indexOf("<", index);
|
|
129
|
+
let stop = end === -1 ? source.length : end;
|
|
130
|
+
let decoded = decodeEntities(source.slice(index, stop));
|
|
131
|
+
if (decoded.status === "failure")
|
|
132
|
+
return decoded;
|
|
133
|
+
let kept = decoded.data.trim().length > 0 ? [decoded.data] : [];
|
|
134
|
+
return success({ value: kept, next: stop });
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Reads a CDATA section, whose content reaches the tree verbatim because CDATA
|
|
138
|
+
* exists precisely to carry markup as literal text.
|
|
139
|
+
*/
|
|
140
|
+
function readCDATA(source, index) {
|
|
141
|
+
let start = index + "<![CDATA[".length;
|
|
142
|
+
let end = source.indexOf("]]>", start);
|
|
143
|
+
if (end === -1)
|
|
144
|
+
return failure(new Error("Unterminated CDATA section"));
|
|
145
|
+
let content = source.slice(start, end);
|
|
146
|
+
let kept = content.trim().length > 0 ? [content] : [];
|
|
147
|
+
return success({ value: kept, next: end + "]]>".length });
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Skips past a construct the tree leaves out, such as a comment or a processing
|
|
151
|
+
* instruction, and names it when the source leaves it unterminated.
|
|
152
|
+
*/
|
|
153
|
+
function skipUntil(source, index, terminator, label) {
|
|
154
|
+
let end = source.indexOf(terminator, index);
|
|
155
|
+
if (end === -1)
|
|
156
|
+
return failure(new Error(`Unterminated ${label}`));
|
|
157
|
+
return success(end + terminator.length);
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Skips a doctype declaration, tracking the internal subset so the declaration
|
|
161
|
+
* ends at the `>` that closes it.
|
|
162
|
+
*/
|
|
163
|
+
function skipDoctype(source, index) {
|
|
164
|
+
let depth = 0;
|
|
165
|
+
for (let cursor = index; cursor < source.length; cursor++) {
|
|
166
|
+
let character = source[cursor];
|
|
167
|
+
if (character === "[")
|
|
168
|
+
depth++;
|
|
169
|
+
if (character === "]")
|
|
170
|
+
depth--;
|
|
171
|
+
if (character === ">" && depth <= 0)
|
|
172
|
+
return success(cursor + 1);
|
|
173
|
+
}
|
|
174
|
+
return failure(new Error("Unterminated doctype declaration"));
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Reads a closing tag and reports the name it closes.
|
|
178
|
+
*/
|
|
179
|
+
function readClosingTag(source, index) {
|
|
180
|
+
let name = matchName(source, index + "</".length);
|
|
181
|
+
if (!name)
|
|
182
|
+
return failure(new Error("Expected an element name after `</`."));
|
|
183
|
+
let cursor = skipWhitespace(source, index + "</".length + name.length);
|
|
184
|
+
if (source[cursor] !== ">")
|
|
185
|
+
return failure(new Error(`Unterminated closing tag "${name}".`));
|
|
186
|
+
return success({ value: name, next: cursor + 1 });
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Reads a start tag with its attributes, reporting whether it closes itself.
|
|
190
|
+
*/
|
|
191
|
+
function readOpeningTag(source, index) {
|
|
192
|
+
let name = matchName(source, index + 1);
|
|
193
|
+
if (!name)
|
|
194
|
+
return failure(new Error("Expected an element name after `<`."));
|
|
195
|
+
let attributes = {};
|
|
196
|
+
let cursor = index + 1 + name.length;
|
|
197
|
+
while (cursor < source.length) {
|
|
198
|
+
let afterWhitespace = skipWhitespace(source, cursor);
|
|
199
|
+
if (source.startsWith("/>", afterWhitespace)) {
|
|
200
|
+
return success({
|
|
201
|
+
value: { name, attributes, selfClosing: true },
|
|
202
|
+
next: afterWhitespace + 2,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
if (source[afterWhitespace] === ">") {
|
|
206
|
+
return success({
|
|
207
|
+
value: { name, attributes, selfClosing: false },
|
|
208
|
+
next: afterWhitespace + 1,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
if (afterWhitespace === cursor) {
|
|
212
|
+
return failure(new Error(`Expected whitespace between attributes of "${name}".`));
|
|
213
|
+
}
|
|
214
|
+
let attribute = readAttribute(source, afterWhitespace, name);
|
|
215
|
+
if (attribute.status === "failure")
|
|
216
|
+
return attribute;
|
|
217
|
+
if (attribute.data.value.name in attributes) {
|
|
218
|
+
return failure(new Error(`Attribute ${attribute.data.value.name} redefined`));
|
|
219
|
+
}
|
|
220
|
+
attributes[attribute.data.value.name] = attribute.data.value.value;
|
|
221
|
+
cursor = attribute.data.next;
|
|
222
|
+
}
|
|
223
|
+
return failure(new Error(`Unterminated opening tag "${name}".`));
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Reads one `name="value"` pair. The value keeps its quote style out of the tree
|
|
227
|
+
* and arrives with whitespace normalized and references resolved.
|
|
228
|
+
*/
|
|
229
|
+
function readAttribute(source, index, elementName) {
|
|
230
|
+
let name = matchName(source, index);
|
|
231
|
+
if (!name)
|
|
232
|
+
return failure(new Error(`Expected an attribute name in "${elementName}".`));
|
|
233
|
+
let cursor = skipWhitespace(source, index + name.length);
|
|
234
|
+
if (source[cursor] !== "=")
|
|
235
|
+
return failure(new Error(`attribute "${name}" missed value!`));
|
|
236
|
+
cursor = skipWhitespace(source, cursor + 1);
|
|
237
|
+
let quote = source[cursor];
|
|
238
|
+
if (quote !== '"' && quote !== "'") {
|
|
239
|
+
return failure(new Error(`attribute "${name}" missed quot(")!`));
|
|
240
|
+
}
|
|
241
|
+
let end = source.indexOf(quote, cursor + 1);
|
|
242
|
+
if (end === -1)
|
|
243
|
+
return failure(new Error(`attribute "${name}" missed quot(")!`));
|
|
244
|
+
let raw = source.slice(cursor + 1, end).replace(ATTRIBUTE_WHITESPACE_PATTERN, " ");
|
|
245
|
+
let decoded = decodeEntities(raw);
|
|
246
|
+
if (decoded.status === "failure")
|
|
247
|
+
return decoded;
|
|
248
|
+
return success({ value: { name, value: decoded.data }, next: end + 1 });
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Advances past any run of whitespace and reports where it ended.
|
|
252
|
+
*/
|
|
253
|
+
function skipWhitespace(source, index) {
|
|
254
|
+
let cursor = index;
|
|
255
|
+
while (cursor < source.length && WHITESPACE_PATTERN.test(source[cursor] ?? ""))
|
|
256
|
+
cursor++;
|
|
257
|
+
return cursor;
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Names the failure for text found outside the root, which reads differently
|
|
261
|
+
* depending on whether the root has already been opened.
|
|
262
|
+
*/
|
|
263
|
+
function strayContent(root, text) {
|
|
264
|
+
if (root)
|
|
265
|
+
return new Error("Extra content at the end of the document");
|
|
266
|
+
return new Error(`Unexpected content outside root element: '${text}'`);
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Extracts the XML declaration, which sits ahead of the tree and so is read
|
|
270
|
+
* straight from the source.
|
|
271
|
+
*/
|
|
272
|
+
function parseDeclaration(source) {
|
|
273
|
+
let match = source.match(XML_DECLARATION_PATTERN);
|
|
274
|
+
if (!match?.[1])
|
|
275
|
+
return undefined;
|
|
276
|
+
let declaration = {};
|
|
277
|
+
let attributes = match[1];
|
|
278
|
+
let attributeMatch = XML_DECLARATION_ATTRIBUTE_PATTERN.exec(attributes);
|
|
279
|
+
while (attributeMatch) {
|
|
280
|
+
let name = attributeMatch[1];
|
|
281
|
+
let value = attributeMatch[3];
|
|
282
|
+
if (name === "version")
|
|
283
|
+
declaration.version = value;
|
|
284
|
+
if (name === "encoding")
|
|
285
|
+
declaration.encoding = value;
|
|
286
|
+
if (name === "standalone" && (value === "yes" || value === "no")) {
|
|
287
|
+
declaration.standalone = value;
|
|
288
|
+
}
|
|
289
|
+
attributeMatch = XML_DECLARATION_ATTRIBUTE_PATTERN.exec(attributes);
|
|
290
|
+
}
|
|
291
|
+
XML_DECLARATION_ATTRIBUTE_PATTERN.lastIndex = 0;
|
|
292
|
+
if (!declaration.version && !declaration.encoding && !declaration.standalone) {
|
|
293
|
+
return undefined;
|
|
294
|
+
}
|
|
295
|
+
return declaration;
|
|
296
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serializes plain XML document data into text, emitting the markup directly so
|
|
3
|
+
* the package runs anywhere JavaScript does, workerd included. Namespace prefixes
|
|
4
|
+
* are checked against the declarations in scope, names against `Name`.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import type { Result } from "@sdxc/result";
|
|
10
|
+
import type { XML } from "../index.js";
|
|
11
|
+
/**
|
|
12
|
+
* Serializes plain XML document data into a string.
|
|
13
|
+
*
|
|
14
|
+
* @param input - The document data to serialize
|
|
15
|
+
* @returns A Result containing the XML string or an error
|
|
16
|
+
*/
|
|
17
|
+
export declare function stringifyDocument(input: XML.Document): Result<string, Error>;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serializes plain XML document data into text, emitting the markup directly so
|
|
3
|
+
* the package runs anywhere JavaScript does, workerd included. Namespace prefixes
|
|
4
|
+
* are checked against the declarations in scope, names against `Name`.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import { failure, success } from "@sdxc/result";
|
|
10
|
+
import { escapeAttribute, escapeText } from "./escape-xml.js";
|
|
11
|
+
import { isValidName } from "./xml-names.js";
|
|
12
|
+
/**
|
|
13
|
+
* `xml` and `xmlns` are bound by the specification itself, so every document may
|
|
14
|
+
* use them directly.
|
|
15
|
+
*/
|
|
16
|
+
const BUILT_IN_PREFIXES = ["xml", "xmlns"];
|
|
17
|
+
/**
|
|
18
|
+
* Serializes plain XML document data into a string.
|
|
19
|
+
*
|
|
20
|
+
* @param input - The document data to serialize
|
|
21
|
+
* @returns A Result containing the XML string or an error
|
|
22
|
+
*/
|
|
23
|
+
export function stringifyDocument(input) {
|
|
24
|
+
if (!isValidName(input.root.name)) {
|
|
25
|
+
return failure(new Error(`Invalid root element name "${input.root.name}".`));
|
|
26
|
+
}
|
|
27
|
+
let root = stringifyElement(input.root, new Set(BUILT_IN_PREFIXES));
|
|
28
|
+
if (root.status === "failure")
|
|
29
|
+
return root;
|
|
30
|
+
let declaration = stringifyDeclaration(input.declaration);
|
|
31
|
+
if (!declaration)
|
|
32
|
+
return root;
|
|
33
|
+
return success(`${declaration}\n${root.data}`);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Writes one element and everything under it, threading the namespace prefixes
|
|
37
|
+
* declared so far down the tree so a child can use a prefix an ancestor declared.
|
|
38
|
+
*/
|
|
39
|
+
function stringifyElement(element, inheritedPrefixes) {
|
|
40
|
+
let attributes = element.attributes ?? {};
|
|
41
|
+
let children = element.children ?? [];
|
|
42
|
+
let prefixes = extendPrefixes(attributes, inheritedPrefixes);
|
|
43
|
+
let elementPrefix = prefixOf(element.name);
|
|
44
|
+
if (elementPrefix && !prefixes.has(elementPrefix)) {
|
|
45
|
+
return failure(new Error(`Missing namespace declaration for prefix "${elementPrefix}" on element "${element.name}".`));
|
|
46
|
+
}
|
|
47
|
+
let serializedAttributes = stringifyAttributes(attributes, prefixes);
|
|
48
|
+
if (serializedAttributes.status === "failure")
|
|
49
|
+
return serializedAttributes;
|
|
50
|
+
let open = `<${element.name}${serializedAttributes.data}`;
|
|
51
|
+
if (children.length === 0)
|
|
52
|
+
return success(`${open}/>`);
|
|
53
|
+
let content = "";
|
|
54
|
+
for (let child of children) {
|
|
55
|
+
if (typeof child === "string") {
|
|
56
|
+
content += escapeText(child);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (!isValidName(child.name)) {
|
|
60
|
+
return failure(new Error(`Invalid element name "${child.name}".`));
|
|
61
|
+
}
|
|
62
|
+
let serializedChild = stringifyElement(child, prefixes);
|
|
63
|
+
if (serializedChild.status === "failure")
|
|
64
|
+
return serializedChild;
|
|
65
|
+
content += serializedChild.data;
|
|
66
|
+
}
|
|
67
|
+
return success(`${open}>${content}</${element.name}>`);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Writes the attribute list in declaration order, which keeps a serialized feed
|
|
71
|
+
* byte-for-byte stable between runs.
|
|
72
|
+
*/
|
|
73
|
+
function stringifyAttributes(attributes, prefixes) {
|
|
74
|
+
let serialized = "";
|
|
75
|
+
for (let [name, value] of Object.entries(attributes)) {
|
|
76
|
+
if (!isValidName(name))
|
|
77
|
+
return failure(new Error(`Invalid attribute name "${name}".`));
|
|
78
|
+
let prefix = prefixOf(name);
|
|
79
|
+
if (prefix && !prefixes.has(prefix)) {
|
|
80
|
+
return failure(new Error(`Missing namespace declaration for prefix "${prefix}" on attribute "${name}".`));
|
|
81
|
+
}
|
|
82
|
+
serialized += ` ${name}="${escapeAttribute(value)}"`;
|
|
83
|
+
}
|
|
84
|
+
return success(serialized);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Collects the prefixes an element declares, so they cover the element itself
|
|
88
|
+
* and everything nested inside it.
|
|
89
|
+
*/
|
|
90
|
+
function extendPrefixes(attributes, inheritedPrefixes) {
|
|
91
|
+
let prefixes = new Set(inheritedPrefixes);
|
|
92
|
+
for (let name of Object.keys(attributes)) {
|
|
93
|
+
if (name.startsWith("xmlns:"))
|
|
94
|
+
prefixes.add(name.slice("xmlns:".length));
|
|
95
|
+
}
|
|
96
|
+
return prefixes;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Reads the namespace prefix off a qualified name. An unprefixed name belongs to
|
|
100
|
+
* the default namespace, which every element already has in scope.
|
|
101
|
+
*/
|
|
102
|
+
function prefixOf(name) {
|
|
103
|
+
let separator = name.indexOf(":");
|
|
104
|
+
if (separator === -1)
|
|
105
|
+
return undefined;
|
|
106
|
+
return name.slice(0, separator);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Converts the declaration object into a stable XML declaration string.
|
|
110
|
+
*/
|
|
111
|
+
function stringifyDeclaration(declaration) {
|
|
112
|
+
if (!declaration)
|
|
113
|
+
return undefined;
|
|
114
|
+
let attributes = [`version="${declaration.version ?? "1.0"}"`];
|
|
115
|
+
if (declaration.encoding)
|
|
116
|
+
attributes.push(`encoding="${declaration.encoding}"`);
|
|
117
|
+
if (declaration.standalone)
|
|
118
|
+
attributes.push(`standalone="${declaration.standalone}"`);
|
|
119
|
+
return `<?xml ${attributes.join(" ")}?>`;
|
|
120
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Traverses and queries XML element trees by predicate or by `/`-delimited
|
|
3
|
+
* child-name path.
|
|
4
|
+
*
|
|
5
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
6
|
+
* @copyright Sergio Xalambrí 2026
|
|
7
|
+
*/
|
|
8
|
+
import type { XML } from "../index.js";
|
|
9
|
+
/**
|
|
10
|
+
* Returns the first element matching the predicate in depth-first order.
|
|
11
|
+
*
|
|
12
|
+
* @param element - The root element to traverse
|
|
13
|
+
* @param predicate - The predicate to match against
|
|
14
|
+
* @returns The first matching element, if one exists
|
|
15
|
+
*/
|
|
16
|
+
export declare function findInElement(element: XML.Element, predicate: XML.Predicate): XML.Element | undefined;
|
|
17
|
+
/**
|
|
18
|
+
* Collects all elements matching the predicate in depth-first order.
|
|
19
|
+
*
|
|
20
|
+
* @param element - The root element to traverse
|
|
21
|
+
* @param predicate - The predicate to match against
|
|
22
|
+
* @param matches - The array to append matches to
|
|
23
|
+
*/
|
|
24
|
+
export declare function collectInElement(element: XML.Element, predicate: XML.Predicate, matches: XML.Element[]): void;
|
|
25
|
+
/**
|
|
26
|
+
* Normalizes a `/`-delimited path into non-empty segments.
|
|
27
|
+
*
|
|
28
|
+
* @param path - The raw query path
|
|
29
|
+
* @returns The normalized path segments
|
|
30
|
+
*/
|
|
31
|
+
export declare function normalizePath(path: string): string[];
|
|
32
|
+
/**
|
|
33
|
+
* Checks whether the path is rooted at the provided root element name.
|
|
34
|
+
*
|
|
35
|
+
* @param segments - The normalized path segments
|
|
36
|
+
* @param rootName - The root element name
|
|
37
|
+
* @returns `true` when the first segment matches the root name
|
|
38
|
+
*/
|
|
39
|
+
export declare function startsWithRoot(segments: string[], rootName: string): boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Traverses the tree by exact child-name matches for each path segment.
|
|
42
|
+
*
|
|
43
|
+
* @param elements - The current set of elements to match from
|
|
44
|
+
* @param segments - The remaining path segments
|
|
45
|
+
* @returns The elements that match the full path
|
|
46
|
+
*/
|
|
47
|
+
export declare function queryFromElements(elements: XML.Element[], segments: string[]): XML.Element[];
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Traverses and queries XML element trees by predicate or by `/`-delimited
|
|
3
|
+
* child-name path.
|
|
4
|
+
*
|
|
5
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
6
|
+
* @copyright Sergio Xalambrí 2026
|
|
7
|
+
*/
|
|
8
|
+
import { cloneElement } from "./clone-element.js";
|
|
9
|
+
/**
|
|
10
|
+
* Returns the first element matching the predicate in depth-first order.
|
|
11
|
+
*
|
|
12
|
+
* @param element - The root element to traverse
|
|
13
|
+
* @param predicate - The predicate to match against
|
|
14
|
+
* @returns The first matching element, if one exists
|
|
15
|
+
*/
|
|
16
|
+
export function findInElement(element, predicate) {
|
|
17
|
+
if (predicate(element))
|
|
18
|
+
return cloneElement(element);
|
|
19
|
+
for (let child of element.children ?? []) {
|
|
20
|
+
if (typeof child === "string")
|
|
21
|
+
continue;
|
|
22
|
+
let match = findInElement(child, predicate);
|
|
23
|
+
if (match)
|
|
24
|
+
return match;
|
|
25
|
+
}
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Collects all elements matching the predicate in depth-first order.
|
|
30
|
+
*
|
|
31
|
+
* @param element - The root element to traverse
|
|
32
|
+
* @param predicate - The predicate to match against
|
|
33
|
+
* @param matches - The array to append matches to
|
|
34
|
+
*/
|
|
35
|
+
export function collectInElement(element, predicate, matches) {
|
|
36
|
+
if (predicate(element))
|
|
37
|
+
matches.push(cloneElement(element));
|
|
38
|
+
for (let child of element.children ?? []) {
|
|
39
|
+
if (typeof child === "string")
|
|
40
|
+
continue;
|
|
41
|
+
collectInElement(child, predicate, matches);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Normalizes a `/`-delimited path into non-empty segments.
|
|
46
|
+
*
|
|
47
|
+
* @param path - The raw query path
|
|
48
|
+
* @returns The normalized path segments
|
|
49
|
+
*/
|
|
50
|
+
export function normalizePath(path) {
|
|
51
|
+
return path
|
|
52
|
+
.split("/")
|
|
53
|
+
.map((segment) => segment.trim())
|
|
54
|
+
.filter((segment) => segment.length > 0);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Checks whether the path is rooted at the provided root element name.
|
|
58
|
+
*
|
|
59
|
+
* @param segments - The normalized path segments
|
|
60
|
+
* @param rootName - The root element name
|
|
61
|
+
* @returns `true` when the first segment matches the root name
|
|
62
|
+
*/
|
|
63
|
+
export function startsWithRoot(segments, rootName) {
|
|
64
|
+
return segments[0] === rootName;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Traverses the tree by exact child-name matches for each path segment.
|
|
68
|
+
*
|
|
69
|
+
* @param elements - The current set of elements to match from
|
|
70
|
+
* @param segments - The remaining path segments
|
|
71
|
+
* @returns The elements that match the full path
|
|
72
|
+
*/
|
|
73
|
+
export function queryFromElements(elements, segments) {
|
|
74
|
+
if (segments.length === 0)
|
|
75
|
+
return elements;
|
|
76
|
+
let [segment, ...rest] = segments;
|
|
77
|
+
let matches = [];
|
|
78
|
+
for (let element of elements) {
|
|
79
|
+
for (let child of element.children ?? []) {
|
|
80
|
+
if (typeof child === "string")
|
|
81
|
+
continue;
|
|
82
|
+
if (child.name !== segment)
|
|
83
|
+
continue;
|
|
84
|
+
matches.push(child);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (rest.length === 0)
|
|
88
|
+
return matches;
|
|
89
|
+
return queryFromElements(matches, rest);
|
|
90
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Implements the XML `Name` production, which both reading and writing depend on:
|
|
3
|
+
* the parser uses it to find where a tag or attribute name ends, and the
|
|
4
|
+
* serializer uses it to confirm a name can be written back out as valid XML.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Reads the name starting at `index`, anchored there so the parser advances
|
|
11
|
+
* through the source one name at a time.
|
|
12
|
+
*
|
|
13
|
+
* @param source - The full XML text being parsed
|
|
14
|
+
* @param index - Offset the name is expected to start at
|
|
15
|
+
* @returns The name, or `undefined` when no name starts there
|
|
16
|
+
*/
|
|
17
|
+
export declare function matchName(source: string, index: number): string | undefined;
|
|
18
|
+
/**
|
|
19
|
+
* Reports whether a name can be written into a tag or attribute as-is, so the
|
|
20
|
+
* serializer emits output that parses back into the tree it was given.
|
|
21
|
+
*
|
|
22
|
+
* @param value - The element or attribute name to check
|
|
23
|
+
* @returns Whether the whole value is one XML name
|
|
24
|
+
*/
|
|
25
|
+
export declare function isValidName(value: string): boolean;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Implements the XML `Name` production, which both reading and writing depend on:
|
|
3
|
+
* the parser uses it to find where a tag or attribute name ends, and the
|
|
4
|
+
* serializer uses it to confirm a name can be written back out as valid XML.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* The `:` belongs to the production itself, so a namespace-prefixed name such as
|
|
11
|
+
* `content:encoded` is one name here and the prefix is resolved separately.
|
|
12
|
+
*/
|
|
13
|
+
const NAME_START = "A-Za-z_:\\u00C0-\\u02FF\\u0370-\\u1FFF\\u200C-\\u200D\\u2070-\\u218F\\u2C00-\\u2FEF\\u3001-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFFD";
|
|
14
|
+
const NAME_REST = `${NAME_START}0-9.\\-\\u00B7\\u0300-\\u036F\\u203F-\\u2040`;
|
|
15
|
+
const SCANNING_PATTERN = new RegExp(`[${NAME_START}][${NAME_REST}]*`, "y");
|
|
16
|
+
const EXACT_PATTERN = new RegExp(`^[${NAME_START}][${NAME_REST}]*$`);
|
|
17
|
+
/**
|
|
18
|
+
* Reads the name starting at `index`, anchored there so the parser advances
|
|
19
|
+
* through the source one name at a time.
|
|
20
|
+
*
|
|
21
|
+
* @param source - The full XML text being parsed
|
|
22
|
+
* @param index - Offset the name is expected to start at
|
|
23
|
+
* @returns The name, or `undefined` when no name starts there
|
|
24
|
+
*/
|
|
25
|
+
export function matchName(source, index) {
|
|
26
|
+
SCANNING_PATTERN.lastIndex = index;
|
|
27
|
+
return SCANNING_PATTERN.exec(source)?.[0];
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Reports whether a name can be written into a tag or attribute as-is, so the
|
|
31
|
+
* serializer emits output that parses back into the tree it was given.
|
|
32
|
+
*
|
|
33
|
+
* @param value - The element or attribute name to check
|
|
34
|
+
* @returns Whether the whole value is one XML name
|
|
35
|
+
*/
|
|
36
|
+
export function isValidName(value) {
|
|
37
|
+
return EXACT_PATTERN.test(value);
|
|
38
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sdxc/xml",
|
|
3
|
+
"version": "0.0.0-pre.1",
|
|
4
|
+
"description": "XML parser and serializer for RSS-style feeds",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@sdxc/result": "2026.9.5"
|
|
12
|
+
},
|
|
13
|
+
"gitHead": "6b352367c6853be4019c3a3ab761185df8ab5ab8",
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/sergiodxa/monorepo.git",
|
|
20
|
+
"directory": "packages/xml"
|
|
21
|
+
}
|
|
22
|
+
}
|