Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 28716c7b5a | |||
| d530f6abdf | |||
| cbee6a4509 | |||
| e8f81bb584 | |||
| 4179b4010a | |||
| 15ac52b6a0 | |||
| c778f3d280 | |||
| d0af3a63bc | |||
| bf73d2670b | |||
| 361613f507 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ztimson/utils",
|
||||
"version": "0.28.15",
|
||||
"version": "0.29.4",
|
||||
"description": "Utility library",
|
||||
"author": "Zak Timson",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -27,6 +27,31 @@ export function camelCase(str?: string): string {
|
||||
return pascal.charAt(0).toLowerCase() + pascal.slice(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode HTML escaped characters
|
||||
* @param html HTML to clean up
|
||||
* @returns {any}
|
||||
*/
|
||||
export function decodeHtml(html: string) {
|
||||
return html
|
||||
.replace(/ /g, '\u00A0')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/¢/g, '¢')
|
||||
.replace(/£/g, '£')
|
||||
.replace(/¥/g, '¥')
|
||||
.replace(/€/g, '€')
|
||||
.replace(/©/g, '©')
|
||||
.replace(/®/g, '®')
|
||||
.replace(/™/g, '™')
|
||||
.replace(/×/g, '×')
|
||||
.replace(/÷/g, '÷')
|
||||
.replace(/&#(\d+);/g, (match, dec) => String.fromCharCode(dec))
|
||||
.replace(/&#x([0-9a-fA-F]+);/g, (match, hex) => String.fromCharCode(parseInt(hex, 16)))
|
||||
.replace(/&/g, '&'); // Always last!
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert number of bytes into a human-readable size
|
||||
@@ -216,7 +241,6 @@ export function snakeCase(str?: string): string {
|
||||
return wordSegments(str).map(w => w.toLowerCase()).join("_");
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Splice a string together (Similar to Array.splice)
|
||||
*
|
||||
@@ -232,6 +256,20 @@ export function strSplice(str: string, start: number, deleteCount: number, inser
|
||||
return before + insert + after;
|
||||
}
|
||||
|
||||
function titleCase(str: string) {
|
||||
// Normalize separators: replace underscores and hyphens with spaces
|
||||
let normalizedStr = str.replace(/(_|-)/g, ' ');
|
||||
// Handle CamelCase/PascalCase boundaries: insert a space before capital letters
|
||||
normalizedStr = normalizedStr.replace(/([a-z])([A-Z])/g, '$1 $2');
|
||||
// Lowercase the whole string, split by any whitespace, and capitalize each word
|
||||
let words = normalizedStr.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
const titledWords = words.map(word => {
|
||||
if (word.length === 0) return '';
|
||||
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
|
||||
});
|
||||
return titledWords.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all substrings that match a given pattern.
|
||||
*
|
||||
|
||||
75
src/time.ts
75
src/time.ts
@@ -78,19 +78,62 @@ export function dayOfYear(date: Date): number {
|
||||
*
|
||||
* @param {string} format How date string will be formatted, default: `YYYY-MM-DD H:mm A`
|
||||
* @param {Date | number | string} date Date or timestamp, defaults to now
|
||||
* @param tz Set timezone offset
|
||||
* @param tz Set timezone offset in: hours (-4) or minutes (430) or IANA string (America/New_York)
|
||||
* @return {string} Formated date
|
||||
*/
|
||||
export function formatDate(format: string = 'YYYY-MM-DD H:mm', date: Date | number | string = new Date(), tz: string | number = 'local'): string {
|
||||
if (typeof date === 'number' || typeof date === 'string') date = new Date(date);
|
||||
if (isNaN(date.getTime())) throw new Error('Invalid date input');
|
||||
const numericTz = typeof tz === 'number';
|
||||
const localTz = tz === 'local' || (!numericTz && tz.toLowerCase?.() === 'local');
|
||||
const tzName = localTz ? Intl.DateTimeFormat().resolvedOptions().timeZone : numericTz ? 'UTC' : tz;
|
||||
|
||||
const TIMEZONE_MAP = [
|
||||
{ name: 'IDLW', iana: 'Etc/GMT+12', offset: -720 },
|
||||
{ name: 'SST', iana: 'Pacific/Pago_Pago', offset: -660 },
|
||||
{ name: 'HST', iana: 'Pacific/Honolulu', offset: -600 },
|
||||
{ name: 'AKST', iana: 'America/Anchorage', offset: -540 },
|
||||
{ name: 'PST', iana: 'America/Los_Angeles', offset: -480 },
|
||||
{ name: 'MST', iana: 'America/Denver', offset: -420 },
|
||||
{ name: 'CST', iana: 'America/Chicago', offset: -360 },
|
||||
{ name: 'EST', iana: 'America/New_York', offset: -300 },
|
||||
{ name: 'AST', iana: 'America/Halifax', offset: -240 },
|
||||
{ name: 'BRT', iana: 'America/Sao_Paulo', offset: -180 },
|
||||
{ name: 'MAT', iana: 'Atlantic/South_Georgia', offset: -120 },
|
||||
{ name: 'AZOT', iana: 'Atlantic/Azores', offset: -60 },
|
||||
{ name: 'UTC', iana: 'UTC', offset: 0 },
|
||||
{ name: 'CET', iana: 'Europe/Paris', offset: 60 },
|
||||
{ name: 'EET', iana: 'Europe/Athens', offset: 120 },
|
||||
{ name: 'MSK', iana: 'Europe/Moscow', offset: 180 },
|
||||
{ name: 'GST', iana: 'Asia/Dubai', offset: 240 },
|
||||
{ name: 'PKT', iana: 'Asia/Karachi', offset: 300 },
|
||||
{ name: 'IST', iana: 'Asia/Kolkata', offset: 330 },
|
||||
{ name: 'BST', iana: 'Asia/Dhaka', offset: 360 },
|
||||
{ name: 'ICT', iana: 'Asia/Bangkok', offset: 420 },
|
||||
{ name: 'CST', iana: 'Asia/Shanghai', offset: 480 },
|
||||
{ name: 'JST', iana: 'Asia/Tokyo', offset: 540 },
|
||||
{ name: 'AEST', iana: 'Australia/Sydney', offset: 600 },
|
||||
{ name: 'SBT', iana: 'Pacific/Guadalcanal', offset: 660 },
|
||||
{ name: 'TOT', iana: 'Pacific/Tongatapu', offset: 780 },
|
||||
{ name: 'LINT', iana: 'Pacific/Kiritimati', offset: 840 },
|
||||
];
|
||||
|
||||
let numericTz = typeof tz === 'number';
|
||||
const localTz = tz === 'local' || (!numericTz && tz.toString().toLowerCase?.() === 'local');
|
||||
let tzName = localTz ? Intl.DateTimeFormat().resolvedOptions().timeZone : numericTz ? 'UTC' : tz;
|
||||
let offsetMinutes = 0;
|
||||
|
||||
if (numericTz) {
|
||||
// Convert hours to minutes if offset is small (likely hours)
|
||||
offsetMinutes = Math.abs(tz as number) < 24 ? (tz as number) * 60 : (tz as number);
|
||||
|
||||
// Find closest matching timezone
|
||||
const closest = TIMEZONE_MAP.reduce((prev, curr) =>
|
||||
Math.abs(curr.offset - offsetMinutes) < Math.abs(prev.offset - offsetMinutes) ? curr : prev
|
||||
);
|
||||
tzName = closest.iana;
|
||||
}
|
||||
|
||||
if (!numericTz && tzName !== 'UTC') {
|
||||
try {
|
||||
new Intl.DateTimeFormat('en-US', { timeZone: tzName }).format();
|
||||
new Intl.DateTimeFormat('en-US', { timeZone: <string>tzName }).format();
|
||||
} catch {
|
||||
throw new Error(`Invalid timezone: ${tzName}`);
|
||||
}
|
||||
@@ -99,9 +142,10 @@ export function formatDate(format: string = 'YYYY-MM-DD H:mm', date: Date | numb
|
||||
let zonedDate = new Date(date);
|
||||
let get: (fn: 'FullYear' | 'Month' | 'Date' | 'Day' | 'Hours' | 'Minutes' | 'Seconds' | 'Milliseconds') => number;
|
||||
const partsMap: Record<string, string> = {};
|
||||
|
||||
if (!numericTz && tzName !== 'UTC') {
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: tzName,
|
||||
timeZone: <string>tzName,
|
||||
year: 'numeric', month: '2-digit', day: '2-digit', weekday: 'long',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
hour12: false
|
||||
@@ -111,7 +155,7 @@ export function formatDate(format: string = 'YYYY-MM-DD H:mm', date: Date | numb
|
||||
});
|
||||
|
||||
const monthValue = parseInt(partsMap.month) - 1;
|
||||
const dayOfWeekValue = new Date(`${partsMap.year}-${partsMap.month}-${partsMap.day}`).getDay();
|
||||
const dayOfWeekValue = new Date(Date.UTC(parseInt(partsMap.year), parseInt(partsMap.month) - 1, parseInt(partsMap.day))).getUTCDay();
|
||||
const hourValue = parseInt(partsMap.hour);
|
||||
|
||||
get = (fn: 'FullYear' | 'Month' | 'Date' | 'Day' | 'Hours' | 'Minutes' | 'Seconds' | 'Milliseconds'): number => {
|
||||
@@ -127,8 +171,7 @@ export function formatDate(format: string = 'YYYY-MM-DD H:mm', date: Date | numb
|
||||
}
|
||||
};
|
||||
} else {
|
||||
const offset = numericTz ? tz as number : 0;
|
||||
zonedDate = new Date(date.getTime() + offset * 60 * 60 * 1000);
|
||||
zonedDate = new Date(date.getTime() + offsetMinutes * 60 * 1000);
|
||||
get = (fn: 'FullYear' | 'Month' | 'Date' | 'Day' | 'Hours' | 'Minutes' | 'Seconds' | 'Milliseconds'): number => zonedDate[`getUTC${fn}`]();
|
||||
}
|
||||
|
||||
@@ -140,13 +183,12 @@ export function formatDate(format: string = 'YYYY-MM-DD H:mm', date: Date | numb
|
||||
|
||||
function getTZOffset(): string {
|
||||
if(numericTz) {
|
||||
const total = (tz as number) * 60;
|
||||
const hours = Math.floor(Math.abs(total) / 60);
|
||||
const mins = Math.abs(total) % 60;
|
||||
return `${tz >= 0 ? '+' : '-'}${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
|
||||
const hours = Math.floor(Math.abs(offsetMinutes) / 60);
|
||||
const mins = Math.abs(offsetMinutes) % 60;
|
||||
return `${offsetMinutes >= 0 ? '+' : '-'}${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
|
||||
}
|
||||
try {
|
||||
const offset = new Intl.DateTimeFormat('en-US', {timeZone: tzName, timeZoneName: 'longOffset', hour: '2-digit', minute: '2-digit',})
|
||||
const offset = new Intl.DateTimeFormat('en-US', {timeZone: <string>tzName, timeZoneName: 'longOffset', hour: '2-digit', minute: '2-digit',})
|
||||
.formatToParts(<Date>date).find(p => p.type === 'timeZoneName')?.value.match(/([+-]\d{2}:\d{2})/)?.[1];
|
||||
if (offset) return offset;
|
||||
} catch {}
|
||||
@@ -154,12 +196,11 @@ export function formatDate(format: string = 'YYYY-MM-DD H:mm', date: Date | numb
|
||||
}
|
||||
|
||||
function getTZAbbr(): string {
|
||||
if (numericTz && tz === 0) return 'UTC';
|
||||
try {
|
||||
return new Intl.DateTimeFormat('en-US', { timeZone: tzName, timeZoneName: 'short' })
|
||||
return new Intl.DateTimeFormat('en-US', { timeZone: <string>tzName, timeZoneName: 'short' })
|
||||
.formatToParts(<Date>date).find(p => p.type === 'timeZoneName')?.value || '';
|
||||
} catch {
|
||||
return tzName;
|
||||
return <string>tzName;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
199
src/xml.ts
Normal file
199
src/xml.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Parses an XML string into a structured JavaScript object.
|
||||
* @param {string} xml - The XML string to parse
|
||||
* @returns {Object} An object with `tag`, `attributes`, and `children` properties
|
||||
*/
|
||||
/**
|
||||
* Parses an XML string into a structured JavaScript object (fast-xml-parser format).
|
||||
* @param {string} xml - The XML string to parse
|
||||
* @returns {Object} An object with tag names as keys and text content or nested objects as values
|
||||
*/
|
||||
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] === '?') {
|
||||
const declaration = parseDeclaration();
|
||||
return { ['?' + declaration]: '', ...parseNode() };
|
||||
}
|
||||
|
||||
if(xml[pos] === '!') {
|
||||
parseComment();
|
||||
return parseNode();
|
||||
}
|
||||
|
||||
const tagName = parseTagName();
|
||||
const attributes = parseAttributes();
|
||||
skipWhitespace();
|
||||
|
||||
if(xml[pos] === '/' && xml[pos + 1] === '>') {
|
||||
pos += 2; // skip />
|
||||
return { [tagName]: '' };
|
||||
}
|
||||
|
||||
pos++; // skip >
|
||||
const children: any[] = [];
|
||||
let textContent = '';
|
||||
|
||||
while(pos < xml.length) {
|
||||
skipWhitespace();
|
||||
if(xml[pos] === '<' && xml[pos + 1] === '/') {
|
||||
pos += 2; // skip </
|
||||
parseTagName(); // skip closing tag name
|
||||
skipWhitespace();
|
||||
pos++; // skip >
|
||||
break;
|
||||
}
|
||||
const startPos = pos;
|
||||
const child = parseNode();
|
||||
if(typeof child === 'string') {
|
||||
textContent += child;
|
||||
} else if(child) {
|
||||
children.push(child);
|
||||
}
|
||||
}
|
||||
|
||||
// If only text content, return simple value
|
||||
if(children.length === 0 && textContent) {
|
||||
const value = isNumeric(textContent) ? Number(textContent) : textContent;
|
||||
return { [tagName]: value };
|
||||
}
|
||||
|
||||
// If only text with no children
|
||||
if(children.length === 0) {
|
||||
return { [tagName]: '' };
|
||||
}
|
||||
|
||||
// Merge children into object
|
||||
const result: any = {};
|
||||
for(const child of children) {
|
||||
for(const [key, value] of Object.entries(child)) {
|
||||
if(result[key]) {
|
||||
// Convert to array if duplicate tags
|
||||
if(!Array.isArray(result[key])) {
|
||||
result[key] = [result[key]];
|
||||
}
|
||||
result[key].push(value);
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { [tagName]: result };
|
||||
}
|
||||
|
||||
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() {
|
||||
pos++; // skip ?
|
||||
let name = '';
|
||||
while (pos < xml.length && xml[pos] !== ' ' && xml[pos] !== '?') {
|
||||
name += xml[pos++];
|
||||
}
|
||||
while (xml[pos] !== '>') pos++;
|
||||
pos++;
|
||||
return name;
|
||||
}
|
||||
|
||||
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++;
|
||||
}
|
||||
|
||||
function isNumeric(str: string) {
|
||||
return !isNaN(Number(str)) && !isNaN(parseFloat(str)) && str.trim() !== '';
|
||||
}
|
||||
|
||||
return parseNode();
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a JavaScript object into an XML string.
|
||||
* @param {Object} obj - Object with `tag`, `attributes`, and `children` properties, or a string
|
||||
* @param {string} indent - Current indentation level (used internally for formatting)
|
||||
* @returns {string} The formatted XML string
|
||||
*/
|
||||
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(<any>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 += `</${tag}>`;
|
||||
return xml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes or unescapes XML special characters.
|
||||
* @param {string} str - The string to process
|
||||
* @param {boolean} decode - If true, decodes XML entities; if false, encodes special characters
|
||||
* @returns {string} The processed string
|
||||
*/
|
||||
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, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
210
tests/xml.spec.ts
Normal file
210
tests/xml.spec.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { toXml, fromXml } from '../src';
|
||||
|
||||
describe('XML Parser', () => {
|
||||
describe('fromXml', () => {
|
||||
it('should parse simple tag', () => {
|
||||
const xml = '<root></root>';
|
||||
const result = fromXml(xml);
|
||||
expect(result).toEqual({ root: '' });
|
||||
});
|
||||
|
||||
it('should parse self-closing tag', () => {
|
||||
const xml = '<item />';
|
||||
const result = fromXml(xml);
|
||||
expect(result).toEqual({ item: '' });
|
||||
});
|
||||
|
||||
it('should parse tag with attributes (ignored in fast-xml-parser format)', () => {
|
||||
const xml = '<user id="1" name="someone" />';
|
||||
const result = fromXml(xml);
|
||||
expect(result).toEqual({ user: '' });
|
||||
});
|
||||
|
||||
it('should parse tag with text content', () => {
|
||||
const xml = '<email>someone@example.com</email>';
|
||||
const result = fromXml(xml);
|
||||
expect(result).toEqual({ email: 'someone@example.com' });
|
||||
});
|
||||
|
||||
it('should parse tag with numeric content', () => {
|
||||
const xml = '<ttl>240</ttl>';
|
||||
const result = fromXml(xml);
|
||||
expect(result).toEqual({ ttl: 240 });
|
||||
});
|
||||
|
||||
it('should parse nested tags', () => {
|
||||
const xml = '<root><child>text</child></root>';
|
||||
const result = fromXml(xml);
|
||||
expect(result).toEqual({
|
||||
root: {
|
||||
child: 'text'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse multiple children with same tag as array', () => {
|
||||
const xml = '<root><item>a</item><item>b</item><item>c</item></root>';
|
||||
const result = fromXml(xml);
|
||||
expect(result).toEqual({
|
||||
root: {
|
||||
item: ['a', 'b', 'c']
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse mixed children', () => {
|
||||
const xml = '<root><a>1</a><b>2</b><c>3</c></root>';
|
||||
const result = fromXml(xml);
|
||||
expect(result.root).toEqual({ a: 1, b: 2, c: 3 });
|
||||
});
|
||||
|
||||
it('should skip XML declaration and include as key', () => {
|
||||
const xml = '<?xml version="1.0"?><root />';
|
||||
const result = fromXml(xml);
|
||||
expect(result).toHaveProperty('?xml');
|
||||
expect(result).toHaveProperty('root');
|
||||
});
|
||||
|
||||
it('should skip comments', () => {
|
||||
const xml = '<root><!-- comment --><child>text</child></root>';
|
||||
const result = fromXml(xml);
|
||||
expect(result).toEqual({
|
||||
root: {
|
||||
child: 'text'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle escaped characters', () => {
|
||||
const xml = '<text><hello> & "world"</text>';
|
||||
const result = fromXml(xml);
|
||||
expect(result.text).toBe('<hello> & "world"');
|
||||
});
|
||||
|
||||
it('should parse complex nested structure', () => {
|
||||
const xml = `
|
||||
<root>
|
||||
<user id="1" name="someone">
|
||||
<email>someone@example.com</email>
|
||||
<active />
|
||||
</user>
|
||||
</root>
|
||||
`;
|
||||
const result = fromXml(xml);
|
||||
expect(result).toEqual({
|
||||
root: {
|
||||
user: {
|
||||
email: 'someone@example.com',
|
||||
active: ''
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse RSS-like structure with multiple items', () => {
|
||||
const xml = `
|
||||
<rss>
|
||||
<channel>
|
||||
<title>Test Feed</title>
|
||||
<item>
|
||||
<title>Item 1</title>
|
||||
<link>http://example.com/1</link>
|
||||
</item>
|
||||
<item>
|
||||
<title>Item 2</title>
|
||||
<link>http://example.com/2</link>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
`;
|
||||
const result = fromXml(xml);
|
||||
expect(result.rss.channel.title).toBe('Test Feed');
|
||||
expect(Array.isArray(result.rss.channel.item)).toBe(true);
|
||||
expect(result.rss.channel.item.length).toBe(2);
|
||||
expect(result.rss.channel.item[0].title).toBe('Item 1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toXml', () => {
|
||||
it('should encode simple tag', () => {
|
||||
const obj = { tag: 'root', attributes: {}, children: [] };
|
||||
expect(toXml(obj)).toBe('<root />');
|
||||
});
|
||||
|
||||
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('<email>someone@example.com</email>');
|
||||
});
|
||||
|
||||
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('<root>');
|
||||
expect(result).toContain(' <child>');
|
||||
expect(result).toContain('</root>');
|
||||
});
|
||||
|
||||
it('should escape special characters', () => {
|
||||
const obj = { tag: 'text', attributes: {}, children: ['<hello> & "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('<a />');
|
||||
expect(result).toContain('<b />');
|
||||
});
|
||||
|
||||
it('should encode string directly', () => {
|
||||
expect(toXml('hello')).toBe('hello');
|
||||
expect(toXml('a & b')).toBe('a & b');
|
||||
});
|
||||
});
|
||||
|
||||
describe('round-trip', () => {
|
||||
it('should parse toXml output back to fast-xml-parser format', () => {
|
||||
const obj = {
|
||||
tag: 'root',
|
||||
attributes: { id: '1' },
|
||||
children: [
|
||||
{ tag: 'child', attributes: {}, children: ['text'] }
|
||||
]
|
||||
};
|
||||
const xml = toXml(obj);
|
||||
const parsed = fromXml(xml);
|
||||
expect(parsed).toEqual({
|
||||
root: {
|
||||
child: 'text'
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user