Skip to content

Commit e89ec29

Browse files
committed
✨ Add a minimal XML parser
In preparation of adding support for SVG images, this commit adds a small, generic, non-validating XML parser. As this library aims to keep runtime dependencies to a minimum, this approach seems preferable to an off-the-shelf XML library. Parsed XML is represented by a tree of elements, with attributes and child nodes, where text nodes are represented as strings. Whitespace-only text nodes are omitted. The parser handles predefined entities and numeric character references. Comments, CDATA sections, DOCTYPE declarations, and processing instructions are skipped. Namespace prefixes are stripped (`xlink:href` becomes `href`) and `xmlns` declarations are dropped. Malformed XML throws an `XmlParseError` carrying position, line, and column.
1 parent 995dfef commit e89ec29

2 files changed

Lines changed: 556 additions & 0 deletions

File tree

src/util/xml.test.ts

Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { parseXml, XmlParseError } from './xml.ts';
4+
5+
describe('parseXml', () => {
6+
it('parses a self-closing root element', () => {
7+
expect(parseXml('<svg/>')).toEqual({ name: 'svg', attrs: {}, children: [] });
8+
expect(parseXml('<svg />')).toEqual({ name: 'svg', attrs: {}, children: [] });
9+
});
10+
11+
it('parses an empty element with a closing tag', () => {
12+
expect(parseXml('<svg></svg>')).toEqual({ name: 'svg', attrs: {}, children: [] });
13+
expect(parseXml('<svg></svg >')).toEqual({ name: 'svg', attrs: {}, children: [] });
14+
});
15+
16+
it('ignores a leading byte order mark', () => {
17+
expect(parseXml('\uFEFF<svg/>')).toEqual({ name: 'svg', attrs: {}, children: [] });
18+
});
19+
20+
it('parses attributes', () => {
21+
expect(parseXml('<rect x="1" y=\'2\'/>')).toEqual({
22+
name: 'rect',
23+
attrs: { x: '1', y: '2' },
24+
children: [],
25+
});
26+
});
27+
28+
it('parses attributes with whitespace around the equals sign', () => {
29+
expect(parseXml('<rect x = "1"\n\ty= "2"/>')).toEqual({
30+
name: 'rect',
31+
attrs: { x: '1', y: '2' },
32+
children: [],
33+
});
34+
});
35+
36+
it('parses attribute values with angle-bracket-free special characters', () => {
37+
expect(parseXml('<t d="M 0,0 L \'1\' 2 &gt; 3"/>')).toEqual({
38+
name: 't',
39+
attrs: { d: "M 0,0 L '1' 2 > 3" },
40+
children: [],
41+
});
42+
});
43+
44+
it('parses nested elements', () => {
45+
expect(parseXml('<svg><g><rect/><circle/></g></svg>')).toEqual({
46+
name: 'svg',
47+
attrs: {},
48+
children: [
49+
{
50+
name: 'g',
51+
attrs: {},
52+
children: [
53+
{ name: 'rect', attrs: {}, children: [] },
54+
{ name: 'circle', attrs: {}, children: [] },
55+
],
56+
},
57+
],
58+
});
59+
});
60+
61+
it('parses text content', () => {
62+
expect(parseXml('<t>foo bar</t>')).toEqual({ name: 't', attrs: {}, children: ['foo bar'] });
63+
});
64+
65+
it('parses mixed content', () => {
66+
expect(parseXml('<p>Hello <b>world</b>!</p>')).toEqual({
67+
name: 'p',
68+
attrs: {},
69+
children: ['Hello ', { name: 'b', attrs: {}, children: ['world'] }, '!'],
70+
});
71+
});
72+
73+
it('omits whitespace-only text nodes', () => {
74+
expect(parseXml('<svg>\n <rect/>\n</svg>')).toEqual({
75+
name: 'svg',
76+
attrs: {},
77+
children: [{ name: 'rect', attrs: {}, children: [] }],
78+
});
79+
});
80+
81+
it('decodes predefined entities in text', () => {
82+
expect(parseXml('<t>&lt;&gt;&amp;&apos;&quot;</t>')).toEqual({
83+
name: 't',
84+
attrs: {},
85+
children: ['<>&\'"'],
86+
});
87+
});
88+
89+
it('decodes numeric character references in text', () => {
90+
expect(parseXml('<t>&#65;&#x42;&#X43;&#x1F600;</t>')).toEqual({
91+
name: 't',
92+
attrs: {},
93+
children: ['ABC\u{1F600}'],
94+
});
95+
});
96+
97+
it('decodes entities in attribute values', () => {
98+
expect(parseXml('<t a="&quot;x&quot;" b="&#x20AC;"/>')).toEqual({
99+
name: 't',
100+
attrs: { a: '"x"', b: '€' },
101+
children: [],
102+
});
103+
});
104+
105+
it('skips the XML declaration and DOCTYPE', () => {
106+
const input = '<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE svg>\n<svg/>';
107+
expect(parseXml(input)).toEqual({ name: 'svg', attrs: {}, children: [] });
108+
});
109+
110+
it('skips a DOCTYPE with an internal subset', () => {
111+
const input = '<!DOCTYPE svg [ <!ENTITY foo "bar"> ]><svg/>';
112+
expect(parseXml(input)).toEqual({ name: 'svg', attrs: {}, children: [] });
113+
});
114+
115+
it("skips a DOCTYPE with '>' in quoted identifiers", () => {
116+
const input = '<!DOCTYPE svg PUBLIC "-//X//DTD > SVG//EN" \'a>b\'><svg/>';
117+
expect(parseXml(input)).toEqual({ name: 'svg', attrs: {}, children: [] });
118+
const subset = '<!DOCTYPE svg [ <!ENTITY gt2 ">"> ]><svg/>';
119+
expect(parseXml(subset)).toEqual({ name: 'svg', attrs: {}, children: [] });
120+
});
121+
122+
it('skips comments', () => {
123+
const input = '<!-- a --><svg><!-- <b> --><rect/></svg><!-- c -->';
124+
expect(parseXml(input)).toEqual({
125+
name: 'svg',
126+
attrs: {},
127+
children: [{ name: 'rect', attrs: {}, children: [] }],
128+
});
129+
});
130+
131+
it('skips processing instructions', () => {
132+
expect(parseXml('<svg><?foo bar?></svg>')).toEqual({ name: 'svg', attrs: {}, children: [] });
133+
});
134+
135+
it('skips CDATA sections', () => {
136+
expect(parseXml('<t>a<![CDATA[ <x> & ]]>b</t>')).toEqual({
137+
name: 't',
138+
attrs: {},
139+
children: ['a', 'b'],
140+
});
141+
});
142+
143+
it('strips namespace prefixes from element and attribute names', () => {
144+
expect(parseXml('<svg:svg><svg:use xlink:href="#a"/></svg:svg>')).toEqual({
145+
name: 'svg',
146+
attrs: {},
147+
children: [{ name: 'use', attrs: { href: '#a' }, children: [] }],
148+
});
149+
});
150+
151+
it('drops namespace declarations', () => {
152+
const input =
153+
'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="10"/>';
154+
expect(parseXml(input)).toEqual({ name: 'svg', attrs: { width: '10' }, children: [] });
155+
});
156+
157+
it('throws on empty input', () => {
158+
expect(() => parseXml('')).toThrow('Expected root element at line 1, column 1');
159+
expect(() => parseXml(' \n ')).toThrow('Expected root element');
160+
});
161+
162+
it('throws on text before the root element', () => {
163+
expect(() => parseXml('foo <svg/>')).toThrow('Expected root element at line 1, column 1');
164+
});
165+
166+
it('throws on content after the root element', () => {
167+
expect(() => parseXml('<a/><b/>')).toThrow(
168+
'Unexpected content after root element at line 1, column 5',
169+
);
170+
expect(() => parseXml('<a/>foo')).toThrow('Unexpected content after root element');
171+
});
172+
173+
it('throws on a missing closing tag', () => {
174+
expect(() => parseXml('<a><b></b>')).toThrow(
175+
"Expected closing tag '</a>' at line 1, column 11",
176+
);
177+
});
178+
179+
it('throws on a mismatched closing tag', () => {
180+
expect(() => parseXml('<a></b>')).toThrow(
181+
"Mismatched closing tag '</b>', expected '</a>' at line 1, column 4",
182+
);
183+
});
184+
185+
it('throws on unexpected end of input in a tag', () => {
186+
expect(() => parseXml('<')).toThrow('Expected name at line 1, column 2');
187+
expect(() => parseXml('<a')).toThrow('Unexpected end of input at line 1, column 3');
188+
expect(() => parseXml('<a x="1"')).toThrow('Unexpected end of input');
189+
});
190+
191+
it('throws on an unquoted attribute value', () => {
192+
expect(() => parseXml('<a x=1/>')).toThrow(
193+
'Expected quoted attribute value at line 1, column 6',
194+
);
195+
});
196+
197+
it('throws on an attribute without a value', () => {
198+
expect(() => parseXml('<a x/>')).toThrow("Expected '=' at line 1, column 5");
199+
});
200+
201+
it('throws on an unterminated attribute value', () => {
202+
expect(() => parseXml('<a x="1')).toThrow('Unterminated attribute value at line 1, column 6');
203+
});
204+
205+
it("throws on '<' in an attribute value", () => {
206+
expect(() => parseXml('<a x="<b>"/>')).toThrow(
207+
"Unexpected '<' in attribute value at line 1, column 7",
208+
);
209+
});
210+
211+
it('throws on duplicate attributes', () => {
212+
expect(() => parseXml('<a x="1" x="2"/>')).toThrow(
213+
"Duplicate attribute 'x' at line 1, column 10",
214+
);
215+
expect(() => parseXml('<a x="1" b:x="2"/>')).toThrow("Duplicate attribute 'x'");
216+
});
217+
218+
it('throws on an unknown entity', () => {
219+
expect(() => parseXml('<t>&nbsp;</t>')).toThrow("Unknown entity '&nbsp;' at line 1, column 4");
220+
});
221+
222+
it('throws on an invalid character reference', () => {
223+
expect(() => parseXml('<t>&#xZZ;</t>')).toThrow("Invalid character reference '&#xZZ;'");
224+
expect(() => parseXml('<t>&#;</t>')).toThrow("Invalid character reference '&#;'");
225+
expect(() => parseXml('<t>&#x110000;</t>')).toThrow("Invalid character reference '&#x110000;'");
226+
});
227+
228+
it('throws on character references to surrogate code points', () => {
229+
expect(() => parseXml('<t>&#xD800;</t>')).toThrow("Invalid character reference '&#xD800;'");
230+
expect(() => parseXml('<t>&#xDFFF;</t>')).toThrow("Invalid character reference '&#xDFFF;'");
231+
expect(() => parseXml('<t>&#55296;</t>')).toThrow("Invalid character reference '&#55296;'");
232+
});
233+
234+
it('throws on a stray ampersand', () => {
235+
expect(() => parseXml('<t>a & b</t>')).toThrow(
236+
'Unterminated entity reference at line 1, column 6',
237+
);
238+
expect(() => parseXml('<t>a & b; c</t>')).toThrow(
239+
'Invalid entity reference at line 1, column 6',
240+
);
241+
});
242+
243+
it('throws on unterminated comments, CDATA sections, and processing instructions', () => {
244+
expect(() => parseXml('<!-- foo')).toThrow('Unterminated comment at line 1, column 1');
245+
expect(() => parseXml('<t><![CDATA[x</t>')).toThrow(
246+
'Unterminated CDATA section at line 1, column 4',
247+
);
248+
expect(() => parseXml('<?xml version="1.0"')).toThrow(
249+
'Unterminated processing instruction at line 1, column 1',
250+
);
251+
});
252+
253+
it('throws on unexpected markup in content', () => {
254+
expect(() => parseXml('<a><!DOCTYPE x></a>')).toThrow('Unexpected markup at line 1, column 4');
255+
});
256+
257+
it('throws an XmlParseError with position info', () => {
258+
let error: unknown;
259+
try {
260+
parseXml('<a>\n <b></c>\n</a>');
261+
} catch (e) {
262+
error = e;
263+
}
264+
expect(error).toBeInstanceOf(XmlParseError);
265+
expect(error).toMatchObject({
266+
name: 'XmlParseError',
267+
message: "Mismatched closing tag '</c>', expected '</b>' at line 2, column 6",
268+
position: 9,
269+
line: 2,
270+
column: 6,
271+
});
272+
});
273+
});

0 commit comments

Comments
 (0)