@flighthq/xml 0.1.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.
@@ -0,0 +1,3 @@
1
+ export type { XmlElement } from './xmlParse';
2
+ export { parseXmlAttributes, parseXmlDocument } from './xmlParse';
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAC7C,OAAO,EAAE,kBAAkB,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { parseXmlAttributes, parseXmlDocument } from './xmlParse';
2
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,kBAAkB,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC"}
@@ -0,0 +1,16 @@
1
+ export interface XmlElement {
2
+ attributes: Record<string, string>;
3
+ /** Direct child elements. Text content and comments are discarded as elements. */
4
+ children: XmlElement[];
5
+ name: string;
6
+ /** Raw text content (trimmed), concatenation of text nodes. */
7
+ text: string;
8
+ }
9
+ /** Parse all attributes from an element's attribute string.
10
+ * Supports double-quoted and single-quoted values and XML entity escapes. */
11
+ export declare function parseXmlAttributes(attrs: string): Record<string, string>;
12
+ /** Parse a simple XML document into a tree of XmlElement objects.
13
+ * Returns the root element, or null when the input contains no recognizable element.
14
+ * Does not validate DTD, namespaces, or processing instructions. */
15
+ export declare function parseXmlDocument(xml: string): XmlElement | null;
16
+ //# sourceMappingURL=xmlParse.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"xmlParse.d.ts","sourceRoot":"","sources":["../src/xmlParse.ts"],"names":[],"mappings":"AAMA,MAAM,WAAW,UAAU;IACzB,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACnC,kFAAkF;IAClF,QAAQ,EAAE,UAAU,EAAE,CAAC;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,+DAA+D;IAC/D,IAAI,EAAE,MAAM,CAAC;CACd;AAED;8EAC8E;AAC9E,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAWxE;AAED;;qEAEqE;AACrE,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,UAAU,GAAG,IAAI,CAW/D"}
@@ -0,0 +1,120 @@
1
+ // Pull-style XML parser sufficient for atlas and plist file formats.
2
+ // Not a general-purpose XML parser, but handles namespaced/extra attributes and elements,
3
+ // both double-quoted and single-quoted attribute values, XML entity escapes
4
+ // (&amp; &lt; &gt; &quot; &apos; plus numeric references), XML comments (<!-- -->),
5
+ // CDATA sections (<![CDATA[...]]>), the XML declaration, and DOCTYPE.
6
+ /** Parse all attributes from an element's attribute string.
7
+ * Supports double-quoted and single-quoted values and XML entity escapes. */
8
+ export function parseXmlAttributes(attrs) {
9
+ const result = {};
10
+ // Match name="value" or name='value', capturing both quote styles
11
+ const re = /([\w:.-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
12
+ let m;
13
+ while ((m = re.exec(attrs)) !== null) {
14
+ const attrName = m[1];
15
+ const value = m[2] !== undefined ? m[2] : (m[3] ?? '');
16
+ result[attrName] = decodeXmlEntities(value);
17
+ }
18
+ return result;
19
+ }
20
+ /** Parse a simple XML document into a tree of XmlElement objects.
21
+ * Returns the root element, or null when the input contains no recognizable element.
22
+ * Does not validate DTD, namespaces, or processing instructions. */
23
+ export function parseXmlDocument(xml) {
24
+ // Normalize: strip comments, process CDATA, normalize line endings
25
+ let src = stripCdata(stripXmlComments(xml)).replace(/\r\n?/g, '\n');
26
+ // Strip XML declaration and DOCTYPE if present
27
+ src = src
28
+ .replace(/<\?[\s\S]*?\?>/g, '')
29
+ .replace(/<!DOCTYPE[^>]*>/gi, '')
30
+ .trim();
31
+ return parseElement(src, { pos: 0 });
32
+ }
33
+ const XML_ENTITIES = {
34
+ amp: '&',
35
+ apos: "'",
36
+ gt: '>',
37
+ lt: '<',
38
+ quot: '"',
39
+ };
40
+ function decodeXmlEntities(s) {
41
+ return s.replace(/&(?:#(\d+)|#x([\da-fA-F]+)|(\w+));/g, (_, dec, hex, name) => {
42
+ if (dec)
43
+ return String.fromCodePoint(parseInt(dec, 10));
44
+ if (hex)
45
+ return String.fromCodePoint(parseInt(hex, 16));
46
+ return XML_ENTITIES[name] ?? _;
47
+ });
48
+ }
49
+ function parseElement(src, state) {
50
+ skipWhitespace(src, state);
51
+ if (state.pos >= src.length || src[state.pos] !== '<')
52
+ return null;
53
+ // Find the end of the opening tag
54
+ state.pos++; // consume '<'
55
+ // Skip processing instructions
56
+ if (src[state.pos] === '?') {
57
+ const end = src.indexOf('?>', state.pos);
58
+ state.pos = end >= 0 ? end + 2 : src.length;
59
+ return parseElement(src, state);
60
+ }
61
+ // Read element name
62
+ const nameStart = state.pos;
63
+ while (state.pos < src.length && !/[\s>/]/.test(src[state.pos]))
64
+ state.pos++;
65
+ const name = src.slice(nameStart, state.pos);
66
+ if (!name)
67
+ return null;
68
+ skipWhitespace(src, state);
69
+ // Read attributes (everything up to '>' or '/')
70
+ let attrsStr = '';
71
+ while (state.pos < src.length && src[state.pos] !== '>' && !(src[state.pos] === '/' && src[state.pos + 1] === '>')) {
72
+ attrsStr += src[state.pos];
73
+ state.pos++;
74
+ }
75
+ const selfClosing = src[state.pos] === '/';
76
+ state.pos += selfClosing ? 2 : 1; // consume '/>' or '>'
77
+ const attributes = parseXmlAttributes(attrsStr);
78
+ const children = [];
79
+ let text = '';
80
+ if (!selfClosing) {
81
+ // Parse children until closing tag
82
+ while (state.pos < src.length) {
83
+ skipWhitespace(src, state);
84
+ if (state.pos >= src.length)
85
+ break;
86
+ if (src[state.pos] !== '<') {
87
+ // Text node
88
+ const textStart = state.pos;
89
+ while (state.pos < src.length && src[state.pos] !== '<')
90
+ state.pos++;
91
+ text += decodeXmlEntities(src.slice(textStart, state.pos).trim());
92
+ continue;
93
+ }
94
+ // Check for closing tag
95
+ if (src[state.pos + 1] === '/') {
96
+ // Skip to '>'
97
+ while (state.pos < src.length && src[state.pos] !== '>')
98
+ state.pos++;
99
+ state.pos++; // consume '>'
100
+ break;
101
+ }
102
+ const child = parseElement(src, state);
103
+ if (child)
104
+ children.push(child);
105
+ }
106
+ }
107
+ return { attributes, children, name, text };
108
+ }
109
+ function skipWhitespace(src, state) {
110
+ while (state.pos < src.length && /\s/.test(src[state.pos]))
111
+ state.pos++;
112
+ }
113
+ function stripCdata(xml) {
114
+ // Replace CDATA with its raw content
115
+ return xml.replace(/<!\[CDATA\[[\s\S]*?]]>/g, (m) => m.slice(9, m.length - 3));
116
+ }
117
+ function stripXmlComments(xml) {
118
+ return xml.replace(/<!--[\s\S]*?-->/g, '');
119
+ }
120
+ //# sourceMappingURL=xmlParse.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"xmlParse.js","sourceRoot":"","sources":["../src/xmlParse.ts"],"names":[],"mappings":"AAAA,qEAAqE;AACrE,0FAA0F;AAC1F,4EAA4E;AAC5E,oFAAoF;AACpF,sEAAsE;AAWtE;8EAC8E;AAC9E,MAAM,UAAU,kBAAkB,CAAC,KAAa;IAC9C,MAAM,MAAM,GAA2B,EAAE,CAAC;IAC1C,kEAAkE;IAClE,MAAM,EAAE,GAAG,2CAA2C,CAAC;IACvD,IAAI,CAAyB,CAAC;IAC9B,OAAO,CAAC,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,KAAK,IAAI,EAAE,CAAC;QACrC,MAAM,QAAQ,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;QACtB,MAAM,KAAK,GAAG,CAAC,CAAC,CAAC,CAAC,KAAK,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC;QACvD,MAAM,CAAC,QAAQ,CAAC,GAAG,iBAAiB,CAAC,KAAK,CAAC,CAAC;IAC9C,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC;AAED;;qEAEqE;AACrE,MAAM,UAAU,gBAAgB,CAAC,GAAW;IAC1C,mEAAmE;IACnE,IAAI,GAAG,GAAG,UAAU,CAAC,gBAAgB,CAAC,GAAG,CAAC,CAAC,CAAC,OAAO,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAC;IAEpE,+CAA+C;IAC/C,GAAG,GAAG,GAAG;SACN,OAAO,CAAC,iBAAiB,EAAE,EAAE,CAAC;SAC9B,OAAO,CAAC,mBAAmB,EAAE,EAAE,CAAC;SAChC,IAAI,EAAE,CAAC;IAEV,OAAO,YAAY,CAAC,GAAG,EAAE,EAAE,GAAG,EAAE,CAAC,EAAE,CAAC,CAAC;AACvC,CAAC;AAMD,MAAM,YAAY,GAA2B;IAC3C,GAAG,EAAE,GAAG;IACR,IAAI,EAAE,GAAG;IACT,EAAE,EAAE,GAAG;IACP,EAAE,EAAE,GAAG;IACP,IAAI,EAAE,GAAG;CACV,CAAC;AAEF,SAAS,iBAAiB,CAAC,CAAS;IAClC,OAAO,CAAC,CAAC,OAAO,CAAC,qCAAqC,EAAE,CAAC,CAAC,EAAE,GAAG,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE;QAC5E,IAAI,GAAG;YAAE,OAAO,MAAM,CAAC,aAAa,CAAC,QAAQ,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC,CAAC;QACxD,IAAI,GAAG;YAAE,OAAO,MAAM,CAAC,aAAa,CAAC,QAAQ,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC,CAAC;QACxD,OAAO,YAAY,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACjC,CAAC,CAAC,CAAC;AACL,CAAC;AAED,SAAS,YAAY,CAAC,GAAW,EAAE,KAAiB;IAClD,cAAc,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;IAC3B,IAAI,KAAK,CAAC,GAAG,IAAI,GAAG,CAAC,MAAM,IAAI,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,KAAK,GAAG;QAAE,OAAO,IAAI,CAAC;IAEnE,kCAAkC;IAClC,KAAK,CAAC,GAAG,EAAE,CAAC,CAAC,cAAc;IAE3B,+BAA+B;IAC/B,IAAI,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,KAAK,GAAG,EAAE,CAAC;QAC3B,MAAM,GAAG,GAAG,GAAG,CAAC,OAAO,CAAC,IAAI,EAAE,KAAK,CAAC,GAAG,CAAC,CAAC;QACzC,KAAK,CAAC,GAAG,GAAG,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC;QAC5C,OAAO,YAAY,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;IAClC,CAAC;IAED,oBAAoB;IACpB,MAAM,SAAS,GAAG,KAAK,CAAC,GAAG,CAAC;IAC5B,OAAO,KAAK,CAAC,GAAG,GAAG,GAAG,CAAC,MAAM,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;QAAE,KAAK,CAAC,GAAG,EAAE,CAAC;IAC7E,MAAM,IAAI,GAAG,GAAG,CAAC,KAAK,CAAC,SAAS,EAAE,KAAK,CAAC,GAAG,CAAC,CAAC;IAC7C,IAAI,CAAC,IAAI;QAAE,OAAO,IAAI,CAAC;IAEvB,cAAc,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;IAE3B,gDAAgD;IAChD,IAAI,QAAQ,GAAG,EAAE,CAAC;IAClB,OAAO,KAAK,CAAC,GAAG,GAAG,GAAG,CAAC,MAAM,IAAI,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,KAAK,GAAG,IAAI,CAAC,CAAC,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,KAAK,GAAG,IAAI,GAAG,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,KAAK,GAAG,CAAC,EAAE,CAAC;QACnH,QAAQ,IAAI,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;QAC3B,KAAK,CAAC,GAAG,EAAE,CAAC;IACd,CAAC;IAED,MAAM,WAAW,GAAG,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,KAAK,GAAG,CAAC;IAC3C,KAAK,CAAC,GAAG,IAAI,WAAW,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,sBAAsB;IAExD,MAAM,UAAU,GAAG,kBAAkB,CAAC,QAAQ,CAAC,CAAC;IAChD,MAAM,QAAQ,GAAiB,EAAE,CAAC;IAClC,IAAI,IAAI,GAAG,EAAE,CAAC;IAEd,IAAI,CAAC,WAAW,EAAE,CAAC;QACjB,mCAAmC;QACnC,OAAO,KAAK,CAAC,GAAG,GAAG,GAAG,CAAC,MAAM,EAAE,CAAC;YAC9B,cAAc,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;YAC3B,IAAI,KAAK,CAAC,GAAG,IAAI,GAAG,CAAC,MAAM;gBAAE,MAAM;YAEnC,IAAI,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,KAAK,GAAG,EAAE,CAAC;gBAC3B,YAAY;gBACZ,MAAM,SAAS,GAAG,KAAK,CAAC,GAAG,CAAC;gBAC5B,OAAO,KAAK,CAAC,GAAG,GAAG,GAAG,CAAC,MAAM,IAAI,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,KAAK,GAAG;oBAAE,KAAK,CAAC,GAAG,EAAE,CAAC;gBACrE,IAAI,IAAI,iBAAiB,CAAC,GAAG,CAAC,KAAK,CAAC,SAAS,EAAE,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC;gBAClE,SAAS;YACX,CAAC;YAED,wBAAwB;YACxB,IAAI,GAAG,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,KAAK,GAAG,EAAE,CAAC;gBAC/B,cAAc;gBACd,OAAO,KAAK,CAAC,GAAG,GAAG,GAAG,CAAC,MAAM,IAAI,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,KAAK,GAAG;oBAAE,KAAK,CAAC,GAAG,EAAE,CAAC;gBACrE,KAAK,CAAC,GAAG,EAAE,CAAC,CAAC,cAAc;gBAC3B,MAAM;YACR,CAAC;YAED,MAAM,KAAK,GAAG,YAAY,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;YACvC,IAAI,KAAK;gBAAE,QAAQ,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QAClC,CAAC;IACH,CAAC;IAED,OAAO,EAAE,UAAU,EAAE,QAAQ,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC;AAC9C,CAAC;AAED,SAAS,cAAc,CAAC,GAAW,EAAE,KAAiB;IACpD,OAAO,KAAK,CAAC,GAAG,GAAG,GAAG,CAAC,MAAM,IAAI,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;QAAE,KAAK,CAAC,GAAG,EAAE,CAAC;AAC1E,CAAC;AAED,SAAS,UAAU,CAAC,GAAW;IAC7B,qCAAqC;IACrC,OAAO,GAAG,CAAC,OAAO,CAAC,yBAAyB,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC;AACjF,CAAC;AAED,SAAS,gBAAgB,CAAC,GAAW;IACnC,OAAO,GAAG,CAAC,OAAO,CAAC,kBAAkB,EAAE,EAAE,CAAC,CAAC;AAC7C,CAAC"}
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@flighthq/xml",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ }
12
+ },
13
+ "files": [
14
+ "dist",
15
+ "src/**/*.test.ts",
16
+ "!dist/**/*.test.js",
17
+ "!dist/**/*.test.d.ts",
18
+ "!dist/**/*.test.js.map",
19
+ "!dist/**/*.test.d.ts.map"
20
+ ],
21
+ "scripts": {
22
+ "build": "tsc -b",
23
+ "clean": "tsc -b --clean",
24
+ "test": "vitest run --config vitest.config.ts",
25
+ "test:watch": "vitest --watch --config vitest.config.ts",
26
+ "prepack": "npm run clean && npm run clean:dist && npm run build",
27
+ "clean:dist": "tsx ../../scripts/clean-package-dist.ts"
28
+ },
29
+ "devDependencies": {
30
+ "typescript": "^5.3.0"
31
+ },
32
+ "description": "Lightweight pull-style XML parser: parseXmlDocument and parseXmlAttributes for atlas, plist, and sprite-sheet format files",
33
+ "sideEffects": false
34
+ }
@@ -0,0 +1,138 @@
1
+ import { parseXmlAttributes, parseXmlDocument } from './xmlParse';
2
+
3
+ describe('parseXmlAttributes', () => {
4
+ it('parses double-quoted attributes', () => {
5
+ const attrs = parseXmlAttributes('name="hero" x="10" y="20"');
6
+ expect(attrs['name']).toBe('hero');
7
+ expect(attrs['x']).toBe('10');
8
+ expect(attrs['y']).toBe('20');
9
+ });
10
+
11
+ it('parses single-quoted attributes', () => {
12
+ const attrs = parseXmlAttributes("name='hero' x='10'");
13
+ expect(attrs['name']).toBe('hero');
14
+ expect(attrs['x']).toBe('10');
15
+ });
16
+
17
+ it('decodes XML entity &amp;', () => {
18
+ const attrs = parseXmlAttributes('name="a&amp;b"');
19
+ expect(attrs['name']).toBe('a&b');
20
+ });
21
+
22
+ it('decodes XML entity &lt; and &gt;', () => {
23
+ const attrs = parseXmlAttributes('name="a&lt;b&gt;c"');
24
+ expect(attrs['name']).toBe('a<b>c');
25
+ });
26
+
27
+ it('decodes XML entity &quot;', () => {
28
+ const attrs = parseXmlAttributes("name='say &quot;hi&quot;'");
29
+ expect(attrs['name']).toBe('say "hi"');
30
+ });
31
+
32
+ it('decodes XML entity &apos;', () => {
33
+ const attrs = parseXmlAttributes('name="it&apos;s"');
34
+ expect(attrs['name']).toBe("it's");
35
+ });
36
+
37
+ it('decodes numeric character references', () => {
38
+ const attrs = parseXmlAttributes('name="&#65;"');
39
+ expect(attrs['name']).toBe('A');
40
+ });
41
+
42
+ it('returns empty object for empty string', () => {
43
+ expect(parseXmlAttributes('')).toEqual({});
44
+ });
45
+ });
46
+
47
+ describe('parseXmlDocument', () => {
48
+ it('returns the first top-level element as the root', () => {
49
+ const root = parseXmlDocument('<TextureAtlas imagePath="sheet.png"></TextureAtlas>');
50
+ expect(root).not.toBeNull();
51
+ expect(root?.name).toBe('TextureAtlas');
52
+ expect(root?.attributes.imagePath).toBe('sheet.png');
53
+ expect(root?.children).toEqual([]);
54
+ });
55
+
56
+ it('parses multiple attributes on a single element', () => {
57
+ const root = parseXmlDocument('<sub x="10" y="20" width="32" height="64"/>');
58
+ expect(root?.attributes).toEqual({ x: '10', y: '20', width: '32', height: '64' });
59
+ });
60
+
61
+ it('accepts both single and double quoted attribute values', () => {
62
+ const root = parseXmlDocument(`<node a='single' b="double"/>`);
63
+ expect(root?.attributes).toEqual({ a: 'single', b: 'double' });
64
+ });
65
+
66
+ it('nests children under their parent open tag', () => {
67
+ const xml =
68
+ '<TextureAtlas imagePath="s.png">' +
69
+ '<SubTexture name="a" x="0" y="0"/>' +
70
+ '<SubTexture name="b" x="8" y="8"/>' +
71
+ '</TextureAtlas>';
72
+ const root = parseXmlDocument(xml);
73
+ expect(root?.name).toBe('TextureAtlas');
74
+ expect(root?.children.length).toBe(2);
75
+ expect(root?.children[0].name).toBe('SubTexture');
76
+ expect(root?.children[0].attributes.name).toBe('a');
77
+ expect(root?.children[1].attributes.name).toBe('b');
78
+ });
79
+
80
+ it('treats self-closing tags as leaves rather than opening a scope', () => {
81
+ const xml = '<root><leaf/><sibling/></root>';
82
+ const root = parseXmlDocument(xml);
83
+ // Both leaf and sibling are direct children of root, not nested.
84
+ expect(root?.children.map((c) => c.name)).toEqual(['leaf', 'sibling']);
85
+ });
86
+
87
+ it('supports deep nesting across multiple levels', () => {
88
+ const xml = '<a><b><c value="deep"/></b></a>';
89
+ const root = parseXmlDocument(xml);
90
+ expect(root?.name).toBe('a');
91
+ const b = root?.children[0];
92
+ expect(b?.name).toBe('b');
93
+ const c = b?.children[0];
94
+ expect(c?.name).toBe('c');
95
+ expect(c?.attributes.value).toBe('deep');
96
+ });
97
+
98
+ it('returns null when the input contains no recognizable element', () => {
99
+ expect(parseXmlDocument('')).toBeNull();
100
+ expect(parseXmlDocument(' just text, no tags ')).toBeNull();
101
+ });
102
+
103
+ it('tolerates unbalanced close tags without underflowing the stack', () => {
104
+ // An extra close tag must not pop past the implicit root or throw.
105
+ const root = parseXmlDocument('<a></a></b>');
106
+ expect(root?.name).toBe('a');
107
+ });
108
+
109
+ it('returns the first element when several share the top level', () => {
110
+ const root = parseXmlDocument('<first/><second/>');
111
+ expect(root?.name).toBe('first');
112
+ });
113
+
114
+ it('allows dots, hyphens, and underscores in tag and attribute names', () => {
115
+ const root = parseXmlDocument('<my-tag.v2 data_key="x"/>');
116
+ expect(root?.name).toBe('my-tag.v2');
117
+ expect(root?.attributes.data_key).toBe('x');
118
+ });
119
+
120
+ it('strips XML comments', () => {
121
+ const xml = '<root><!-- This is a comment --><child/></root>';
122
+ const doc = parseXmlDocument(xml);
123
+ expect(doc?.children).toHaveLength(1);
124
+ expect(doc?.children[0]?.name).toBe('child');
125
+ });
126
+
127
+ it('strips the XML declaration', () => {
128
+ const xml = '<?xml version="1.0" encoding="UTF-8"?><root/>';
129
+ const doc = parseXmlDocument(xml);
130
+ expect(doc?.name).toBe('root');
131
+ });
132
+
133
+ it('parses text content', () => {
134
+ const xml = '<root><key>hello</key></root>';
135
+ const doc = parseXmlDocument(xml);
136
+ expect(doc?.children[0]?.text).toBe('hello');
137
+ });
138
+ });