@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.
@@ -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 `&#10;` 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
+ }