From 44cdcc53e14a9530efe0289a5197ae33208686a0 Mon Sep 17 00:00:00 2001 From: Austin Turner Date: Wed, 1 Apr 2026 19:16:54 -0700 Subject: [PATCH] fix: update XML builder and parser to handle null and undefined values correctly --- src/__tests__/build.test.ts | 12 ++++++++++-- src/__tests__/parse.test.ts | 18 +++++++++--------- src/build.ts | 15 +++++++++------ src/parse.ts | 9 ++++++--- 4 files changed, 34 insertions(+), 20 deletions(-) diff --git a/src/__tests__/build.test.ts b/src/__tests__/build.test.ts index e99f7dd..bad76b6 100644 --- a/src/__tests__/build.test.ts +++ b/src/__tests__/build.test.ts @@ -20,8 +20,16 @@ describe('build', () => { expect(build({ root: { item: ['a', 'b', 'c'] } })).toBe('abc'); }); - it('builds empty string as self-closing tag', () => { - expect(build({ root: { empty: '' } })).toBe(''); + it('builds empty string as open/close tags', () => { + expect(build({ root: { empty: '' } })).toBe(''); + }); + + it('builds null as self-closing tag', () => { + expect(build({ root: { nullField: null, version: '1.0' } })).toBe('1.0'); + }); + + it('skips undefined values', () => { + expect(build({ root: { undef: undefined, kept: 'val' } })).toBe('val'); }); it('builds numeric values', () => { diff --git a/src/__tests__/parse.test.ts b/src/__tests__/parse.test.ts index bade996..55c83f3 100644 --- a/src/__tests__/parse.test.ts +++ b/src/__tests__/parse.test.ts @@ -57,7 +57,7 @@ describe('parse', () => { describe('attributes', () => { it('parses attributes with default prefix', () => { const result = parse('value'); - expect(result).toEqual({ root: { '#text': 'value', '@_id': 1, '@_name': 'test' } }); + expect(result).toEqual({ root: { '#text': 'value', '@_id': '1', '@_name': 'test' } }); }); it('parses attributes on nested elements', () => { @@ -67,7 +67,7 @@ describe('parse', () => { it('parses self-closing tag with attributes', () => { const result = parse(''); - expect(result).toEqual({ root: { item: { '@_id': 1 } } }); + expect(result).toEqual({ root: { item: { '@_id': '1' } } }); }); it('ignores attributes when ignoreAttributes is true', () => { @@ -81,12 +81,12 @@ describe('parse', () => { const result = parse('text', { attributeNamePrefix: '@', }); - expect(result).toEqual({ root: { '#text': 'text', '@id': 1 } }); + expect(result).toEqual({ root: { '#text': 'text', '@id': '1' } }); }); it('handles attributes with single quotes', () => { const result = parse("text"); - expect(result).toEqual({ root: { '#text': 'text', '@_id': 42 } }); + expect(result).toEqual({ root: { '#text': 'text', '@_id': '42' } }); }); }); @@ -105,8 +105,8 @@ describe('parse', () => { const result = parse('text', { removeNSPrefix: true, }); - // xmlns:sf -> removeNS -> sf, sf:id -> removeNS -> id - expect(result).toEqual({ root: { '#text': 'text', '@_sf': 'http://example.com', '@_id': 1 } }); + // xmlns:sf is stripped entirely, sf:id -> removeNS -> id + expect(result).toEqual({ root: { '#text': 'text', '@_id': '1' } }); }); }); @@ -346,9 +346,9 @@ describe('parse', () => { expect(execResult.success).toBe(true); expect(execResult.line).toBe(-1); // xsi:nil="true" self-closing tags have the nil attribute (ns-prefix stripped) - expect(execResult.compileProblem).toEqual({ '@_nil': true }); - expect(execResult.exceptionMessage).toEqual({ '@_nil': true }); - expect(execResult.exceptionStackTrace).toEqual({ '@_nil': true }); + expect(execResult.compileProblem).toEqual({ '@_nil': 'true' }); + expect(execResult.exceptionMessage).toEqual({ '@_nil': 'true' }); + expect(execResult.exceptionStackTrace).toEqual({ '@_nil': 'true' }); }); }); }); diff --git a/src/build.ts b/src/build.ts index e3cc6d9..229e48f 100644 --- a/src/build.ts +++ b/src/build.ts @@ -23,7 +23,14 @@ export function build(obj: Record, options?: BuildOptions): str const parts: string[] = []; function serialize(key: string, value: unknown, depth: number): void { - if (value === null || value === undefined) return; + if (value === undefined) return; + + if (value === null) { + const indent = format ? indentBy.repeat(depth) : ''; + const nl = format ? '\n' : ''; + parts.push(`${indent}<${key}/>${nl}`); + return; + } const indent = format ? indentBy.repeat(depth) : ''; const nl = format ? '\n' : ''; @@ -76,11 +83,7 @@ export function build(obj: Record, options?: BuildOptions): str } else { // Primitive value const str = String(value); - if (str === '') { - parts.push(`${indent}<${key}/>${nl}`); - } else { - parts.push(`${indent}<${key}>${escapeXml(str)}${nl}`); - } + parts.push(`${indent}<${key}>${escapeXml(str)}${nl}`); } } diff --git a/src/parse.ts b/src/parse.ts index b0e749a..c75d5ed 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -260,10 +260,13 @@ function parseAttributes( const re = /([^\s=]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g; let match: RegExpExecArray | null; while ((match = re.exec(str)) !== null) { - let name = match[1]!; + const rawName = match[1]!; let value: string = (match[2] ?? match[3])!; - if (rmNS) name = removeNS(name); + // When stripping namespace prefixes, drop xmlns and xmlns:* declarations entirely + if (rmNS && (rawName === 'xmlns' || rawName.startsWith('xmlns:'))) continue; + const name = rmNS ? removeNS(rawName) : rawName; if (processEnt) value = decodeEntities(value); - attrs[prefix + name] = parseValue(value, coerce); + // Attribute values are always strings — never coerce + attrs[prefix + name] = value; } }