Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ const result = parse(soapResponse, {
| `attributeNamePrefix` | `string` | `'@_'` | Prefix prepended to attribute names in the output object |
| `parseTagValue` | `boolean` | `true` | Coerce numeric/boolean text values to their JS types |
| `processEntities` | `boolean` | `true` | Decode XML entities (`&amp;` → `&`, `&lt;` → `<`, etc.) |
| `maxDepth` | `number` | `256` | Maximum nesting depth allowed. Throws if exceeded |
| `strict` | `boolean` | `false` | Throw on malformed XML (unclosed, mismatched, or extra closing tags) |

### `build(obj: Record<string, unknown>, options?: BuildOptions): string`

Expand Down Expand Up @@ -105,7 +107,7 @@ The parser produces the same object shape as `fast-xml-parser`:
- Processing instructions (skipped)
- Self-closing tags
- XML declaration (skipped)
- Built-in XML entities (`&amp;`, `&lt;`, `&gt;`, `&quot;`, `&apos;`)
- XML entities: named (`&amp;`, `&lt;`, `&gt;`, `&quot;`, `&apos;`) and numeric (`&#123;`, `&#x7B;`)

## License

Expand Down
45 changes: 45 additions & 0 deletions src/__tests__/build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,51 @@ describe('build', () => {
});
});

describe('builder edge cases', () => {
it('builds elements with special characters in text that need escaping', () => {
expect(build({ root: 'Tom & Jerry <"friends">' })).toBe(
'<root>Tom &amp; Jerry &lt;&quot;friends&quot;&gt;</root>',
);
});

it('handles deeply nested objects', () => {
const deep = { a: { b: { c: { d: { e: 'val' } } } } };
expect(build(deep)).toBe('<a><b><c><d><e>val</e></d></c></b></a>');
});

it('handles arrays of objects with mixed content', () => {
const obj = {
root: {
item: [
{ '@_id': '1', '#text': 'first' },
{ '@_id': '2', child: 'nested' },
'plain',
],
},
};
const result = build(obj);
expect(result).toContain('<item id="1">first</item>');
expect(result).toContain('<item id="2"><child>nested</child></item>');
expect(result).toContain('<item>plain</item>');
});

it('handles numeric zero values', () => {
expect(build({ root: { count: 0 } })).toBe('<root><count>0</count></root>');
});

it('handles false boolean values', () => {
expect(build({ root: { flag: false } })).toBe('<root><flag>false</flag></root>');
});

it('handles empty object as self-closing', () => {
expect(build({ root: {} })).toBe('<root/>');
});

it('handles empty array (no output for key)', () => {
expect(build({ root: { items: [] } })).toBe('<root></root>');
});
});

