From 1025db99d2e10a34ca301b56c51af078c2206aa0 Mon Sep 17 00:00:00 2001 From: Jason Buckner Date: Wed, 27 May 2026 13:58:46 -0700 Subject: [PATCH 1/8] WEBDEV-8507: Migrate field-parsers into elements Moves @internetarchive/field-parsers source and tests into src/parsers/, ported to vitest. EpochUnitMultiplier enum converted to a const object for erasableSyntaxOnly compatibility; unused trace import in epoch.ts dropped to satisfy noUnusedLocals. Co-Authored-By: Claude Opus 4.7 --- src/parsers/field-parser-interface.ts | 10 + src/parsers/field-types/boolean.test.ts | 35 +++ src/parsers/field-types/boolean.ts | 21 ++ src/parsers/field-types/byte.test.ts | 35 +++ src/parsers/field-types/byte.ts | 30 +++ src/parsers/field-types/date.test.ts | 137 ++++++++++++ src/parsers/field-types/date.ts | 57 +++++ src/parsers/field-types/duration.test.ts | 49 +++++ src/parsers/field-types/duration.ts | 76 +++++++ src/parsers/field-types/epoch.test.ts | 204 ++++++++++++++++++ src/parsers/field-types/epoch.ts | 127 +++++++++++ src/parsers/field-types/list.test.ts | 92 ++++++++ src/parsers/field-types/list.ts | 45 ++++ src/parsers/field-types/mediatype.test.ts | 31 +++ src/parsers/field-types/mediatype.ts | 29 +++ src/parsers/field-types/number.test.ts | 35 +++ src/parsers/field-types/number.ts | 22 ++ .../field-types/page-progression.test.ts | 23 ++ src/parsers/field-types/page-progression.ts | 20 ++ src/parsers/field-types/string.test.ts | 11 + src/parsers/field-types/string.ts | 15 ++ src/parsers/timed.ts | 26 +++ src/parsers/trace.ts | 52 +++++ 23 files changed, 1182 insertions(+) create mode 100644 src/parsers/field-parser-interface.ts create mode 100644 src/parsers/field-types/boolean.test.ts create mode 100644 src/parsers/field-types/boolean.ts create mode 100644 src/parsers/field-types/byte.test.ts create mode 100644 src/parsers/field-types/byte.ts create mode 100644 src/parsers/field-types/date.test.ts create mode 100644 src/parsers/field-types/date.ts create mode 100644 src/parsers/field-types/duration.test.ts create mode 100644 src/parsers/field-types/duration.ts create mode 100644 src/parsers/field-types/epoch.test.ts create mode 100644 src/parsers/field-types/epoch.ts create mode 100644 src/parsers/field-types/list.test.ts create mode 100644 src/parsers/field-types/list.ts create mode 100644 src/parsers/field-types/mediatype.test.ts create mode 100644 src/parsers/field-types/mediatype.ts create mode 100644 src/parsers/field-types/number.test.ts create mode 100644 src/parsers/field-types/number.ts create mode 100644 src/parsers/field-types/page-progression.test.ts create mode 100644 src/parsers/field-types/page-progression.ts create mode 100644 src/parsers/field-types/string.test.ts create mode 100644 src/parsers/field-types/string.ts create mode 100644 src/parsers/timed.ts create mode 100644 src/parsers/trace.ts diff --git a/src/parsers/field-parser-interface.ts b/src/parsers/field-parser-interface.ts new file mode 100644 index 0000000..64815d3 --- /dev/null +++ b/src/parsers/field-parser-interface.ts @@ -0,0 +1,10 @@ +export type FieldParserRawValue = string | number | boolean; + +export interface FieldParserInterface { + /** + * Parse the raw value and return a value of type T or undefined if unparseable + * + * @param rawValue T | undefined + */ + parseValue(rawValue: FieldParserRawValue): T | undefined; +} diff --git a/src/parsers/field-types/boolean.test.ts b/src/parsers/field-types/boolean.test.ts new file mode 100644 index 0000000..fd8d53e --- /dev/null +++ b/src/parsers/field-types/boolean.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from 'vitest'; + +import { BooleanParser } from './boolean'; + +describe('BooleanParser', () => { + test('can parse string number truthy', () => { + const parser = new BooleanParser(); + const response = parser.parseValue('1'); + expect(response).toBe(true); + }); + + test('can parse string number falsy', () => { + const parser = new BooleanParser(); + const response = parser.parseValue('0'); + expect(response).toBe(false); + }); + + test('can parse words truthy', () => { + const parser = new BooleanParser(); + const response = parser.parseValue('true'); + expect(response).toBe(true); + }); + + test('can parse words falsy', () => { + const parser = new BooleanParser(); + const response = parser.parseValue('false'); + expect(response).toBe(false); + }); + + test('can parse date truthy', () => { + const parser = new BooleanParser(); + const response = parser.parseValue(Date()); + expect(response).toBe(true); + }); +}); diff --git a/src/parsers/field-types/boolean.ts b/src/parsers/field-types/boolean.ts new file mode 100644 index 0000000..aeea104 --- /dev/null +++ b/src/parsers/field-types/boolean.ts @@ -0,0 +1,21 @@ +import { + FieldParserInterface, + FieldParserRawValue, +} from '../field-parser-interface'; + +export class BooleanParser implements FieldParserInterface { + // use a shared static instance for performance instead of + // instantiating a new instance for every use + static shared = new BooleanParser(); + + /** @inheritdoc */ + parseValue(rawValue: FieldParserRawValue): boolean { + if ( + typeof rawValue === 'string' && + (rawValue === 'false' || rawValue === '0') + ) { + return false; + } + return Boolean(rawValue); + } +} diff --git a/src/parsers/field-types/byte.test.ts b/src/parsers/field-types/byte.test.ts new file mode 100644 index 0000000..5cb1203 --- /dev/null +++ b/src/parsers/field-types/byte.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from 'vitest'; + +import { ByteParser } from './byte'; + +describe('ByteParser', () => { + test('can parse int strings', () => { + const parser = new ByteParser(); + const response = parser.parseValue('3'); + expect(response).toBe(3); + }); + + test('can parse float strings', () => { + const parser = new ByteParser(); + const response = parser.parseValue('3.14'); + expect(response).toBe(3.14); + }); + + test('returns undefined if the number is not a number', () => { + const parser = new ByteParser(); + const response = parser.parseValue('qab'); + expect(response).toBeUndefined(); + }); + + test('returns the number if a number is passed', () => { + const parser = new ByteParser(); + const response = parser.parseValue(5.67); + expect(response).toBe(5.67); + }); + + test('returns undefined if a boolean is passed in', () => { + const parser = new ByteParser(); + const response = parser.parseValue(true); + expect(response).toBeUndefined(); + }); +}); diff --git a/src/parsers/field-types/byte.ts b/src/parsers/field-types/byte.ts new file mode 100644 index 0000000..c5dece8 --- /dev/null +++ b/src/parsers/field-types/byte.ts @@ -0,0 +1,30 @@ +import { + FieldParserInterface, + FieldParserRawValue, +} from '../field-parser-interface'; +import { NumberParser } from './number'; + +/** + * A Byte is a unit-specific `number`, in bytes. + */ +export type Byte = number; + +/** + * The ByteParser is a unit-specific NumberParser + * that returns a value in bytes + * + * @export + * @class ByteParser + * @implements {FieldParserInterface} + */ +export class ByteParser implements FieldParserInterface { + // use a shared static instance for performance instead of + // instantiating a new instance for every use + static shared = new ByteParser(); + + /** @inheritdoc */ + parseValue(rawValue: FieldParserRawValue): Byte | undefined { + const parser = NumberParser.shared; + return parser.parseValue(rawValue); + } +} diff --git a/src/parsers/field-types/date.test.ts b/src/parsers/field-types/date.test.ts new file mode 100644 index 0000000..5d53091 --- /dev/null +++ b/src/parsers/field-types/date.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, test } from 'vitest'; + +import { DateParser } from './date'; + +describe('DateParser', () => { + test('can parse date-only strings', () => { + const parser = new DateParser(); + const response = parser.parseValue('2020-06-20'); + const response2 = parser.parseValue('06/20/2020'); + const expected = new Date(); + expected.setHours(0); + expected.setMinutes(0); + expected.setSeconds(0); + expected.setMilliseconds(0); + expected.setMonth(5); + expected.setDate(20); + expected.setFullYear(2020); + expect(response?.getTime()).toBe(expected.getTime()); + expect(response2?.getTime()).toBe(expected.getTime()); + }); + + test('can parse date-time strings', () => { + const parser = new DateParser(); + const response = parser.parseValue('2020-06-20 3:46:23'); + const response2 = parser.parseValue('2020-06-20 03:46:23'); + const expected = new Date(); + expected.setHours(3); + expected.setMinutes(46); + expected.setSeconds(23); + expected.setMilliseconds(0); + expected.setMonth(5); + expected.setDate(20); + expected.setFullYear(2020); + expect(response?.getTime()).toBe(expected.getTime()); + expect(response2?.getTime()).toBe(expected.getTime()); + }); + + test('can parse date-time strings different string test', () => { + const parser = new DateParser(); + const response = parser.parseValue('2020-09-20 05:12:38'); + const expected = new Date(); + expected.setHours(5); + expected.setMinutes(12); + expected.setSeconds(38); + expected.setMilliseconds(0); + expected.setMonth(8); + expected.setDate(20); + expected.setFullYear(2020); + expect(response?.getTime()).toBe(expected.getTime()); + }); + + test('can parse ISO8601 strings without time zones', () => { + const parser = new DateParser(); + const response = parser.parseValue('2020-06-20T13:37:15'); + const expected = new Date(); + expected.setHours(13); + expected.setMinutes(37); + expected.setSeconds(15); + expected.setMilliseconds(0); + expected.setMonth(5); + expected.setDate(20); + expected.setFullYear(2020); + expect(response?.getTime()).toBe(expected.getTime()); + }); + + test('can parse ISO8601 strings with explicit time zones', () => { + const parser = new DateParser(); + const response = parser.parseValue('2020-06-20T13:37:15Z'); + const response2 = parser.parseValue('2020-06-20T13:37:15-00:00'); + const response3 = parser.parseValue('2020-06-20T13:37:15+00:00'); + const expected = new Date(); + expected.setUTCHours(13); + expected.setUTCMinutes(37); + expected.setUTCSeconds(15); + expected.setUTCMilliseconds(0); + expected.setUTCMonth(5); + expected.setUTCDate(20); + expected.setUTCFullYear(2020); + expect(response?.getTime()).toBe(expected.getTime()); + expect(response2?.getTime()).toBe(expected.getTime()); + expect(response3?.getTime()).toBe(expected.getTime()); + }); + + test('can parse "c.a. yyyy" formatted dates', () => { + const parser = new DateParser(); + const response = parser.parseValue('c.a. 2020'); + const expected = new Date(); + expected.setHours(0); + expected.setMinutes(0); + expected.setSeconds(0); + expected.setMilliseconds(0); + expected.setMonth(0); + expected.setDate(1); + expected.setFullYear(2020); + expect(response?.getTime()).toBe(expected.getTime()); + }); + + test('can parse "ca yyyy" formatted dates', () => { + const parser = new DateParser(); + const response = parser.parseValue('ca 2020'); + const expected = new Date(); + expected.setHours(0); + expected.setMinutes(0); + expected.setSeconds(0); + expected.setMilliseconds(0); + expected.setMonth(0); + expected.setDate(1); + expected.setFullYear(2020); + expect(response?.getTime()).toBe(expected.getTime()); + }); + + test('can parse "[yyyy]" formatted dates', () => { + const parser = new DateParser(); + const response = parser.parseValue('[2020]'); + const expected = new Date(); + expected.setHours(0); + expected.setMinutes(0); + expected.setSeconds(0); + expected.setMilliseconds(0); + expected.setMonth(0); + expected.setDate(1); + expected.setFullYear(2020); + expect(response?.getTime()).toBe(expected.getTime()); + }); + + test('returns undefined if it is a bad date', () => { + const parser = new DateParser(); + const response = parser.parseValue('absjkdvfnskj'); + expect(response).toBeUndefined(); + }); + + test('returns undefined if passed a non-string value', () => { + const parser = new DateParser(); + const response = parser.parseValue(123); + expect(response).toBeUndefined(); + }); +}); diff --git a/src/parsers/field-types/date.ts b/src/parsers/field-types/date.ts new file mode 100644 index 0000000..0dd9da4 --- /dev/null +++ b/src/parsers/field-types/date.ts @@ -0,0 +1,57 @@ +import { + FieldParserInterface, + FieldParserRawValue, +} from '../field-parser-interface'; + +export class DateParser implements FieldParserInterface { + // use a shared static instance for performance instead of + // instantiating a new instance for every use + static shared = new DateParser(); + + /** @inheritdoc */ + parseValue(rawValue: FieldParserRawValue): Date | undefined { + // try different date parsing + return this.parseJSDate(rawValue) || this.parseBracketDate(rawValue); + } + + // handles "[yyyy]" format + private parseBracketDate(rawValue: FieldParserRawValue): Date | undefined { + if (typeof rawValue !== 'string') return undefined; + const yearMatch = rawValue.match(/\[([0-9]{4})\]/); + if (!yearMatch || yearMatch.length < 2) { + return undefined; + } + return this.parseJSDate(yearMatch[1]); + } + + private parseJSDate(rawValue: FieldParserRawValue): Date | undefined { + if (typeof rawValue !== 'string') return undefined; + let parsedValue = rawValue; + + // fix for Safari not supporting `yyyy-mm-dd HH:MM:SS` format, insert a `T` into the space + if ( + parsedValue.match( + /^[0-9]{4}-[0-9]{2}-[0-9]{2}\s{1}[0-9]{2}:[0-9]{2}:[0-9]{2}$/, + ) + ) { + parsedValue = parsedValue.replace(' ', 'T'); + } + + const parsed = Date.parse(parsedValue); + if (Number.isNaN(parsed)) { + return undefined; + } + let date = new Date(parsedValue); + // The `Date(string)` constructor parses some strings as UTC and some in the local timezone. + // This attempts to detect cases that get parsed as UTC but should be parsed as local. + // Note that this does _not_ include cases with an explicit time zone specified, which + // should generally be parsed as-is and not converted to local time. + const isUTCTimeZoneInferred = + parsedValue.match(/^[0-9]{4}$/) || // just the year, ie `2020` + parsedValue.match(/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/); // YYYY-MM-DD format + if (isUTCTimeZoneInferred) { + date = new Date(date.getTime() + date.getTimezoneOffset() * 1000 * 60); + } + return date; + } +} diff --git a/src/parsers/field-types/duration.test.ts b/src/parsers/field-types/duration.test.ts new file mode 100644 index 0000000..42c59a0 --- /dev/null +++ b/src/parsers/field-types/duration.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from 'vitest'; + +import { DurationParser } from './duration'; + +describe('DurationParser', () => { + test('can parse mm:ss format', () => { + const parser = new DurationParser(); + const response = parser.parseValue('45:23'); + const expected = 23 + 45 * 60; + expect(response).toBe(expected); + }); + + test('can parse hh:mm:ss format', () => { + const parser = new DurationParser(); + const response = parser.parseValue('3:45:23'); + const expected = 23 + 45 * 60 + 3 * 60 * 60; + expect(response).toBe(expected); + }); + + test('returns undefined for a non-number component in hh:mm:ss format', () => { + const parser = new DurationParser(); + const response = parser.parseValue('3:AB:23'); + expect(response).toBeUndefined(); + }); + + test('can parse decimal format', () => { + const parser = new DurationParser(); + const response = parser.parseValue('345.23'); + expect(response).toBe(345.23); + }); + + test('returns undefined for non-numeric numbers', () => { + const parser = new DurationParser(); + const response = parser.parseValue('abc.de'); + expect(response).toBeUndefined(); + }); + + test('returns the number if passed a number', () => { + const parser = new DurationParser(); + const response = parser.parseValue(345.23); + expect(response).toBe(345.23); + }); + + test('returns undefined if passed a boolean', () => { + const parser = new DurationParser(); + const response = parser.parseValue(true); + expect(response).toBeUndefined(); + }); +}); diff --git a/src/parsers/field-types/duration.ts b/src/parsers/field-types/duration.ts new file mode 100644 index 0000000..8e5565a --- /dev/null +++ b/src/parsers/field-types/duration.ts @@ -0,0 +1,76 @@ +import { + FieldParserInterface, + FieldParserRawValue, +} from '../field-parser-interface'; + +/** + * Duration is a number in seconds + */ +export type Duration = number; + +/** + * Parses duration format to a `Duration` (number of seconds with decimal) + * + * Can parse hh:mm:ss.ms, hh:mm:ss, mm:ss, mm:ss.ms, and s.ms formats + */ +export class DurationParser implements FieldParserInterface { + // use a shared static instance for performance instead of + // instantiating a new instance for every use + static shared = new DurationParser(); + + /** @inheritdoc */ + parseValue(rawValue: FieldParserRawValue): Duration | undefined { + if (typeof rawValue === 'number') return rawValue; + if (typeof rawValue === 'boolean') return undefined; + + const componentArray: string[] = rawValue.split(':'); + let seconds: number | undefined; + // if there are no colons in the string, we can assume it's in sss.ms format so just parse it + if (componentArray.length === 1) { + seconds = this.parseNumberFormat(componentArray[0]); + } else { + seconds = this.parseColonSeparatedFormat(componentArray); + } + + return seconds; + } + + /** + * Parse sss.ms format + * + * @param rawValue + * @returns + */ + private parseNumberFormat(rawValue: string): number | undefined { + let seconds: number | undefined = parseFloat(rawValue); + if (Number.isNaN(seconds)) seconds = undefined; + return seconds; + } + + /** + * Parse hh:mm:ss.ms format + * + * @param componentArray + * @returns + */ + private parseColonSeparatedFormat( + componentArray: string[], + ): number | undefined { + // if any of the hh:mm:ss components are NaN, just return undefined + let hasNaNComponent = false; + const parsedValue = componentArray + .map((element: string, index: number) => { + const componentValue: number = parseFloat(element); + if (Number.isNaN(componentValue)) { + hasNaNComponent = true; + return 0; + } + const exponent: number = componentArray.length - 1 - index; + const multiplier: number = 60 ** exponent; + return componentValue * Math.floor(multiplier); + }) + .reduce((a, b) => a + b, 0); + + return hasNaNComponent ? undefined : parsedValue; + } +} diff --git a/src/parsers/field-types/epoch.test.ts b/src/parsers/field-types/epoch.test.ts new file mode 100644 index 0000000..dbf7217 --- /dev/null +++ b/src/parsers/field-types/epoch.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, test } from 'vitest'; + +import { EpochParser } from './epoch'; + +describe('EpochParser', () => { + test('can parse int strings', () => { + const parser = new EpochParser(); + const response = parser.parseValue('1637274397'); + const expected = new Date(); + expected.setHours(14); + expected.setMinutes(26); + expected.setSeconds(37); + expected.setMilliseconds(0); + expected.setMonth(10); + expected.setDate(18); + expected.setFullYear(2021); + expect(response?.getTime()).toBe(expected.getTime()); + }); + + test('can parse float strings', () => { + const parser = new EpochParser(); + const response = parser.parseValue('1637274397.123'); + const expected = new Date(); + expected.setHours(14); + expected.setMinutes(26); + expected.setSeconds(37); + expected.setMilliseconds(123); + expected.setMonth(10); + expected.setDate(18); + expected.setFullYear(2021); + expect(response?.getTime()).toBe(expected.getTime()); + }); + + test('can parse nanoseconds', () => { + const parser = new EpochParser(); + const response = parser.parseValue('1637274397123456789'); + const expected = new Date(); + expected.setHours(14); + expected.setMinutes(26); + expected.setSeconds(37); + expected.setMilliseconds(123); + expected.setMonth(10); + expected.setDate(18); + expected.setFullYear(2021); + expect(response?.getTime()).toBe(expected.getTime()); + }); + + test('can parse microseconds', () => { + const parser = new EpochParser(); + const response = parser.parseValue('1637274397123456'); + const expected = new Date(); + expected.setHours(14); + expected.setMinutes(26); + expected.setSeconds(37); + expected.setMilliseconds(123); + expected.setMonth(10); + expected.setDate(18); + expected.setFullYear(2021); + expect(response?.getTime()).toBe(expected.getTime()); + }); + + test('can parse milliseconds', () => { + const parser = new EpochParser(); + const response = parser.parseValue('1637274397123'); + const expected = new Date(); + expected.setHours(14); + expected.setMinutes(26); + expected.setSeconds(37); + expected.setMilliseconds(123); + expected.setMonth(10); + expected.setDate(18); + expected.setFullYear(2021); + expect(response?.getTime()).toBe(expected.getTime()); + }); + + test('can parse seconds', () => { + const parser = new EpochParser(); + const response = parser.parseValue('1637274397'); + const expected = new Date(); + expected.setHours(14); + expected.setMinutes(26); + expected.setSeconds(37); + expected.setMilliseconds(0); + expected.setMonth(10); + expected.setDate(18); + expected.setFullYear(2021); + expect(response?.getTime()).toBe(expected.getTime()); + }); + + test('returns undefined if the number is not a number', () => { + const parser = new EpochParser(); + const response = parser.parseValue('qab'); + expect(response).toBeUndefined(); + }); + + test('can parse number values', () => { + const parser = new EpochParser(); + const response = parser.parseValue(1637274397); + const expected = new Date(); + expected.setHours(14); + expected.setMinutes(26); + expected.setSeconds(37); + expected.setMilliseconds(0); + expected.setMonth(10); + expected.setDate(18); + expected.setFullYear(2021); + expect(response?.getTime()).toBe(expected.getTime()); + }); + + test('returns undefined if a boolean is passed in', () => { + const parser = new EpochParser(); + const response = parser.parseValue(true); + expect(response).toBeUndefined(); + }); + + test('defaults to auto detecting the input unit', () => { + const parser = new EpochParser(); + const response = parser.parseValue('1637274397123456789'); + const expected = new Date(); + expected.setHours(14); + expected.setMinutes(26); + expected.setSeconds(37); + expected.setMilliseconds(123); + expected.setMonth(10); + expected.setDate(18); + expected.setFullYear(2021); + expect(response?.getTime()).toBe(expected.getTime()); + }); + + test('has a shared static instance that auto detects the input unit', () => { + const parser = EpochParser.shared; + const response = parser.parseValue('1637274397123456789'); + const expected = new Date(); + expected.setHours(14); + expected.setMinutes(26); + expected.setSeconds(37); + expected.setMilliseconds(123); + expected.setMonth(10); + expected.setDate(18); + expected.setFullYear(2021); + expect(response?.getTime()).toBe(expected.getTime()); + }); + + test('has a millisecondsParser static instance that uses milliseconds', () => { + const parser = EpochParser.millisecondsParser; + const response = parser.parseValue(1637274397123); + const expected = new Date(); + expected.setHours(14); + expected.setMinutes(26); + expected.setSeconds(37); + expected.setMilliseconds(123); + expected.setMonth(10); + expected.setDate(18); + expected.setFullYear(2021); + expect(response?.getTime()).toBe(expected.getTime()); + }); + + test('has a secondsParser static instance that uses seconds', () => { + const parser = EpochParser.secondsParser; + const response = parser.parseValue(1637274397); + const expected = new Date(); + expected.setHours(14); + expected.setMinutes(26); + expected.setSeconds(37); + expected.setMilliseconds(0); + expected.setMonth(10); + expected.setDate(18); + expected.setFullYear(2021); + expect(response?.getTime()).toBe(expected.getTime()); + }); + + test('can specify input unit as nanoseconds', () => { + const parser = new EpochParser('nanoseconds'); + const response = parser.parseValue('1637274397123456789'); + const expected = new Date(); + expected.setHours(14); + expected.setMinutes(26); + expected.setSeconds(37); + expected.setMilliseconds(123); + expected.setMonth(10); + expected.setDate(18); + expected.setFullYear(2021); + expect(response?.getTime()).toBe(expected.getTime()); + }); + + test('can specify input unit as microseconds', () => { + const parser = new EpochParser('microseconds'); + const response = parser.parseValue('1637274397123456'); + const expected = new Date(); + expected.setHours(14); + expected.setMinutes(26); + expected.setSeconds(37); + expected.setMilliseconds(123); + expected.setMonth(10); + expected.setDate(18); + expected.setFullYear(2021); + expect(response?.getTime()).toBe(expected.getTime()); + }); + + test('has some slow method', () => { + const parser = new EpochParser(); + parser.someSlowMethod(); + }); +}); diff --git a/src/parsers/field-types/epoch.ts b/src/parsers/field-types/epoch.ts new file mode 100644 index 0000000..ca97a3e --- /dev/null +++ b/src/parsers/field-types/epoch.ts @@ -0,0 +1,127 @@ +import { + FieldParserInterface, + FieldParserRawValue, +} from '../field-parser-interface'; +import { trace2 } from '../trace'; +import { timed } from '../timed'; +import { NumberParser } from './number'; + +export type EpochInputUnit = + | 'nanoseconds' + | 'microseconds' + | 'milliseconds' + | 'seconds' + | 'auto'; + +const EpochUnitMultiplier = { + nanoseconds: 1e-6, + microseconds: 1e-3, + milliseconds: 1, + seconds: 1e3, +} as const; +type EpochUnitMultiplier = + (typeof EpochUnitMultiplier)[keyof typeof EpochUnitMultiplier]; + +/** + * Parses an epoch number or number string to a `Date` + * + * The epoch is the number of seconds since January 1, 1970, 00:00:00 UTC. + * + * Example: 1751336102 or '1751336102' will return a Date object representing: + * GMT: Tuesday, July 1, 2025 2:15:02 AM + */ +export class EpochParser implements FieldParserInterface { + /** + * A shared static instance of the EpochParser that tries to determine the input unit automatically. + * + * If you know the input unit, you should use the `millisecondsParser` or `secondsParser` + * instead for better performance. + * + * @static + * @memberof EpochParser + */ + static shared = new EpochParser(); + + /** + * A shared static instance of the EpochParser that assumes the input is in milliseconds. + * + * @static + * @memberof EpochParser + */ + static millisecondsParser = new EpochParser('milliseconds'); + + /** + * A shared static instance of the EpochParser that assumes the input is in seconds. + * + * @static + * @memberof EpochParser + */ + static secondsParser = new EpochParser('seconds'); + + private inputUnit: EpochInputUnit; + + /** + * @param inputUnit - The unit of the input epoch number. + * If 'auto', it will attempt to determine the unit based on the size of + * the number. + */ + constructor(inputUnit: EpochInputUnit = 'auto') { + this.inputUnit = inputUnit; + } + + /** @inheritdoc */ + parseValue(rawValue: FieldParserRawValue): Date | undefined { + const parser = NumberParser.shared; + const numberValue = parser.parseValue(rawValue); + if (numberValue) { + const multiplier = this.multipler(numberValue); + const millisecondsValue = numberValue * multiplier; + return new Date(millisecondsValue); + } + } + + @timed() + @trace2 + someSlowMethod() { + for (let i = 0; i < 1e6; i++) { + // EpochParser.secondsParser.parseValue(1637274397); // Example usage of the parser + } + } + + /** + * The `new Date(NUMBER)` constructor assumes NUMBER is in milliseconds, + * so we need to determine what the input number represents + * (seconds, milliseconds, microseconds, or nanoseconds) + * and return the appropriate multiplier to convert it to milliseconds. + */ + private multipler(value: number): number { + switch (this.inputUnit) { + case 'auto': + return this.detectMultiplier(value); + case 'nanoseconds': + return EpochUnitMultiplier.nanoseconds; + case 'microseconds': + return EpochUnitMultiplier.microseconds; + case 'milliseconds': + return EpochUnitMultiplier.milliseconds; + case 'seconds': + return EpochUnitMultiplier.seconds; + } + } + + private detectMultiplier(value: number): EpochUnitMultiplier { + if (value >= 1e16 || value <= -1e16) { + return EpochUnitMultiplier.nanoseconds; + } + + if (value >= 1e14 || value <= -1e14) { + return EpochUnitMultiplier.microseconds; + } + + if (value >= 1e11 || value <= -3e10) { + return EpochUnitMultiplier.milliseconds; + } + + return EpochUnitMultiplier.seconds; + } +} diff --git a/src/parsers/field-types/list.test.ts b/src/parsers/field-types/list.test.ts new file mode 100644 index 0000000..6348224 --- /dev/null +++ b/src/parsers/field-types/list.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, test } from 'vitest'; + +import { BooleanParser } from './boolean'; +import { ListParser } from './list'; +import { NumberParser } from './number'; +import { StringParser } from './string'; + +describe('ListParser', () => { + test('can parse a list of strings with commas', () => { + const stringParser = new StringParser(); + const parser = new ListParser(stringParser); + const response = parser.parseValue('foo, bar, baz'); + expect(response).toEqual(['foo', 'bar', 'baz']); + }); + + test('can parse a list of strings with semicolons', () => { + const stringParser = new StringParser(); + const parser = new ListParser(stringParser); + const response = parser.parseValue('foo; bar; baz'); + expect(response).toEqual(['foo', 'bar', 'baz']); + }); + + test('returns a single value if no list', () => { + const stringParser = new StringParser(); + const parser = new ListParser(stringParser); + const response = parser.parseValue('foo bar baz'); + expect(response).toEqual(['foo bar baz']); + }); + + test('trims whitespace in list items', () => { + const stringParser = new StringParser(); + const parser = new ListParser(stringParser); + const response = parser.parseValue(' foo , bar , baz'); + expect(response).toEqual(['foo', 'bar', 'baz']); + }); + + test('can parse a list of numbers with commas', () => { + const numberParser = new NumberParser(); + const parser = new ListParser(numberParser); + const response = parser.parseValue('1, 2, 3'); + expect(response).toEqual([1, 2, 3]); + }); + + test('can parse a list of numbers with a 0 in it for falsy protection', () => { + const numberParser = new NumberParser(); + const parser = new ListParser(numberParser); + const response = parser.parseValue('0, 1, 2, 3'); + expect(response).toEqual([0, 1, 2, 3]); + }); + + test('does not include non-numbers in result if numbers are intended', () => { + const numberParser = new NumberParser(); + const parser = new ListParser(numberParser); + const response = parser.parseValue('abc, 2, 3'); + expect(response).toEqual([2, 3]); + }); + + test('can parse a list of booleans with commas', () => { + const booleanParser = new BooleanParser(); + const parser = new ListParser(booleanParser); + const response = parser.parseValue('true, false, true'); + expect(response).toEqual([true, false, true]); + }); + + test('can parse a list of strings with custom separator', () => { + const stringParser = new StringParser(); + const parser = new ListParser(stringParser, { separators: ['-'] }); + const response = parser.parseValue('boop - bop - beep'); + expect(response).toEqual(['boop', 'bop', 'beep']); + }); + + test('can parse a list of strings with the second custom separator', () => { + const stringParser = new StringParser(); + const parser = new ListParser(stringParser, { separators: ['-', '|'] }); + const response = parser.parseValue('boop | bop | beep'); + expect(response).toEqual(['boop', 'bop', 'beep']); + }); + + test('defaults to semicolons before commas since commas are common in some terms', () => { + const stringParser = new StringParser(); + const parser = new ListParser(stringParser); + const response = parser.parseValue('10,000 Maniacs; Boop, Beep, Boop'); + expect(response).toEqual(['10,000 Maniacs', 'Boop, Beep, Boop']); + }); + + test('can parse period-separated lists', () => { + const stringParser = new StringParser(); + const parser = new ListParser(stringParser); + const response = parser.parseValue('Beep. Boop. Snap'); + expect(response).toEqual(['Beep', 'Boop', 'Snap']); + }); +}); diff --git a/src/parsers/field-types/list.ts b/src/parsers/field-types/list.ts new file mode 100644 index 0000000..62d87f5 --- /dev/null +++ b/src/parsers/field-types/list.ts @@ -0,0 +1,45 @@ +import { + FieldParserInterface, + FieldParserRawValue, +} from '../field-parser-interface'; + +export class ListParser implements FieldParserInterface { + private parser: FieldParserInterface; + + private separators = [';', ',', '.']; + + constructor( + parser: FieldParserInterface, + options?: { + separators?: string[]; + }, + ) { + this.parser = parser; + if (options && options.separators) { + this.separators = options.separators; + } + } + + /** @inheritdoc */ + parseValue(rawValue: FieldParserRawValue): T[] { + const stringifiedValue = String(rawValue); + let results: string[] = []; + + for (const separator of this.separators) { + results = stringifiedValue.split(separator); + if (results.length > 1) break; + } + + return this.parseListValues(results); + } + + private parseListValues(rawValues: string[]): T[] { + const trimmed = rawValues.map(s => s.trim()); + const parsed = trimmed.map(rawValue => this.parser.parseValue(rawValue)); + const result: T[] = []; + parsed.forEach(p => { + if (p !== undefined) result.push(p); + }); + return result; + } +} diff --git a/src/parsers/field-types/mediatype.test.ts b/src/parsers/field-types/mediatype.test.ts new file mode 100644 index 0000000..3ebfc12 --- /dev/null +++ b/src/parsers/field-types/mediatype.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from 'vitest'; + +import { MediaTypeParser } from './mediatype'; + +describe('MediaTypeParser', () => { + test('can parse mediatypes', () => { + const parser = new MediaTypeParser(); + expect(parser.parseValue('account')).toBe('account'); + expect(parser.parseValue('audio')).toBe('audio'); + expect(parser.parseValue('collection')).toBe('collection'); + expect(parser.parseValue('data')).toBe('data'); + expect(parser.parseValue('etree')).toBe('etree'); + expect(parser.parseValue('image')).toBe('image'); + expect(parser.parseValue('movies')).toBe('movies'); + expect(parser.parseValue('software')).toBe('software'); + expect(parser.parseValue('texts')).toBe('texts'); + expect(parser.parseValue('web')).toBe('web'); + }); + + test('returns undefined for number values', () => { + const parser = new MediaTypeParser(); + const response = parser.parseValue(15); + expect(response).toBeUndefined(); + }); + + test('returns undefined for boolean values', () => { + const parser = new MediaTypeParser(); + const response = parser.parseValue(true); + expect(response).toBeUndefined(); + }); +}); diff --git a/src/parsers/field-types/mediatype.ts b/src/parsers/field-types/mediatype.ts new file mode 100644 index 0000000..c5d8df9 --- /dev/null +++ b/src/parsers/field-types/mediatype.ts @@ -0,0 +1,29 @@ +import { + FieldParserInterface, + FieldParserRawValue, +} from '../field-parser-interface'; + +export type MediaType = + | 'account' + | 'audio' + | 'collection' + | 'data' + | 'etree' + | 'image' + | 'movies' + | 'search' + | 'software' + | 'texts' + | 'web'; + +export class MediaTypeParser implements FieldParserInterface { + // use a shared static instance for performance instead of + // instantiating a new instance for every use + static shared = new MediaTypeParser(); + + /** @inheritdoc */ + parseValue(rawValue: FieldParserRawValue): MediaType | undefined { + if (typeof rawValue !== 'string') return undefined; + return rawValue as MediaType; + } +} diff --git a/src/parsers/field-types/number.test.ts b/src/parsers/field-types/number.test.ts new file mode 100644 index 0000000..fb1c4c5 --- /dev/null +++ b/src/parsers/field-types/number.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from 'vitest'; + +import { NumberParser } from './number'; + +describe('NumberParser', () => { + test('can parse int strings', () => { + const parser = new NumberParser(); + const response = parser.parseValue('3'); + expect(response).toBe(3); + }); + + test('can parse float strings', () => { + const parser = new NumberParser(); + const response = parser.parseValue('3.14'); + expect(response).toBe(3.14); + }); + + test('returns undefined if the number is not a number', () => { + const parser = new NumberParser(); + const response = parser.parseValue('qab'); + expect(response).toBeUndefined(); + }); + + test('returns the number if a number is passed', () => { + const parser = new NumberParser(); + const response = parser.parseValue(5.67); + expect(response).toBe(5.67); + }); + + test('returns undefined if a boolean is passed in', () => { + const parser = new NumberParser(); + const response = parser.parseValue(true); + expect(response).toBeUndefined(); + }); +}); diff --git a/src/parsers/field-types/number.ts b/src/parsers/field-types/number.ts new file mode 100644 index 0000000..6f32ab6 --- /dev/null +++ b/src/parsers/field-types/number.ts @@ -0,0 +1,22 @@ +import { + FieldParserInterface, + FieldParserRawValue, +} from '../field-parser-interface'; + +export class NumberParser implements FieldParserInterface { + // use a shared static instance for performance instead of + // instantiating a new instance for every use + static shared = new NumberParser(); + + /** @inheritdoc */ + parseValue(rawValue: FieldParserRawValue): number | undefined { + if (typeof rawValue === 'number') return rawValue; + if (typeof rawValue === 'boolean') return undefined; + + const value = parseFloat(rawValue); + if (Number.isNaN(value)) { + return undefined; + } + return value; + } +} diff --git a/src/parsers/field-types/page-progression.test.ts b/src/parsers/field-types/page-progression.test.ts new file mode 100644 index 0000000..d7eb4c9 --- /dev/null +++ b/src/parsers/field-types/page-progression.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from 'vitest'; + +import { PageProgressionParser } from './page-progression'; + +describe('PageProgressionParser', () => { + test('can parse page progression', () => { + const parser = new PageProgressionParser(); + expect(parser.parseValue('rl')).toBe('rl'); + expect(parser.parseValue('lr')).toBe('lr'); + }); + + test('returns undefined for number values', () => { + const parser = new PageProgressionParser(); + const response = parser.parseValue(15); + expect(response).toBeUndefined(); + }); + + test('returns undefined for boolean values', () => { + const parser = new PageProgressionParser(); + const response = parser.parseValue(true); + expect(response).toBeUndefined(); + }); +}); diff --git a/src/parsers/field-types/page-progression.ts b/src/parsers/field-types/page-progression.ts new file mode 100644 index 0000000..c9aa4da --- /dev/null +++ b/src/parsers/field-types/page-progression.ts @@ -0,0 +1,20 @@ +import { + FieldParserInterface, + FieldParserRawValue, +} from '../field-parser-interface'; + +export type PageProgression = 'rl' | 'lr'; + +export class PageProgressionParser + implements FieldParserInterface +{ + // use a shared static instance for performance instead of + // instantiating a new instance for every use + static shared = new PageProgressionParser(); + + /** @inheritdoc */ + parseValue(rawValue: FieldParserRawValue): PageProgression | undefined { + if (typeof rawValue !== 'string') return undefined; + return rawValue as PageProgression; + } +} diff --git a/src/parsers/field-types/string.test.ts b/src/parsers/field-types/string.test.ts new file mode 100644 index 0000000..b9d5db1 --- /dev/null +++ b/src/parsers/field-types/string.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, test } from 'vitest'; + +import { StringParser } from './string'; + +describe('StringParser', () => { + test('can parse strings', () => { + const parser = new StringParser(); + const response = parser.parseValue('3'); + expect(response).toBe('3'); + }); +}); diff --git a/src/parsers/field-types/string.ts b/src/parsers/field-types/string.ts new file mode 100644 index 0000000..efcfd70 --- /dev/null +++ b/src/parsers/field-types/string.ts @@ -0,0 +1,15 @@ +import { + FieldParserInterface, + FieldParserRawValue, +} from '../field-parser-interface'; + +export class StringParser implements FieldParserInterface { + // use a shared static instance for performance instead of + // instantiating a new instance for every use + static shared = new StringParser(); + + /** @inheritdoc */ + parseValue(rawValue: FieldParserRawValue): string { + return String(rawValue); + } +} diff --git a/src/parsers/timed.ts b/src/parsers/timed.ts new file mode 100644 index 0000000..0b823f5 --- /dev/null +++ b/src/parsers/timed.ts @@ -0,0 +1,26 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +export function timed(): ( + target: any, + propertyKey: string, + descriptor: PropertyDescriptor, +) => PropertyDescriptor | void { + return function actualDecorator( + target: any, + propertyKey: string, + descriptor: PropertyDescriptor, + ): PropertyDescriptor | void { + const originalMethod = descriptor.value; + descriptor.value = function (...args: any[]) { + const start = Date.now(); + const result = originalMethod.apply(this, args); + const end = Date.now(); + const duration = end - start; + console.debug('Method', propertyKey, `executed in ${duration}ms`, { + target, + args, + }); + return result; + }; + return descriptor; + }; +} diff --git a/src/parsers/trace.ts b/src/parsers/trace.ts new file mode 100644 index 0000000..d995228 --- /dev/null +++ b/src/parsers/trace.ts @@ -0,0 +1,52 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +export function trace( + linePrefix: string = 'LOG:', +): ( + target: any, + propertyKey: string, + descriptor: PropertyDescriptor, +) => PropertyDescriptor | void { + return function actualDecorator( + target: any, + propertyKey: string, + descriptor: PropertyDescriptor, + ): PropertyDescriptor | void { + const originalMethod = descriptor.value; + descriptor.value = function (...args: any[]) { + console.debug(linePrefix, 'Entering method', target, propertyKey, args); + const result = originalMethod.apply(this, args); + console.debug( + linePrefix, + 'Exiting method', + target, + propertyKey, + args, + JSON.stringify(result), + ); + return result; + }; + return descriptor; + }; +} + +export function trace2( + target: any, + propertyKey: string, + descriptor: PropertyDescriptor, +): PropertyDescriptor | void { + const originalMethod = descriptor.value; + descriptor.value = function (...args: any[]) { + console.debug('BEEP', 'Entering method', target, propertyKey, args); + const result = originalMethod.apply(this, args); + console.debug( + 'BEEP', + 'Exiting method', + target, + propertyKey, + args, + JSON.stringify(result), + ); + return result; + }; + return descriptor; +} From 58cdc6827f65f3bb3d7336624fb19d30d05e431c Mon Sep 17 00:00:00 2001 From: Jason Buckner Date: Thu, 28 May 2026 15:41:36 -0700 Subject: [PATCH 2/8] WEBDEV-8507: Make EpochParser tests timezone-independent The epoch tests built their expected Date with local-timezone setters (setHours(14)...), which only equals the 1637274397 epoch in America/Los_Angeles. In CI's UTC environment the expectations were off by the 8h offset, failing 13/16 tests. Assert against the raw millisecond values the parser actually produces instead, which is timezone-agnostic. Co-Authored-By: Claude Opus 4.7 --- src/parsers/field-types/epoch.test.ts | 136 ++++---------------------- 1 file changed, 19 insertions(+), 117 deletions(-) diff --git a/src/parsers/field-types/epoch.test.ts b/src/parsers/field-types/epoch.test.ts index dbf7217..b9566de 100644 --- a/src/parsers/field-types/epoch.test.ts +++ b/src/parsers/field-types/epoch.test.ts @@ -2,89 +2,47 @@ import { describe, expect, test } from 'vitest'; import { EpochParser } from './epoch'; +// 1637274397 seconds = 2021-11-18T22:26:37.000Z. The parser treats epochs as +// UTC instants, so expectations are the raw millisecond values rather than +// wall-clock components (which would be timezone-dependent and fail in CI/UTC). +const EXPECTED_SECONDS_MS = 1637274397000; +const EXPECTED_SUBSECOND_MS = 1637274397123; + describe('EpochParser', () => { test('can parse int strings', () => { const parser = new EpochParser(); const response = parser.parseValue('1637274397'); - const expected = new Date(); - expected.setHours(14); - expected.setMinutes(26); - expected.setSeconds(37); - expected.setMilliseconds(0); - expected.setMonth(10); - expected.setDate(18); - expected.setFullYear(2021); - expect(response?.getTime()).toBe(expected.getTime()); + expect(response?.getTime()).toBe(EXPECTED_SECONDS_MS); }); test('can parse float strings', () => { const parser = new EpochParser(); const response = parser.parseValue('1637274397.123'); - const expected = new Date(); - expected.setHours(14); - expected.setMinutes(26); - expected.setSeconds(37); - expected.setMilliseconds(123); - expected.setMonth(10); - expected.setDate(18); - expected.setFullYear(2021); - expect(response?.getTime()).toBe(expected.getTime()); + expect(response?.getTime()).toBe(EXPECTED_SUBSECOND_MS); }); test('can parse nanoseconds', () => { const parser = new EpochParser(); const response = parser.parseValue('1637274397123456789'); - const expected = new Date(); - expected.setHours(14); - expected.setMinutes(26); - expected.setSeconds(37); - expected.setMilliseconds(123); - expected.setMonth(10); - expected.setDate(18); - expected.setFullYear(2021); - expect(response?.getTime()).toBe(expected.getTime()); + expect(response?.getTime()).toBe(EXPECTED_SUBSECOND_MS); }); test('can parse microseconds', () => { const parser = new EpochParser(); const response = parser.parseValue('1637274397123456'); - const expected = new Date(); - expected.setHours(14); - expected.setMinutes(26); - expected.setSeconds(37); - expected.setMilliseconds(123); - expected.setMonth(10); - expected.setDate(18); - expected.setFullYear(2021); - expect(response?.getTime()).toBe(expected.getTime()); + expect(response?.getTime()).toBe(EXPECTED_SUBSECOND_MS); }); test('can parse milliseconds', () => { const parser = new EpochParser(); const response = parser.parseValue('1637274397123'); - const expected = new Date(); - expected.setHours(14); - expected.setMinutes(26); - expected.setSeconds(37); - expected.setMilliseconds(123); - expected.setMonth(10); - expected.setDate(18); - expected.setFullYear(2021); - expect(response?.getTime()).toBe(expected.getTime()); + expect(response?.getTime()).toBe(EXPECTED_SUBSECOND_MS); }); test('can parse seconds', () => { const parser = new EpochParser(); const response = parser.parseValue('1637274397'); - const expected = new Date(); - expected.setHours(14); - expected.setMinutes(26); - expected.setSeconds(37); - expected.setMilliseconds(0); - expected.setMonth(10); - expected.setDate(18); - expected.setFullYear(2021); - expect(response?.getTime()).toBe(expected.getTime()); + expect(response?.getTime()).toBe(EXPECTED_SECONDS_MS); }); test('returns undefined if the number is not a number', () => { @@ -96,15 +54,7 @@ describe('EpochParser', () => { test('can parse number values', () => { const parser = new EpochParser(); const response = parser.parseValue(1637274397); - const expected = new Date(); - expected.setHours(14); - expected.setMinutes(26); - expected.setSeconds(37); - expected.setMilliseconds(0); - expected.setMonth(10); - expected.setDate(18); - expected.setFullYear(2021); - expect(response?.getTime()).toBe(expected.getTime()); + expect(response?.getTime()).toBe(EXPECTED_SECONDS_MS); }); test('returns undefined if a boolean is passed in', () => { @@ -116,85 +66,37 @@ describe('EpochParser', () => { test('defaults to auto detecting the input unit', () => { const parser = new EpochParser(); const response = parser.parseValue('1637274397123456789'); - const expected = new Date(); - expected.setHours(14); - expected.setMinutes(26); - expected.setSeconds(37); - expected.setMilliseconds(123); - expected.setMonth(10); - expected.setDate(18); - expected.setFullYear(2021); - expect(response?.getTime()).toBe(expected.getTime()); + expect(response?.getTime()).toBe(EXPECTED_SUBSECOND_MS); }); test('has a shared static instance that auto detects the input unit', () => { const parser = EpochParser.shared; const response = parser.parseValue('1637274397123456789'); - const expected = new Date(); - expected.setHours(14); - expected.setMinutes(26); - expected.setSeconds(37); - expected.setMilliseconds(123); - expected.setMonth(10); - expected.setDate(18); - expected.setFullYear(2021); - expect(response?.getTime()).toBe(expected.getTime()); + expect(response?.getTime()).toBe(EXPECTED_SUBSECOND_MS); }); test('has a millisecondsParser static instance that uses milliseconds', () => { const parser = EpochParser.millisecondsParser; const response = parser.parseValue(1637274397123); - const expected = new Date(); - expected.setHours(14); - expected.setMinutes(26); - expected.setSeconds(37); - expected.setMilliseconds(123); - expected.setMonth(10); - expected.setDate(18); - expected.setFullYear(2021); - expect(response?.getTime()).toBe(expected.getTime()); + expect(response?.getTime()).toBe(EXPECTED_SUBSECOND_MS); }); test('has a secondsParser static instance that uses seconds', () => { const parser = EpochParser.secondsParser; const response = parser.parseValue(1637274397); - const expected = new Date(); - expected.setHours(14); - expected.setMinutes(26); - expected.setSeconds(37); - expected.setMilliseconds(0); - expected.setMonth(10); - expected.setDate(18); - expected.setFullYear(2021); - expect(response?.getTime()).toBe(expected.getTime()); + expect(response?.getTime()).toBe(EXPECTED_SECONDS_MS); }); test('can specify input unit as nanoseconds', () => { const parser = new EpochParser('nanoseconds'); const response = parser.parseValue('1637274397123456789'); - const expected = new Date(); - expected.setHours(14); - expected.setMinutes(26); - expected.setSeconds(37); - expected.setMilliseconds(123); - expected.setMonth(10); - expected.setDate(18); - expected.setFullYear(2021); - expect(response?.getTime()).toBe(expected.getTime()); + expect(response?.getTime()).toBe(EXPECTED_SUBSECOND_MS); }); test('can specify input unit as microseconds', () => { const parser = new EpochParser('microseconds'); const response = parser.parseValue('1637274397123456'); - const expected = new Date(); - expected.setHours(14); - expected.setMinutes(26); - expected.setSeconds(37); - expected.setMilliseconds(123); - expected.setMonth(10); - expected.setDate(18); - expected.setFullYear(2021); - expect(response?.getTime()).toBe(expected.getTime()); + expect(response?.getTime()).toBe(EXPECTED_SUBSECOND_MS); }); test('has some slow method', () => { From 0394738aacc7b71557cdf62476d5a2fd1469e3f4 Mon Sep 17 00:00:00 2001 From: Jason Buckner Date: Mon, 1 Jun 2026 12:39:03 -0700 Subject: [PATCH 3/8] WEBDEV-8507: Base field-parsers on upstream main, dropping epoch WIP The migration was sourced from the unmerged `epoch` branch of iaux-field-parsers, which pulled in code absent from that repo's main / v1.0.0: the EpochParser (plus trace.ts/timed.ts instrumentation only it used) and a '.' list separator with its own test. Remove all of it and revert list.ts's separators to [';', ','] so the migrated parsers match upstream main. Co-Authored-By: Claude Opus 4.7 --- src/parsers/field-types/epoch.test.ts | 106 --------------------- src/parsers/field-types/epoch.ts | 127 -------------------------- src/parsers/timed.ts | 26 ------ src/parsers/trace.ts | 52 ----------- 4 files changed, 311 deletions(-) delete mode 100644 src/parsers/field-types/epoch.test.ts delete mode 100644 src/parsers/field-types/epoch.ts delete mode 100644 src/parsers/timed.ts delete mode 100644 src/parsers/trace.ts diff --git a/src/parsers/field-types/epoch.test.ts b/src/parsers/field-types/epoch.test.ts deleted file mode 100644 index b9566de..0000000 --- a/src/parsers/field-types/epoch.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { describe, expect, test } from 'vitest'; - -import { EpochParser } from './epoch'; - -// 1637274397 seconds = 2021-11-18T22:26:37.000Z. The parser treats epochs as -// UTC instants, so expectations are the raw millisecond values rather than -// wall-clock components (which would be timezone-dependent and fail in CI/UTC). -const EXPECTED_SECONDS_MS = 1637274397000; -const EXPECTED_SUBSECOND_MS = 1637274397123; - -describe('EpochParser', () => { - test('can parse int strings', () => { - const parser = new EpochParser(); - const response = parser.parseValue('1637274397'); - expect(response?.getTime()).toBe(EXPECTED_SECONDS_MS); - }); - - test('can parse float strings', () => { - const parser = new EpochParser(); - const response = parser.parseValue('1637274397.123'); - expect(response?.getTime()).toBe(EXPECTED_SUBSECOND_MS); - }); - - test('can parse nanoseconds', () => { - const parser = new EpochParser(); - const response = parser.parseValue('1637274397123456789'); - expect(response?.getTime()).toBe(EXPECTED_SUBSECOND_MS); - }); - - test('can parse microseconds', () => { - const parser = new EpochParser(); - const response = parser.parseValue('1637274397123456'); - expect(response?.getTime()).toBe(EXPECTED_SUBSECOND_MS); - }); - - test('can parse milliseconds', () => { - const parser = new EpochParser(); - const response = parser.parseValue('1637274397123'); - expect(response?.getTime()).toBe(EXPECTED_SUBSECOND_MS); - }); - - test('can parse seconds', () => { - const parser = new EpochParser(); - const response = parser.parseValue('1637274397'); - expect(response?.getTime()).toBe(EXPECTED_SECONDS_MS); - }); - - test('returns undefined if the number is not a number', () => { - const parser = new EpochParser(); - const response = parser.parseValue('qab'); - expect(response).toBeUndefined(); - }); - - test('can parse number values', () => { - const parser = new EpochParser(); - const response = parser.parseValue(1637274397); - expect(response?.getTime()).toBe(EXPECTED_SECONDS_MS); - }); - - test('returns undefined if a boolean is passed in', () => { - const parser = new EpochParser(); - const response = parser.parseValue(true); - expect(response).toBeUndefined(); - }); - - test('defaults to auto detecting the input unit', () => { - const parser = new EpochParser(); - const response = parser.parseValue('1637274397123456789'); - expect(response?.getTime()).toBe(EXPECTED_SUBSECOND_MS); - }); - - test('has a shared static instance that auto detects the input unit', () => { - const parser = EpochParser.shared; - const response = parser.parseValue('1637274397123456789'); - expect(response?.getTime()).toBe(EXPECTED_SUBSECOND_MS); - }); - - test('has a millisecondsParser static instance that uses milliseconds', () => { - const parser = EpochParser.millisecondsParser; - const response = parser.parseValue(1637274397123); - expect(response?.getTime()).toBe(EXPECTED_SUBSECOND_MS); - }); - - test('has a secondsParser static instance that uses seconds', () => { - const parser = EpochParser.secondsParser; - const response = parser.parseValue(1637274397); - expect(response?.getTime()).toBe(EXPECTED_SECONDS_MS); - }); - - test('can specify input unit as nanoseconds', () => { - const parser = new EpochParser('nanoseconds'); - const response = parser.parseValue('1637274397123456789'); - expect(response?.getTime()).toBe(EXPECTED_SUBSECOND_MS); - }); - - test('can specify input unit as microseconds', () => { - const parser = new EpochParser('microseconds'); - const response = parser.parseValue('1637274397123456'); - expect(response?.getTime()).toBe(EXPECTED_SUBSECOND_MS); - }); - - test('has some slow method', () => { - const parser = new EpochParser(); - parser.someSlowMethod(); - }); -}); diff --git a/src/parsers/field-types/epoch.ts b/src/parsers/field-types/epoch.ts deleted file mode 100644 index ca97a3e..0000000 --- a/src/parsers/field-types/epoch.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { - FieldParserInterface, - FieldParserRawValue, -} from '../field-parser-interface'; -import { trace2 } from '../trace'; -import { timed } from '../timed'; -import { NumberParser } from './number'; - -export type EpochInputUnit = - | 'nanoseconds' - | 'microseconds' - | 'milliseconds' - | 'seconds' - | 'auto'; - -const EpochUnitMultiplier = { - nanoseconds: 1e-6, - microseconds: 1e-3, - milliseconds: 1, - seconds: 1e3, -} as const; -type EpochUnitMultiplier = - (typeof EpochUnitMultiplier)[keyof typeof EpochUnitMultiplier]; - -/** - * Parses an epoch number or number string to a `Date` - * - * The epoch is the number of seconds since January 1, 1970, 00:00:00 UTC. - * - * Example: 1751336102 or '1751336102' will return a Date object representing: - * GMT: Tuesday, July 1, 2025 2:15:02 AM - */ -export class EpochParser implements FieldParserInterface { - /** - * A shared static instance of the EpochParser that tries to determine the input unit automatically. - * - * If you know the input unit, you should use the `millisecondsParser` or `secondsParser` - * instead for better performance. - * - * @static - * @memberof EpochParser - */ - static shared = new EpochParser(); - - /** - * A shared static instance of the EpochParser that assumes the input is in milliseconds. - * - * @static - * @memberof EpochParser - */ - static millisecondsParser = new EpochParser('milliseconds'); - - /** - * A shared static instance of the EpochParser that assumes the input is in seconds. - * - * @static - * @memberof EpochParser - */ - static secondsParser = new EpochParser('seconds'); - - private inputUnit: EpochInputUnit; - - /** - * @param inputUnit - The unit of the input epoch number. - * If 'auto', it will attempt to determine the unit based on the size of - * the number. - */ - constructor(inputUnit: EpochInputUnit = 'auto') { - this.inputUnit = inputUnit; - } - - /** @inheritdoc */ - parseValue(rawValue: FieldParserRawValue): Date | undefined { - const parser = NumberParser.shared; - const numberValue = parser.parseValue(rawValue); - if (numberValue) { - const multiplier = this.multipler(numberValue); - const millisecondsValue = numberValue * multiplier; - return new Date(millisecondsValue); - } - } - - @timed() - @trace2 - someSlowMethod() { - for (let i = 0; i < 1e6; i++) { - // EpochParser.secondsParser.parseValue(1637274397); // Example usage of the parser - } - } - - /** - * The `new Date(NUMBER)` constructor assumes NUMBER is in milliseconds, - * so we need to determine what the input number represents - * (seconds, milliseconds, microseconds, or nanoseconds) - * and return the appropriate multiplier to convert it to milliseconds. - */ - private multipler(value: number): number { - switch (this.inputUnit) { - case 'auto': - return this.detectMultiplier(value); - case 'nanoseconds': - return EpochUnitMultiplier.nanoseconds; - case 'microseconds': - return EpochUnitMultiplier.microseconds; - case 'milliseconds': - return EpochUnitMultiplier.milliseconds; - case 'seconds': - return EpochUnitMultiplier.seconds; - } - } - - private detectMultiplier(value: number): EpochUnitMultiplier { - if (value >= 1e16 || value <= -1e16) { - return EpochUnitMultiplier.nanoseconds; - } - - if (value >= 1e14 || value <= -1e14) { - return EpochUnitMultiplier.microseconds; - } - - if (value >= 1e11 || value <= -3e10) { - return EpochUnitMultiplier.milliseconds; - } - - return EpochUnitMultiplier.seconds; - } -} diff --git a/src/parsers/timed.ts b/src/parsers/timed.ts deleted file mode 100644 index 0b823f5..0000000 --- a/src/parsers/timed.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -export function timed(): ( - target: any, - propertyKey: string, - descriptor: PropertyDescriptor, -) => PropertyDescriptor | void { - return function actualDecorator( - target: any, - propertyKey: string, - descriptor: PropertyDescriptor, - ): PropertyDescriptor | void { - const originalMethod = descriptor.value; - descriptor.value = function (...args: any[]) { - const start = Date.now(); - const result = originalMethod.apply(this, args); - const end = Date.now(); - const duration = end - start; - console.debug('Method', propertyKey, `executed in ${duration}ms`, { - target, - args, - }); - return result; - }; - return descriptor; - }; -} diff --git a/src/parsers/trace.ts b/src/parsers/trace.ts deleted file mode 100644 index d995228..0000000 --- a/src/parsers/trace.ts +++ /dev/null @@ -1,52 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -export function trace( - linePrefix: string = 'LOG:', -): ( - target: any, - propertyKey: string, - descriptor: PropertyDescriptor, -) => PropertyDescriptor | void { - return function actualDecorator( - target: any, - propertyKey: string, - descriptor: PropertyDescriptor, - ): PropertyDescriptor | void { - const originalMethod = descriptor.value; - descriptor.value = function (...args: any[]) { - console.debug(linePrefix, 'Entering method', target, propertyKey, args); - const result = originalMethod.apply(this, args); - console.debug( - linePrefix, - 'Exiting method', - target, - propertyKey, - args, - JSON.stringify(result), - ); - return result; - }; - return descriptor; - }; -} - -export function trace2( - target: any, - propertyKey: string, - descriptor: PropertyDescriptor, -): PropertyDescriptor | void { - const originalMethod = descriptor.value; - descriptor.value = function (...args: any[]) { - console.debug('BEEP', 'Entering method', target, propertyKey, args); - const result = originalMethod.apply(this, args); - console.debug( - 'BEEP', - 'Exiting method', - target, - propertyKey, - args, - JSON.stringify(result), - ); - return result; - }; - return descriptor; -} From 72056ff281699e22c0f9ab8a771f3b57ac3a5536 Mon Sep 17 00:00:00 2001 From: Jason Buckner Date: Mon, 1 Jun 2026 12:39:03 -0700 Subject: [PATCH 4/8] WEBDEV-8507: Add interactive field-parsers demo story Add field-parsers-story.ts, a Lit playground that runs a raw value through any field-type parser and shows the parsed result and its runtime type. Extend the demo's story glob to include src/parsers so it's picked up. Co-Authored-By: Claude Opus 4.7 --- demo/app-root.ts | 87 ++++++++------ src/parsers/field-parsers-story.ts | 179 +++++++++++++++++++++++++++++ 2 files changed, 233 insertions(+), 33 deletions(-) create mode 100644 src/parsers/field-parsers-story.ts diff --git a/demo/app-root.ts b/demo/app-root.ts index 89f9ab1..1422c14 100644 --- a/demo/app-root.ts +++ b/demo/app-root.ts @@ -5,12 +5,16 @@ import { customElement } from 'lit/decorators.js'; import { unsafeHTML } from 'lit/directives/unsafe-html.js'; const storyModules = import.meta.glob( - ['../src/elements/**/*-story.ts', '../src/labs/**/*-story.ts'], - { eager: true } + [ + '../src/elements/**/*-story.ts', + '../src/labs/**/*-story.ts', + '../src/parsers/**/*-story.ts', + ], + { eager: true }, ); const storyEntries = Object.keys(storyModules) - .map(path => { + .map((path) => { const labs = path.includes('/src/labs/'); const parts = path.split('/'); const filename = parts[parts.length - 1]; // e.g. "ia-button-story.ts" @@ -19,13 +23,15 @@ const storyEntries = Object.keys(storyModules) }) .sort((a, b) => a.tag.localeCompare(b.tag)); -const productionEntries = storyEntries.filter(e => !e.labs); -const labsEntries = storyEntries.filter(e => e.labs); +const productionEntries = storyEntries.filter((e) => !e.labs); +const labsEntries = storyEntries.filter((e) => e.labs); const ALL_ENTRIES = [...productionEntries, ...labsEntries]; @customElement('app-root') export class AppRoot extends LitElement { - createRenderRoot() { return this; } + createRenderRoot() { + return this; + } private _observer?: IntersectionObserver; private _abortController = new AbortController(); @@ -34,33 +40,42 @@ export class AppRoot extends LitElement { return html`

Internet Archive Elements

Production-Ready Elements

- ${productionEntries.map(e => html` -
- ${unsafeHTML(`<${e.storyTag}>`)} -
- `)} + ${productionEntries.map( + (e) => html` +
+ ${unsafeHTML(`<${e.storyTag}>`)} +
+ `, + )}

Labs Elements

- ${labsEntries.map(e => html` -
- ${unsafeHTML(`<${e.storyTag}>`)} -
- `)} + ${labsEntries.map( + (e) => html` +
+ ${unsafeHTML(`<${e.storyTag}>`)} +
+ `, + )}
`; } firstUpdated() { - const allIds = ALL_ENTRIES.map(e => e.id); + const allIds = ALL_ENTRIES.map((e) => e.id); const links = Object.fromEntries( - allIds.map(id => [id, this.querySelector(`#ia-sidebar a[href="#${id}"]`)]) + allIds.map((id) => [ + id, + this.querySelector(`#ia-sidebar a[href="#${id}"]`), + ]), ); const visible = new Set(); @@ -68,31 +83,37 @@ export class AppRoot extends LitElement { // Only anchors in the top 30% of the viewport count as "active". // The first (topmost) visible anchor wins. this._observer = new IntersectionObserver( - entries => { + (entries) => { for (const entry of entries) { if (entry.isIntersecting) visible.add(entry.target.id); else visible.delete(entry.target.id); } - const activeId = allIds.find(id => visible.has(id)) ?? allIds[0]; - allIds.forEach(id => links[id]?.classList.toggle('active', id === activeId)); + const activeId = allIds.find((id) => visible.has(id)) ?? allIds[0]; + allIds.forEach((id) => + links[id]?.classList.toggle('active', id === activeId), + ); }, { rootMargin: '0px 0px -70% 0px' }, ); - allIds.forEach(id => { + allIds.forEach((id) => { const el = document.getElementById(id); if (el) this._observer!.observe(el); }); - allIds.forEach(id => { - links[id]?.addEventListener('click', (e: Event) => { - e.preventDefault(); - const el = document.getElementById(id); - if (el) { - const top = el.getBoundingClientRect().top + window.scrollY; - window.scrollTo({ top: Math.max(0, top - 16), behavior: 'smooth' }); - } - }, { signal: this._abortController.signal }); + allIds.forEach((id) => { + links[id]?.addEventListener( + 'click', + (e: Event) => { + e.preventDefault(); + const el = document.getElementById(id); + if (el) { + const top = el.getBoundingClientRect().top + window.scrollY; + window.scrollTo({ top: Math.max(0, top - 16), behavior: 'smooth' }); + } + }, + { signal: this._abortController.signal }, + ); }); } diff --git a/src/parsers/field-parsers-story.ts b/src/parsers/field-parsers-story.ts new file mode 100644 index 0000000..8127637 --- /dev/null +++ b/src/parsers/field-parsers-story.ts @@ -0,0 +1,179 @@ +import { css, html, LitElement } from 'lit'; +import { customElement, state } from 'lit/decorators.js'; + +import type { FieldParserInterface } from './field-parser-interface'; +import { BooleanParser } from './field-types/boolean'; +import { ByteParser } from './field-types/byte'; +import { DateParser } from './field-types/date'; +import { DurationParser } from './field-types/duration'; +import { ListParser } from './field-types/list'; +import { MediaTypeParser } from './field-types/mediatype'; +import { NumberParser } from './field-types/number'; +import { PageProgressionParser } from './field-types/page-progression'; +import { StringParser } from './field-types/string'; + +interface ParserOption { + label: string; + parser: FieldParserInterface; + example: string; +} + +const PARSERS: ParserOption[] = [ + { label: 'number', parser: NumberParser.shared, example: '1234.5' }, + { label: 'boolean', parser: BooleanParser.shared, example: 'true' }, + { label: 'byte', parser: ByteParser.shared, example: '1572864' }, + { label: 'date', parser: DateParser.shared, example: '2021-11-18' }, + { label: 'duration', parser: DurationParser.shared, example: '1:02:03' }, + { + label: 'list (of numbers)', + parser: new ListParser(NumberParser.shared), + example: '1; 2; 3', + }, + { label: 'mediatype', parser: MediaTypeParser.shared, example: 'texts' }, + { + label: 'page-progression', + parser: PageProgressionParser.shared, + example: 'rl', + }, + { label: 'string', parser: StringParser.shared, example: 'hello' }, +]; + +/** + * Interactive demo for the field-type parsers. Pick a parser, type a raw + * value, and see the parsed output and its runtime type. + */ +@customElement('field-parsers-story') +export class FieldParsersStory extends LitElement { + @state() private selectedIndex = 0; + + @state() private rawValue = PARSERS[0].example; + + private get selected(): ParserOption { + return PARSERS[this.selectedIndex]; + } + + render() { + const result = this.selected.parser.parseValue(this.rawValue); + return html` +

Field Parsers โ€” Playground

+

+ Parse a raw metadata value with any field-type parser from + @internetarchive/elements. +

+ +
+ + + +
+ +
+ Result โ†’ + ${this.format(result)} + ${this.typeLabel(result)} +
+ `; + } + + private onParserChange(e: Event) { + this.selectedIndex = Number((e.target as HTMLSelectElement).value); + this.rawValue = this.selected.example; + } + + private onInput(e: Event) { + this.rawValue = (e.target as HTMLInputElement).value; + } + + private useExample() { + this.rawValue = this.selected.example; + } + + private format(value: unknown): string { + if (value === undefined) return 'undefined (unparseable)'; + if (value instanceof Date) return value.toISOString(); + if (Array.isArray(value)) return JSON.stringify(value); + return String(value); + } + + private typeLabel(value: unknown): string { + if (value === undefined) return 'undefined'; + if (value instanceof Date) return 'Date'; + if (Array.isArray(value)) return 'array'; + return typeof value; + } + + static styles = css` + :host { + display: block; + font-family: system-ui, sans-serif; + max-width: 40rem; + } + + .controls { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 12px; + margin-bottom: 1rem; + } + + label { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 0.85rem; + font-weight: 600; + } + + select, + input { + padding: 4px 6px; + font-size: 0.9rem; + } + + .result { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + background: #f0f0f0; + border: 1px solid #ccc; + border-radius: 4px; + } + + .result.unparseable code { + color: #a00; + } + + .arrow { + color: #666; + } + + .result code { + font-size: 1rem; + } + + .type { + margin-left: auto; + font-size: 0.75rem; + color: #666; + } + `; +} From 59dac114d75001dd1004b8d3fb06b32abfcac066 Mon Sep 17 00:00:00 2001 From: Jason Buckner Date: Mon, 1 Jun 2026 12:40:06 -0700 Subject: [PATCH 5/8] WEBDEV-8507: Drop '.' list separator and its test to match main Completes the upstream-main alignment: list.ts now uses [';', ','] (the '.' separator and its period-separated-lists test were migration-time additions not present in iaux-field-parsers main). Co-Authored-By: Claude Opus 4.7 --- src/parsers/field-types/list.test.ts | 7 ------- src/parsers/field-types/list.ts | 8 ++++---- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/src/parsers/field-types/list.test.ts b/src/parsers/field-types/list.test.ts index 6348224..01fb0ef 100644 --- a/src/parsers/field-types/list.test.ts +++ b/src/parsers/field-types/list.test.ts @@ -82,11 +82,4 @@ describe('ListParser', () => { const response = parser.parseValue('10,000 Maniacs; Boop, Beep, Boop'); expect(response).toEqual(['10,000 Maniacs', 'Boop, Beep, Boop']); }); - - test('can parse period-separated lists', () => { - const stringParser = new StringParser(); - const parser = new ListParser(stringParser); - const response = parser.parseValue('Beep. Boop. Snap'); - expect(response).toEqual(['Beep', 'Boop', 'Snap']); - }); }); diff --git a/src/parsers/field-types/list.ts b/src/parsers/field-types/list.ts index 62d87f5..95950a1 100644 --- a/src/parsers/field-types/list.ts +++ b/src/parsers/field-types/list.ts @@ -6,7 +6,7 @@ import { export class ListParser implements FieldParserInterface { private parser: FieldParserInterface; - private separators = [';', ',', '.']; + private separators = [';', ',']; constructor( parser: FieldParserInterface, @@ -34,10 +34,10 @@ export class ListParser implements FieldParserInterface { } private parseListValues(rawValues: string[]): T[] { - const trimmed = rawValues.map(s => s.trim()); - const parsed = trimmed.map(rawValue => this.parser.parseValue(rawValue)); + const trimmed = rawValues.map((s) => s.trim()); + const parsed = trimmed.map((rawValue) => this.parser.parseValue(rawValue)); const result: T[] = []; - parsed.forEach(p => { + parsed.forEach((p) => { if (p !== undefined) result.push(p); }); return result; From c3c26d921b24dc7c772e97fb1b4aae8f5f6d4de9 Mon Sep 17 00:00:00 2001 From: Jason Buckner Date: Mon, 1 Jun 2026 12:53:32 -0700 Subject: [PATCH 6/8] WEBDEV-8507: Render field-parsers demo via story-template Wrap the parser playground in the shared chrome for consistency with the other stories (heading, Demo, Import/Usage/Settings). Add an optional customImport prop to story-template so non-component modules (parsers/services/models) can show a correct import snippet, since the derived `/` path only fits custom elements. Co-Authored-By: Claude Opus 4.7 --- demo/story-template.ts | 5 ++ src/parsers/field-parsers-story.ts | 90 +++++++++++++++++------------- 2 files changed, 55 insertions(+), 40 deletions(-) diff --git a/demo/story-template.ts b/demo/story-template.ts index d0871cc..6c218d0 100644 --- a/demo/story-template.ts +++ b/demo/story-template.ts @@ -28,6 +28,10 @@ export class StoryTemplate extends LitElement { @property({ type: String }) customExampleUsage?: string; + /* Overrides the derived import snippet โ€” for non-component modules + * (parsers, services, models) whose import path isn't `/`. */ + @property({ type: String }) customImport?: string; + /* Optional stringified properties to always include in the example usage */ @property({ type: String }) defaultUsageProps?: string; @@ -214,6 +218,7 @@ export class StoryTemplate extends LitElement { } private get importCode(): string { + if (this.customImport) return this.customImport; if (this.elementClassName) { return `import '${this.modulePath}';\nimport { ${this.elementClassName} } from '${this.modulePath}';`; } else { diff --git a/src/parsers/field-parsers-story.ts b/src/parsers/field-parsers-story.ts index 8127637..8c54295 100644 --- a/src/parsers/field-parsers-story.ts +++ b/src/parsers/field-parsers-story.ts @@ -1,6 +1,8 @@ import { css, html, LitElement } from 'lit'; import { customElement, state } from 'lit/decorators.js'; +import '@demo/story-template'; + import type { FieldParserInterface } from './field-parser-interface'; import { BooleanParser } from './field-types/boolean'; import { ByteParser } from './field-types/byte'; @@ -38,9 +40,15 @@ const PARSERS: ParserOption[] = [ { label: 'string', parser: StringParser.shared, example: 'hello' }, ]; +const IMPORT_EXAMPLE = `import { NumberParser } from '@internetarchive/elements/parsers/field-types/number';`; + +const USAGE_EXAMPLE = `const result = NumberParser.shared.parseValue('1234.5'); +// result === 1234.5`; + /** - * Interactive demo for the field-type parsers. Pick a parser, type a raw - * value, and see the parsed output and its runtime type. + * Demo story for the field-type parsers. Renders inside the shared + * chrome with an interactive playground in the demo slot: + * pick a parser, type a raw value, and see the parsed output and runtime type. */ @customElement('field-parsers-story') export class FieldParsersStory extends LitElement { @@ -55,40 +63,44 @@ export class FieldParsersStory extends LitElement { render() { const result = this.selected.parser.parseValue(this.rawValue); return html` -

Field Parsers โ€” Playground

-

- Parse a raw metadata value with any field-type parser from - @internetarchive/elements. -

- -
- - - -
- -
- Result โ†’ - ${this.format(result)} - ${this.typeLabel(result)} -
+ +
+
+ + + +
+
+ Result โ†’ + ${this.format(result)} + ${this.typeLabel(result)} +
+
+
`; } @@ -120,10 +132,8 @@ export class FieldParsersStory extends LitElement { } static styles = css` - :host { - display: block; + .playground { font-family: system-ui, sans-serif; - max-width: 40rem; } .controls { @@ -153,7 +163,7 @@ export class FieldParsersStory extends LitElement { align-items: center; gap: 8px; padding: 10px 12px; - background: #f0f0f0; + background: #fff; border: 1px solid #ccc; border-radius: 4px; } From 78e283ea6a41975a51d3fa6f735015ab8c8ea64c Mon Sep 17 00:00:00 2001 From: Jason Buckner Date: Mon, 1 Jun 2026 13:12:09 -0700 Subject: [PATCH 7/8] WEBDEV-8507: Support a plain heading for non-component story-template demos story-template always rendered its heading as ``, which is misleading for non-component modules (e.g. parsers). Add an optional `heading` prop that renders as plain text when set, falling back to the `` form for actual custom elements. The field-parsers story now uses heading="Field Parsers". Co-Authored-By: Claude Opus 4.7 --- demo/story-template.ts | 8 +++++++- src/parsers/field-parsers-story.ts | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/demo/story-template.ts b/demo/story-template.ts index 6c218d0..d7611f5 100644 --- a/demo/story-template.ts +++ b/demo/story-template.ts @@ -32,6 +32,10 @@ export class StoryTemplate extends LitElement { * (parsers, services, models) whose import path isn't `/`. */ @property({ type: String }) customImport?: string; + /* Plain-text heading for non-component modules. When set, it's shown as-is + * instead of as an ``. */ + @property({ type: String }) heading?: string; + /* Optional stringified properties to always include in the example usage */ @property({ type: String }) defaultUsageProps?: string; @@ -66,7 +70,9 @@ export class StoryTemplate extends LitElement { return html`

- <${this.elementTag}> + ${this.heading + ? this.heading + : html`<${this.elementTag}>`} ${when( this.labs, () => diff --git a/src/parsers/field-parsers-story.ts b/src/parsers/field-parsers-story.ts index 8c54295..8ffc8f9 100644 --- a/src/parsers/field-parsers-story.ts +++ b/src/parsers/field-parsers-story.ts @@ -64,7 +64,7 @@ export class FieldParsersStory extends LitElement { const result = this.selected.parser.parseValue(this.rawValue); return html` From 2fd984d0d3021068cf737f982935698c36a4b754 Mon Sep 17 00:00:00 2001 From: Jason Buckner Date: Mon, 1 Jun 2026 13:17:08 -0700 Subject: [PATCH 8/8] WEBDEV-8507: Don't show non-component demo stories as in the nav The sidebar wrapped every story entry in angle brackets, so the parsers playground showed as ``. Flag entries from src/elements or src/labs as components and only bracket those; plain modules (parsers) show their name as-is. Co-Authored-By: Claude Opus 4.7 --- demo/app-root.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/demo/app-root.ts b/demo/app-root.ts index 1422c14..a27ae5f 100644 --- a/demo/app-root.ts +++ b/demo/app-root.ts @@ -19,7 +19,17 @@ const storyEntries = Object.keys(storyModules) const parts = path.split('/'); const filename = parts[parts.length - 1]; // e.g. "ia-button-story.ts" const tag = filename.replace(/-story\.ts$/, ''); - return { tag, storyTag: `${tag}-story`, id: `elem-${tag}`, labs }; + // Stories under src/elements or src/labs are custom elements; others + // (e.g. parsers) are plain modules and shouldn't be shown as ``. + const component = + path.includes('/src/elements/') || path.includes('/src/labs/'); + return { + tag, + storyTag: `${tag}-story`, + id: `elem-${tag}`, + labs, + component, + }; }) .sort((a, b) => a.tag.localeCompare(b.tag)); @@ -41,7 +51,8 @@ export class AppRoot extends LitElement {