From bf73d2670b572081291e33a0caba126257bc828c Mon Sep 17 00:00:00 2001 From: ztimson Date: Sat, 4 Apr 2026 16:58:53 -0400 Subject: [PATCH] Added to/from xml helpers --- src/index.ts | 4 +- src/xml.ts | 132 +++++++++++++++++++++++++++++++++++ tests/xml.spec.ts | 170 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 305 insertions(+), 1 deletion(-) create mode 100644 src/xml.ts create mode 100644 tests/xml.spec.ts diff --git a/src/index.ts b/src/index.ts index 6456fe9..28e226f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,5 @@ +export * from 'var-persist'; + export * from './arg-parser'; export * from './array'; export * from './aset'; @@ -23,4 +25,4 @@ export * from './template'; export * from './time'; export * from './tts'; export * from './types'; -export * from 'var-persist'; +export * from './xml'; diff --git a/src/xml.ts b/src/xml.ts new file mode 100644 index 0000000..4de9ee1 --- /dev/null +++ b/src/xml.ts @@ -0,0 +1,132 @@ +export function fromXml(xml: string) { + xml = xml.trim(); + let pos = 0; + + function parseNode(): any { + skipWhitespace(); + if(xml[pos] !== '<') return parseText(); + pos++; // skip < + + if(xml[pos] === '?') { + parseDeclaration(); + return parseNode(); + } + + if(xml[pos] === '!') { + parseComment(); + return parseNode(); + } + + const tagName = parseTagName(); + const attributes = parseAttributes(); + skipWhitespace(); + + if(xml[pos] === '/' && xml[pos + 1] === '>') { + pos += 2; // skip /> + return { tag: tagName, attributes, children: [] }; + } + + pos++; // skip > + const children = []; + while(pos < xml.length) { + skipWhitespace(); + if(xml[pos] === '<' && xml[pos + 1] === '/') { + pos += 2; // skip + break; + } + const child = parseNode(); + if(child) children.push(child); + } + return { tag: tagName, attributes, children }; + } + + function parseTagName() { + let name = ''; + while (pos < xml.length && /[a-zA-Z0-9_:-]/.test(xml[pos])) name += xml[pos++]; + return name; + } + + function parseAttributes() { + const attrs: any = {}; + while (pos < xml.length) { + skipWhitespace(); + if (xml[pos] === '>' || xml[pos] === '/') break; + const name = parseTagName(); + skipWhitespace(); + if (xml[pos] === '=') { + pos++; + skipWhitespace(); + const quote = xml[pos++]; + let value = ''; + while (xml[pos] !== quote) value += xml[pos++]; + pos++; // skip closing quote + attrs[name] = escapeXml(value, true); + } + } + return attrs; + } + + function parseText() { + let text = ''; + while (pos < xml.length && xml[pos] !== '<') text += xml[pos++]; + text = text.trim(); + return text ? escapeXml(text, true) : null; + } + + function parseDeclaration() { + while (xml[pos] !== '>') pos++; + pos++; + } + + function parseComment() { + while (!(xml[pos] === '-' && xml[pos + 1] === '-' && xml[pos + 2] === '>')) pos++; + pos += 3; + } + + function skipWhitespace() { + while (pos < xml.length && /\s/.test(xml[pos])) pos++; + } + + return parseNode(); +} + +export function toXml(obj: any, indent = '') { + if(typeof obj === 'string') return escapeXml(obj); + const { tag, attributes = {}, children = [] } = obj; + let xml = `${indent}<${tag}`; + for (const [key, value] of Object.entries(attributes)) + xml += ` ${key}="${escapeXml(value)}"`; + if (children.length === 0) { + xml += ' />'; + return xml; + } + xml += '>'; + const hasComplexChildren = children.some((c: any) => typeof c === 'object'); + for (const child of children) { + if (hasComplexChildren) xml += '\n'; + xml += toXml(child, hasComplexChildren ? indent + ' ' : ''); + } + if(hasComplexChildren) xml += `\n${indent}`; + xml += ``; + return xml; +} + +export function escapeXml(str: string, decode = false) { + if(decode) { + return str + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, '&'); + } + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} diff --git a/tests/xml.spec.ts b/tests/xml.spec.ts new file mode 100644 index 0000000..e78e353 --- /dev/null +++ b/tests/xml.spec.ts @@ -0,0 +1,170 @@ +import { toXml, fromXml } from '../src'; + +describe('XML Parser', () => { + describe('fromXml', () => { + it('should parse simple tag', () => { + const xml = ''; + const result = fromXml(xml); + expect(result).toEqual({ tag: 'root', attributes: {}, children: [] }); + }); + + it('should parse self-closing tag', () => { + const xml = ''; + const result = fromXml(xml); + expect(result).toEqual({ tag: 'item', attributes: {}, children: [] }); + }); + + it('should parse tag with attributes', () => { + const xml = ''; + const result = fromXml(xml); + expect(result).toEqual({ + tag: 'user', + attributes: { id: '1', name: 'someone' }, + children: [] + }); + }); + + it('should parse tag with text content', () => { + const xml = 'someone@example.com'; + const result = fromXml(xml); + expect(result).toEqual({ + tag: 'email', + attributes: {}, + children: ['someone@example.com'] + }); + }); + + it('should parse nested tags', () => { + const xml = 'text'; + const result = fromXml(xml); + expect(result).toEqual({ + tag: 'root', + attributes: {}, + children: [ + { tag: 'child', attributes: {}, children: ['text'] } + ] + }); + }); + + it('should parse multiple children', () => { + const xml = ''; + const result = fromXml(xml); + expect(result.children.length).toBe(3); + expect(result.children[0]).toEqual({ tag: 'a', attributes: {}, children: [] }); + }); + + it('should skip XML declaration', () => { + const xml = ''; + const result = fromXml(xml); + expect(result.tag).toBe('root'); + }); + + it('should skip comments', () => { + const xml = ''; + const result = fromXml(xml); + expect(result.children.length).toBe(1); + expect(result.children[0].tag).toBe('child'); + }); + + it('should handle escaped characters', () => { + const xml = '<hello> & "world"'; + const result = fromXml(xml); + expect(result.children[0]).toBe(' & "world"'); + }); + + it('should parse complex nested structure', () => { + const xml = ` + + + someone@example.com + + + + `; + const result = fromXml(xml); + expect(result.tag).toBe('root'); + expect(result.children[0].tag).toBe('user'); + expect(result.children[0].attributes.name).toBe('someone'); + expect(result.children[0].children.length).toBe(2); + }); + }); + + describe('toXml', () => { + it('should encode simple tag', () => { + const obj = { tag: 'root', attributes: {}, children: [] }; + expect(toXml(obj)).toBe(''); + }); + + it('should encode tag with attributes', () => { + const obj = { tag: 'user', attributes: { id: '1', name: 'someone' }, children: [] }; + const result = toXml(obj); + expect(result).toContain('id="1"'); + expect(result).toContain('name="someone"'); + }); + + it('should encode tag with text content', () => { + const obj = { tag: 'email', attributes: {}, children: ['someone@example.com'] }; + expect(toXml(obj)).toBe('someone@example.com'); + }); + + it('should encode nested tags with indentation', () => { + const obj = { + tag: 'root', + attributes: {}, + children: [ + { tag: 'child', attributes: {}, children: ['text'] } + ] + }; + const result = toXml(obj); + expect(result).toContain(''); + expect(result).toContain(' '); + expect(result).toContain(''); + }); + + it('should escape special characters', () => { + const obj = { tag: 'text', attributes: {}, children: [' & "world"'] }; + const result = toXml(obj); + expect(result).toContain('<hello> & "world"'); + }); + + it('should escape attributes', () => { + const obj = { tag: 'node', attributes: { attr: 'a & b' }, children: [] }; + const result = toXml(obj); + expect(result).toContain('attr="a & b"'); + }); + + it('should handle multiple children', () => { + const obj = { + tag: 'root', + attributes: {}, + children: [ + { tag: 'a', attributes: {}, children: [] }, + { tag: 'b', attributes: {}, children: [] } + ] + }; + const result = toXml(obj); + expect(result).toContain(''); + expect(result).toContain(''); + }); + + it('should encode string directly', () => { + expect(toXml('hello')).toBe('hello'); + expect(toXml('a & b')).toBe('a & b'); + }); + }); + + describe('round-trip', () => { + it('should encode and decode to same structure', () => { + const obj = { + tag: 'root', + attributes: { id: '1' }, + children: [ + { tag: 'child', attributes: {}, children: ['text'] } + ] + }; + const xml = toXml(obj); + const parsed = fromXml(xml); + expect(parsed).toEqual(obj); + }); + }); +});