describe('round-trip', () => {
it('parse -> build -> parse produces equivalent structure', () => {
const xml = '<root><items><item id="1">first</item><item id="2">second</item></items></root>';
Expand Down
153 changes: 153 additions & 0 deletions src/__tests__/parse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,12 @@ describe('parse', () => {
expect(() => parse('<a><b>text</b></a>', { strict: true })).not.toThrow();
});

it('throws on extra closing tags in strict mode', () => {
expect(() => parse('<root>text</root></extra>', { strict: true })).toThrow(
'Unexpected closing tag </extra>',
);
});

it('silently ignores unclosed tags when strict is false (default)', () => {
const result = parse('<a><b>text</b>');
expect(result).toEqual({});
Expand Down Expand Up @@ -351,4 +357,151 @@ describe('parse', () => {
expect(execResult.exceptionStackTrace).toEqual({ '@_nil': 'true' });
});
});

describe('numeric entity handling', () => {
it('decodes decimal numeric entities', () => {
expect(parse('<root>&#72;&#101;&#108;&#108;&#111;</root>')).toEqual({ root: 'Hello' });
});

it('decodes hex numeric entities', () => {
expect(parse('<root>&#x48;&#x65;&#x6C;&#x6C;&#x6F;</root>')).toEqual({ root: 'Hello' });
});

it('decodes mixed named and numeric entities', () => {
expect(parse('<root>&lt;div class=&#34;test&#34;&gt;</root>')).toEqual({ root: '<div class="test">' });
});

it('leaves numeric entities as-is when processEntities is false', () => {
expect(parse('<root>&#72;ello</root>', { processEntities: false })).toEqual({ root: '&#72;ello' });
});

it('handles numeric entities in attribute values', () => {
const result = parse('<root title="&#72;ello">text</root>');
expect(result).toEqual({ root: { '#text': 'text', '@_title': 'Hello' } });
});

it('leaves invalid numeric entities as-is instead of crashing', () => {
expect(parse('<root>&#99999999999;</root>')).toEqual({ root: '&#99999999999;' });
expect(parse('<root>&#xFFFFFFFF;</root>')).toEqual({ root: '&#xFFFFFFFF;' });
});
});

describe('malformed XML handling', () => {
it('handles unclosed tags gracefully in non-strict mode', () => {
expect(() => parse('<root><unclosed>text')).not.toThrow();
});

it('handles extra closing tags gracefully in non-strict mode', () => {
const result = parse('<root>text</root></extra>');
expect(result).toEqual({ root: 'text' });
});

it('handles completely empty input', () => {
expect(parse('')).toEqual({});
});

it('handles whitespace-only input', () => {
expect(parse(' \n\t ')).toEqual({});
});

it('handles XML with only a declaration', () => {
expect(parse('<?xml version="1.0"?>')).toEqual({});
});

it('handles XML with only comments', () => {
expect(parse('<!-- just a comment -->')).toEqual({});
});

it('handles unescaped ampersand in text content without crashing', () => {
expect(() => parse('<root>AT&T</root>')).not.toThrow();
});
});

describe('whitespace edge cases', () => {
it('handles whitespace-only text nodes between elements', () => {
const result = parse('<root>\n <a>1</a>\n <b>2</b>\n</root>');
expect(result).toEqual({ root: { a: 1, b: 2 } });
});

it('preserves significant whitespace in text content with trimValues false', () => {
const result = parse('<root> line1\n line2 </root>', { trimValues: false });
expect(result).toEqual({ root: ' line1\n line2 ' });
});

it('does not crash on malformed tags with whitespace in names', () => {
// < root > is not valid XML — just verify no crash
const result = parse('< root >value</ root >');
expect(result).toEqual({ '': 'value' });
});
});

describe('CDATA edge cases', () => {
it('handles multiple adjacent CDATA sections', () => {
const result = parse('<root><![CDATA[part1]]><![CDATA[part2]]></root>');
expect(result).toEqual({ root: 'part1part2' });
});

it('handles CDATA mixed with regular text', () => {
const result = parse('<root>before<![CDATA[<middle>]]>after</root>');
expect(result).toEqual({ root: 'before<middle>after' });
});

it('handles empty CDATA', () => {
const result = parse('<root><![CDATA[]]></root>');
expect(result).toEqual({ root: '' });
});

it('handles CDATA with newlines and special characters', () => {
const result = parse('<root><![CDATA[line1\nline2\t& <special>]]></root>');
expect(result).toEqual({ root: 'line1\nline2\t& <special>' });
});
});

describe('attribute edge cases', () => {
it('handles attributes with empty values', () => {
const result = parse('<root attr="">text</root>');
expect(result).toEqual({ root: { '#text': 'text', '@_attr': '' } });
});

it('handles attributes with encoded entities in values', () => {
const result = parse('<root title="a &amp; b">text</root>');
expect(result).toEqual({ root: { '#text': 'text', '@_title': 'a & b' } });
});

it('handles attributes with encoded entities when processEntities is false', () => {
const result = parse('<root title="a &amp; b">text</root>', { processEntities: false });
expect(result).toEqual({ root: { '#text': 'text', '@_title': 'a &amp; b' } });
});

it('handles many attributes on one element', () => {
const result = parse('<root a="1" b="2" c="3" d="4" e="5">text</root>');
expect(result).toEqual({
root: { '#text': 'text', '@_a': '1', '@_b': '2', '@_c': '3', '@_d': '4', '@_e': '5' },
});
});

it('handles duplicate attributes (last wins)', () => {
const result = parse('<root attr="first" attr="second">text</root>');
expect(result).toEqual({ root: { '#text': 'text', '@_attr': 'second' } });
});
});

describe('large payloads', () => {
it('handles XML with many sibling elements', () => {
const items = Array.from({ length: 1000 }, (_, i) => `<item>${i}</item>`).join('');
const xml = `<root>${items}</root>`;
const result = parse(xml);
const arr = (result.root as any).item as number[];
expect(arr).toHaveLength(1000);
expect(arr[0]).toBe(0);
expect(arr[999]).toBe(999);
});

it('handles XML with very long text content', () => {
const longText = 'x'.repeat(100_000);
const xml = `<root>${longText}</root>`;
const result = parse(xml);
expect(result.root).toBe(longText);
});
});
});
24 changes: 22 additions & 2 deletions src/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,19 @@ const ENTITY_MAP: Record<string, string> = {
'&apos;': "'",
};

const ENTITY_RE = /&(?:amp|lt|gt|quot|apos);/g;
const ENTITY_RE = /&(?:amp|lt|gt|quot|apos|#x[0-9a-fA-F]+|#[0-9]+);/g;

function decodeEntities(str: string): string {
return str.replace(ENTITY_RE, match => ENTITY_MAP[match] || match);
return str.replace(ENTITY_RE, match => {
if (match in ENTITY_MAP) return ENTITY_MAP[match]!;
try {
if (match.startsWith('&#x')) return String.fromCodePoint(parseInt(match.slice(3, -1), 16));
if (match.startsWith('&#')) return String.fromCodePoint(parseInt(match.slice(2, -1), 10));
} catch {
return match;
}
return match;
});
}

function removeNS(name: string): string {
Expand Down Expand Up @@ -137,6 +146,17 @@ export function parse(xml: string, options?: ParseOptions): Record<string, unkno
const end = xml.indexOf('>', i + 2);
if (end === -1) break;

// Guard against extra closing tags that would pop the root sentinel
if (stack.length <= 1) {
if (strict) {
let closingName = xml.slice(i + 2, end).trim();
if (rmNSPrefix) closingName = removeNS(closingName);
throw new Error(`Unexpected closing tag </${closingName}>`);
}
i = end + 1;
continue;
}

if (strict) {
let closingName = xml.slice(i + 2, end).trim();
if (rmNSPrefix) closingName = removeNS(closingName);
Expand Down