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
12 changes: 10 additions & 2 deletions src/__tests__/build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,16 @@ describe('build', () => {
expect(build({ root: { item: ['a', 'b', 'c'] } })).toBe('<root><item>a</item><item>b</item><item>c</item></root>');
});

it('builds empty string as self-closing tag', () => {
expect(build({ root: { empty: '' } })).toBe('<root><empty/></root>');
it('builds empty string as open/close tags', () => {
expect(build({ root: { empty: '' } })).toBe('<root><empty></empty></root>');
});

it('builds null as self-closing tag', () => {
expect(build({ root: { nullField: null, version: '1.0' } })).toBe('<root><nullField/><version>1.0</version></root>');
});

it('skips undefined values', () => {
expect(build({ root: { undef: undefined, kept: 'val' } })).toBe('<root><kept>val</kept></root>');
});

it('builds numeric values', () => {
Expand Down
18 changes: 9 additions & 9 deletions src/__tests__/parse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ describe('parse', () => {
describe('attributes', () => {
it('parses attributes with default prefix', () => {
const result = parse('<root id="1" name="test">value</root>');
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', () => {
Expand All @@ -67,7 +67,7 @@ describe('parse', () => {

it('parses self-closing tag with attributes', () => {
const result = parse('<root><item id="1"/></root>');
expect(result).toEqual({ root: { item: { '@_id': 1 } } });
expect(result).toEqual({ root: { item: { '@_id': '1' } } });
});

it('ignores attributes when ignoreAttributes is true', () => {
Expand All @@ -81,12 +81,12 @@ describe('parse', () => {
const result = parse('<root id="1">text</root>', {
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("<root id='42'>text</root>");
expect(result).toEqual({ root: { '#text': 'text', '@_id': 42 } });
expect(result).toEqual({ root: { '#text': 'text', '@_id': '42' } });
});
});

Expand All @@ -105,8 +105,8 @@ describe('parse', () => {
const result = parse('<root xmlns:sf="http://example.com" sf:id="1">text</root>', {
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' } });
});
});

Expand Down Expand Up @@ -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' });
});
});
});
15 changes: 9 additions & 6 deletions src/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,14 @@ export function build(obj: Record<string, unknown>, 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;
}
Comment on lines +26 to +33
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

serialize now skips undefined values via an early return, but undefined can still be serialized in other paths: (1) object attributes currently do String(v) and will emit attr="undefined", and (2) object child keys with undefined are still collected into childEntries, which can force the parent into the "has children" branch and produce <parent></parent> instead of a self-closing tag when all children were skipped. Consider filtering out v === undefined when collecting attrParts / childEntries so the "skip undefined values" behavior is consistent and doesn’t affect parent rendering.

Copilot uses AI. Check for mistakes.

const indent = format ? indentBy.repeat(depth) : '';
const nl = format ? '\n' : '';
Expand Down Expand Up @@ -76,11 +83,7 @@ export function build(obj: Record<string, unknown>, options?: BuildOptions): str
} else {
// Primitive value
const str = String(value);
if (str === '') {
parts.push(`${indent}<${key}/>${nl}`);
} else {
parts.push(`${indent}<${key}>${escapeXml(str)}</${key}>${nl}`);
}
parts.push(`${indent}<${key}>${escapeXml(str)}</${key}>${nl}`);
}
Comment on lines 84 to 87
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This changes empty-string serialization from a self-closing element (<tag/>) to an explicit open/close pair (<tag></tag>). If this is intentional (e.g., to distinguish '' from null), it should be called out as a behavioral/breaking change in the PR description/release notes; otherwise consider keeping the previous behavior or making it configurable.

Copilot uses AI. Check for mistakes.
}

Expand Down
9 changes: 6 additions & 3 deletions src/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment on lines +265 to +266
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With removeNSPrefix enabled, this now drops xmlns / xmlns:* declarations entirely. That’s more than prefix stripping (the option is documented as soap:Body -> Body) and may be breaking for callers that relied on namespace declarations being present in the output attributes. Consider documenting this behavior clearly and/or adding an option to preserve xmlns attributes when needed.

Suggested change
// When stripping namespace prefixes, drop xmlns and xmlns:* declarations entirely
if (rmNS && (rawName === 'xmlns' || rawName.startsWith('xmlns:'))) continue;
// When stripping namespace prefixes, preserve xmlns and xmlns:* declarations
// so callers can still access namespace information if needed.
if (rmNS && (rawName === 'xmlns' || rawName.startsWith('xmlns:'))) {
if (processEnt) value = decodeEntities(value);
attrs[prefix + rawName] = value;
continue;
}

Copilot uses AI. Check for mistakes.
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;
}
}
Loading