Compare commits

..

6 Commits

Author SHA1 Message Date
28716c7b5a Added titleCase helper
All checks were successful
Build / Publish Docs (push) Successful in 2m20s
Build / Build NPM Project (push) Successful in 2m29s
Build / Tag Version (push) Successful in 9s
2026-04-15 21:59:42 -04:00
d530f6abdf Cross timezone day of week fix
All checks were successful
Build / Build NPM Project (push) Successful in 53s
Build / Tag Version (push) Successful in 11s
Build / Publish Docs (push) Successful in 40s
2026-04-11 23:19:10 -04:00
cbee6a4509 Timezone abbreviation support
All checks were successful
Build / Publish Docs (push) Successful in 1m48s
Build / Build NPM Project (push) Successful in 2m48s
Build / Tag Version (push) Successful in 10s
2026-04-11 16:29:53 -04:00
e8f81bb584 Proper xml to json format
All checks were successful
Build / Publish Docs (push) Successful in 49s
Build / Build NPM Project (push) Successful in 56s
Build / Tag Version (push) Successful in 10s
2026-04-04 18:32:48 -04:00
4179b4010a Revert "added fast-xml-parser for testing"
Some checks failed
Build / Tag Version (push) Has been cancelled
Build / Build NPM Project (push) Has been cancelled
Build / Publish Docs (push) Has been cancelled
This reverts commit 15ac52b6a0.
2026-04-04 18:20:44 -04:00
15ac52b6a0 added fast-xml-parser for testing
Some checks failed
Build / Publish Docs (push) Failing after 16s
Build / Build NPM Project (push) Failing after 54s
Build / Tag Version (push) Has been skipped
2026-04-04 18:18:24 -04:00
5 changed files with 204 additions and 66 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "@ztimson/utils",
"version": "0.29.0",
"version": "0.29.4",
"description": "Utility library",
"author": "Zak Timson",
"license": "MIT",

View File

@@ -241,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)
*
@@ -257,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.
*

View File

@@ -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;
}
}

View File

@@ -3,6 +3,11 @@
* @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;
@@ -13,8 +18,8 @@ export function fromXml(xml: string) {
pos++; // skip <
if(xml[pos] === '?') {
parseDeclaration();
return parseNode();
const declaration = parseDeclaration();
return { ['?' + declaration]: '', ...parseNode() };
}
if(xml[pos] === '!') {
@@ -28,11 +33,13 @@ export function fromXml(xml: string) {
if(xml[pos] === '/' && xml[pos + 1] === '>') {
pos += 2; // skip />
return { tag: tagName, attributes, children: [] };
return { [tagName]: '' };
}
pos++; // skip >
const children = [];
const children: any[] = [];
let textContent = '';
while(pos < xml.length) {
skipWhitespace();
if(xml[pos] === '<' && xml[pos + 1] === '/') {
@@ -42,20 +49,51 @@ export function fromXml(xml: string) {
pos++; // skip >
break;
}
const startPos = pos;
const child = parseNode();
if(child) children.push(child);
if(typeof child === 'string') {
textContent += child;
} else if(child) {
children.push(child);
}
return { tag: tagName, attributes, children };
}
/** Parses and returns the tag name at the current position */
// 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;
}
/** Parses and returns an object containing all attributes at the current position */
function parseAttributes() {
const attrs: any = {};
while (pos < xml.length) {
@@ -76,7 +114,6 @@ export function fromXml(xml: string) {
return attrs;
}
/** Parses and returns text content, or null if empty */
function parseText() {
let text = '';
while (pos < xml.length && xml[pos] !== '<') text += xml[pos++];
@@ -84,23 +121,30 @@ export function fromXml(xml: string) {
return text ? escapeXml(text, true) : null;
}
/** Skips over XML declaration (<?xml ... ?>) */
function parseDeclaration() {
pos++; // skip ?
let name = '';
while (pos < xml.length && xml[pos] !== ' ' && xml[pos] !== '?') {
name += xml[pos++];
}
while (xml[pos] !== '>') pos++;
pos++;
return name;
}
/** Skips over XML comments (<!-- ... -->) */
function parseComment() {
while (!(xml[pos] === '-' && xml[pos + 1] === '-' && xml[pos + 2] === '>')) pos++;
pos += 3;
}
/** Advances position past any whitespace characters */
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();
}

View File

@@ -5,71 +5,80 @@ describe('XML Parser', () => {
it('should parse simple tag', () => {
const xml = '<root></root>';
const result = fromXml(xml);
expect(result).toEqual({ tag: 'root', attributes: {}, children: [] });
expect(result).toEqual({ root: '' });
});
it('should parse self-closing tag', () => {
const xml = '<item />';
const result = fromXml(xml);
expect(result).toEqual({ tag: 'item', attributes: {}, children: [] });
expect(result).toEqual({ item: '' });
});
it('should parse tag with attributes', () => {
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({
tag: 'user',
attributes: { id: '1', name: 'someone' },
children: []
});
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({
tag: 'email',
attributes: {},
children: ['someone@example.com']
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({
tag: 'root',
attributes: {},
children: [
{ tag: 'child', attributes: {}, children: ['text'] }
]
root: {
child: 'text'
}
});
});
it('should parse multiple children', () => {
const xml = '<root><a /><b /><c /></root>';
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.children.length).toBe(3);
expect(result.children[0]).toEqual({ tag: 'a', attributes: {}, children: [] });
expect(result).toEqual({
root: {
item: ['a', 'b', 'c']
}
});
});
it('should skip XML declaration', () => {
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.tag).toBe('root');
expect(result).toHaveProperty('?xml');
expect(result).toHaveProperty('root');
});
it('should skip comments', () => {
const xml = '<root><!-- comment --><child /></root>';
const xml = '<root><!-- comment --><child>text</child></root>';
const result = fromXml(xml);
expect(result.children.length).toBe(1);
expect(result.children[0].tag).toBe('child');
expect(result).toEqual({
root: {
child: 'text'
}
});
});
it('should handle escaped characters', () => {
const xml = '<text>&lt;hello&gt; &amp; &quot;world&quot;</text>';
const result = fromXml(xml);
expect(result.children[0]).toBe('<hello> & "world"');
expect(result.text).toBe('<hello> & "world"');
});
it('should parse complex nested structure', () => {
@@ -82,10 +91,37 @@ describe('XML Parser', () => {
</root>
`;
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);
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');
});
});
@@ -154,7 +190,7 @@ describe('XML Parser', () => {
});
describe('round-trip', () => {
it('should encode and decode to same structure', () => {
it('should parse toXml output back to fast-xml-parser format', () => {
const obj = {
tag: 'root',
attributes: { id: '1' },
@@ -164,7 +200,11 @@ describe('XML Parser', () => {
};
const xml = toXml(obj);
const parsed = fromXml(xml);
expect(parsed).toEqual(obj);
expect(parsed).toEqual({
root: {
child: 'text'
}
});
});
});
});