From cb2b8ee5ebfac5125debcbafa02584a0d6443033 Mon Sep 17 00:00:00 2001 From: Olaf Tomalka Date: Mon, 14 Oct 2024 18:54:20 +0200 Subject: [PATCH 1/8] Added support for parsing iso-8601 dates --- package.json | 1 + src/iso8601-date.test.ts | 15 +++ src/iso8601-date.ts | 257 +++++++++++++++++++++++++++++++++++++++ yarn.lock | 8 ++ 4 files changed, 281 insertions(+) create mode 100644 src/iso8601-date.test.ts create mode 100644 src/iso8601-date.ts diff --git a/package.json b/package.json index c49083e2a..87a2f6203 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "jest-worker@^28.1.3": "patch:jest-worker@npm%3A28.1.3#./.yarn/patches/jest-worker-npm-28.1.3-5d0ff9006c.patch" }, "dependencies": { + "@date-fns/tz": "^1.1.2", "@ethereumjs/tx": "^4.2.0", "@metamask/superstruct": "^3.1.0", "@noble/hashes": "^1.3.1", diff --git a/src/iso8601-date.test.ts b/src/iso8601-date.test.ts new file mode 100644 index 000000000..3e683a2e5 --- /dev/null +++ b/src/iso8601-date.test.ts @@ -0,0 +1,15 @@ +import { parseDateTime } from './iso8601-date'; + +describe('parseDateTime', () => { + it.each([ + ['2020', new Date(2020, 0)], + ['2021-02', new Date(2021, 1)], + ['2022-10', new Date(2022, 9)], + ['2023-11-02', new Date(2023, 10, 1)], + ])('parses %s local-time correctly', (testIso, expectedDate) => { + const result = parseDateTime(testIso); + + expect(result).toBeInstanceOf(Date); + expect(result.toISOString()).toStrictEqual(expectedDate.toISOString()); + }); +}); diff --git a/src/iso8601-date.ts b/src/iso8601-date.ts new file mode 100644 index 000000000..f48b09b61 --- /dev/null +++ b/src/iso8601-date.ts @@ -0,0 +1,257 @@ +import { TZDate } from '@date-fns/tz'; + +import { assert } from './assert'; + +enum ParseDateState { + YEAR = 1, + MONTH = 2, + DATE = 3, + HOUR = 4, + MINUTE = 5, + SECOND = 6, + Z = 7, + ZHOUR = 8, + ZMINUTE = 9, + END = 0, +} +const END = Symbol('END'); + +const RE_YEAR = /[0-9]{4}/u; +const RE_MONTH = /(?:0[1-9])|(?:1[0-2])/u; +const RE_DATE = /(?:[1-9])|(?:[1-2][0-9])|(?:3[0-1])/u; +const RE_HOUR = /(?:[0-1][0-9])|(?:2[0-3])/u; +const RE_MINUTE_SECOND = /[0-5][0-9]/u; + +export const InvalidIso8601Date = new Error('Invalid ISO-8601 date'); + +/** + * Parses ISO-8601 date time string. + * + * @throws {InvalidIso8601Date} Is the input value is not correct. + * @param value - An ISO-8601 formatted string. + * @returns A date if value is in local-time, a TZDate if timezone data provided. + */ +export function parseDateTime(value: string): Date | TZDate { + let at = 0; + let hasSeparators: boolean | null = null; + let state: ParseDateState = ParseDateState.YEAR; + + const consume = () => { + if (at >= value.length) { + throw InvalidIso8601Date; + } + const char = value[at] as string; + at += 1; + return char; + }; + const peek = () => { + if (at >= value.length) { + return END; + } + return value[at] as string; + }; + const skip = (count = 1) => { + if (at + count >= value.length) { + throw InvalidIso8601Date; + } + at += count; + }; + const consumeSeparator = (sep: '-' | ':') => { + const next = peek(); + if (next === END) { + throw InvalidIso8601Date; + } + if (next === '-' || next === ':') { + if (hasSeparators === false || next !== sep) { + throw InvalidIso8601Date; + } + hasSeparators = true; + skip(); + } else { + if (hasSeparators === true) { + throw InvalidIso8601Date; + } + hasSeparators = false; + } + }; + + /* eslint-disable id-length */ + let Y; // year + let M; // month + let D; // date + let H; // hours + let m; // minutes + let S; // seconds + let Z = null; // null, "Z", "+", "-" + let OH; // offset hours + let Om; // offset minutes + /* eslint-enable id-length */ + + while (state !== ParseDateState.END) { + switch (state) { + case ParseDateState.YEAR: + Y = ''; + for (let i = 0; i < 4; i++) { + Y += consume(); + } + if (!RE_YEAR.test(Y)) { + throw InvalidIso8601Date; + } + if (peek() === END) { + state = ParseDateState.END; + } else { + consumeSeparator('-'); + state = ParseDateState.MONTH; + } + break; + case ParseDateState.MONTH: + M = consume() + consume(); + if (!RE_MONTH.test(M)) { + throw InvalidIso8601Date; + } + + // YYYYMM is not a valid ISO-8601 + // it requires a separator: YYYY-MM + if (hasSeparators === false && (peek() === END || peek() === 'T')) { + throw InvalidIso8601Date; + } else if (peek() === END) { + state = ParseDateState.END; + } else if (peek() === 'T') { + state = ParseDateState.HOUR; + } else { + consumeSeparator('-'); + state = ParseDateState.DATE; + } + break; + case ParseDateState.DATE: + D = consume() + consume(); + if (!RE_DATE.test(D)) { + throw InvalidIso8601Date; + } + + if (peek() === END) { + state = ParseDateState.END; + } else { + state = ParseDateState.HOUR; + } + break; + case ParseDateState.HOUR: + if (consume() !== 'T') { + throw InvalidIso8601Date; + } + + H = consume() + consume(); + if (!RE_HOUR.test(H)) { + throw InvalidIso8601Date; + } + + if (peek() === END) { + state = ParseDateState.END; + } else if (['Z', '-', '+'].includes(peek() as string)) { + state = ParseDateState.Z; + } else { + consumeSeparator(':'); + state = ParseDateState.MINUTE; + } + break; + case ParseDateState.MINUTE: + m = consume() + consume(); + if (!RE_MINUTE_SECOND.test(m)) { + throw InvalidIso8601Date; + } + + if (peek() === END) { + state = ParseDateState.END; + } else if (['Z', '-', '+'].includes(peek() as string)) { + state = ParseDateState.Z; + } else { + consumeSeparator(':'); + state = ParseDateState.SECOND; + } + break; + case ParseDateState.SECOND: + S = consume() + consume(); + if (!RE_MINUTE_SECOND.test(S)) { + throw InvalidIso8601Date; + } + + if (peek() === END) { + state = ParseDateState.END; + } else { + state = ParseDateState.Z; + } + break; + case ParseDateState.Z: + Z = consume(); + + if (Z === 'Z') { + state = ParseDateState.END; + } else if (['-', '+'].includes(Z)) { + state = ParseDateState.ZHOUR; + } else { + throw InvalidIso8601Date; + } + break; + case ParseDateState.ZHOUR: + OH = consume() + consume(); + if (!RE_MINUTE_SECOND.test(OH)) { + throw InvalidIso8601Date; + } + + if (peek() === END) { + state = ParseDateState.END; + } else { + consumeSeparator(':'); + state = ParseDateState.ZMINUTE; + } + break; + case ParseDateState.ZMINUTE: + Om = consume() + consume(); + if (!RE_MINUTE_SECOND.test(Om)) { + throw InvalidIso8601Date; + } + + if (peek() === END) { + state = ParseDateState.END; + } else { + // Garbage at the end + throw InvalidIso8601Date; + } + break; + default: + assert('Invalid ISO-8601 parser state'); + } + } + + assert(Y !== undefined); + M = M ?? '01'; + D = D ?? '01'; + H = H ?? '00'; + m = m ?? '00'; + S = S ?? '00'; + OH = OH ?? '00'; + Om = Om ?? '00'; + + if (Z !== null) { + if (Z === 'Z') { + Z = '+'; + } + return new TZDate( + parseInt(Y, 10), + parseInt(M, 10), + parseInt(D, 10), + parseInt(H, 10), + parseInt(m, 10), + parseInt(S, 10), + `${Z}${OH}:${Om}`, + ); + } + return new Date( + parseInt(Y, 10), + parseInt(M, 10), + parseInt(D, 10), + parseInt(H, 10), + parseInt(m, 10), + parseInt(S, 10), + ); +} diff --git a/yarn.lock b/yarn.lock index 591cd2d1d..41718498c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -426,6 +426,13 @@ __metadata: languageName: node linkType: hard +"@date-fns/tz@npm:^1.1.2": + version: 1.1.2 + resolution: "@date-fns/tz@npm:1.1.2" + checksum: 4b2b3d3f062e456c51d5dec5332266c27d9a136352f75fdfd5769be25bcd02ababd9b5e809ae3f9c5e8c3fad1831d11a79a03c87b0f566a48bf9fec084df7665 + languageName: node + linkType: hard + "@es-joy/jsdoccomment@npm:~0.36.1": version: 0.36.1 resolution: "@es-joy/jsdoccomment@npm:0.36.1" @@ -1065,6 +1072,7 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/utils@workspace:." dependencies: + "@date-fns/tz": ^1.1.2 "@ethereumjs/tx": ^4.2.0 "@lavamoat/allow-scripts": ^3.0.4 "@lavamoat/preinstall-always-fail": ^1.0.0 From c4887e43be74b6ba56d38d690100411dcb7ba46b Mon Sep 17 00:00:00 2001 From: Olaf Tomalka Date: Mon, 14 Oct 2024 19:36:56 +0200 Subject: [PATCH 2/8] Added 100% coverage for iso-8601 --- src/iso8601-date.test.ts | 86 +++++++++++++++++++++++++++++++++++++++- src/iso8601-date.ts | 21 +++++----- 2 files changed, 95 insertions(+), 12 deletions(-) diff --git a/src/iso8601-date.test.ts b/src/iso8601-date.test.ts index 3e683a2e5..3a173309b 100644 --- a/src/iso8601-date.test.ts +++ b/src/iso8601-date.test.ts @@ -1,15 +1,97 @@ -import { parseDateTime } from './iso8601-date'; +import { TZDate } from '@date-fns/tz'; +import assert from 'assert'; + +import { InvalidIso8601Date, parseDateTime } from './iso8601-date'; describe('parseDateTime', () => { it.each([ ['2020', new Date(2020, 0)], ['2021-02', new Date(2021, 1)], ['2022-10', new Date(2022, 9)], - ['2023-11-02', new Date(2023, 10, 1)], + ['2023-11-02', new Date(2023, 10, 2)], + ['2024-12T01', new Date(2024, 11, 1, 1)], + ['2025-01T02:01', new Date(2025, 0, 1, 2, 1)], + ['2026-02-02T03', new Date(2026, 1, 2, 3)], + ['2027-03-03T04:02', new Date(2027, 2, 3, 4, 2)], + ['2027-03-03T04:02:01', new Date(2027, 2, 3, 4, 2, 1)], + ['20280404', new Date(2028, 3, 4)], + ['20290505T05', new Date(2029, 4, 5, 5)], + ['20300606T0603', new Date(2030, 5, 6, 6, 3)], + ['20310707T070402', new Date(2031, 6, 7, 7, 4, 2)], ])('parses %s local-time correctly', (testIso, expectedDate) => { const result = parseDateTime(testIso); expect(result).toBeInstanceOf(Date); expect(result.toISOString()).toStrictEqual(expectedDate.toISOString()); }); + + it.each([ + ['2023-04-04T04Z', new TZDate(2023, 3, 4, 4, '+00:00')], + ['2023-04-04T04-01', new TZDate(2023, 3, 4, 4, '-01:00')], + ['2023-04-04T04+02', new TZDate(2023, 3, 4, 4, '+02:00')], + ['2023-04-04T04-01:01', new TZDate(2023, 3, 4, 4, '-01:01')], + ['2023-04-04T04+02:02', new TZDate(2023, 3, 4, 4, '+02:02')], + + ['2023-04-04T04:04Z', new TZDate(2023, 3, 4, 4, 4, '+00:00')], + ['2023-04-04T04:04-01', new TZDate(2023, 3, 4, 4, 4, '-01:00')], + ['2023-04-04T04:04+02', new TZDate(2023, 3, 4, 4, 4, '+02:00')], + ['2023-04-04T04:04-01:01', new TZDate(2023, 3, 4, 4, 4, '-01:01')], + ['2023-04-04T04:04+02:02', new TZDate(2023, 3, 4, 4, 4, '+02:02')], + + ['2023-04-04T04:04:04Z', new TZDate(2023, 3, 4, 4, 4, 4, '+00:00')], + ['2023-04-04T04:04:04-01', new TZDate(2023, 3, 4, 4, 4, 4, '-01:00')], + ['2023-04-04T04:04:04+02', new TZDate(2023, 3, 4, 4, 4, 4, '+02:00')], + ['2023-04-04T04:04:04-01:01', new TZDate(2023, 3, 4, 4, 4, 4, '-01:01')], + ['2023-04-04T04:04:04+02:02', new TZDate(2023, 3, 4, 4, 4, 4, '+02:02')], + ])('parses %s with time-zone correctly', (testIso, expectedDate) => { + const result = parseDateTime(testIso); + + assert(result instanceof TZDate); + expect(result.timeZone).toStrictEqual(expectedDate.timeZone); + expect(result.toISOString()).toStrictEqual(expectedDate.toISOString()); + }); + + it.each([ + '', + '0', + '00', + '000', + '0000a', + '2020-0', + '2020-00', + '2020-01a', + '202001', + '2020-01-00', + '2020-01-01a', + '2020-0101', + '202001-01', + '2020-01-01T', + '2020-01-01T0000', + '2020-01-01T00:00a', + '2020-01-01T00:0000', + '00:00', + '2020:01', + '2020-01:01', + '2020-01-01T00:00A', + '2020-01-01T00:00Za', + '2020-01-01T00:00+a', + '2020-01-01T00:00-a', + '2020-01-01T00:00+0', + '2020-01-01T00:00-0', + '2020-01-01T00:00+00a', + '2020-01-01T00:00+00:0', + '2020-01-01T00:00-00:0', + '2020-01-01T00:00+00:00a', + '2020-01-01T00:00-00:00a', + '2020-', + '2020-01-01T24', + '2020-01-01T23:60', + '2020-01-01T23:59:60', + '2020-01-01T23:59:59-13', + '2020-01-01T23:59:59+13', + '2020-01-01T23:59:59-11:60', + '2020-01-01T23:59:59a', + ])('throws on invalid datetiime "%s"', (testIso) => { + expect(() => parseDateTime(testIso)).toThrow(InvalidIso8601Date); + }); }); diff --git a/src/iso8601-date.ts b/src/iso8601-date.ts index f48b09b61..2fe77ee73 100644 --- a/src/iso8601-date.ts +++ b/src/iso8601-date.ts @@ -21,6 +21,7 @@ const RE_MONTH = /(?:0[1-9])|(?:1[0-2])/u; const RE_DATE = /(?:[1-9])|(?:[1-2][0-9])|(?:3[0-1])/u; const RE_HOUR = /(?:[0-1][0-9])|(?:2[0-3])/u; const RE_MINUTE_SECOND = /[0-5][0-9]/u; +const RE_Z_HOUR = /(?:0[0-9])|(?:1[0-2])/u; export const InvalidIso8601Date = new Error('Invalid ISO-8601 date'); @@ -51,16 +52,12 @@ export function parseDateTime(value: string): Date | TZDate { return value[at] as string; }; const skip = (count = 1) => { - if (at + count >= value.length) { - throw InvalidIso8601Date; - } + assert(at + count <= value.length, 'Invalid ISO-8601 parser state'); at += count; }; const consumeSeparator = (sep: '-' | ':') => { const next = peek(); - if (next === END) { - throw InvalidIso8601Date; - } + assert(next !== END, 'Invalid ISO-8601 parser state'); if (next === '-' || next === ':') { if (hasSeparators === false || next !== sep) { throw InvalidIso8601Date; @@ -185,6 +182,9 @@ export function parseDateTime(value: string): Date | TZDate { Z = consume(); if (Z === 'Z') { + if (peek() !== END) { + throw InvalidIso8601Date; + } state = ParseDateState.END; } else if (['-', '+'].includes(Z)) { state = ParseDateState.ZHOUR; @@ -194,7 +194,7 @@ export function parseDateTime(value: string): Date | TZDate { break; case ParseDateState.ZHOUR: OH = consume() + consume(); - if (!RE_MINUTE_SECOND.test(OH)) { + if (!RE_Z_HOUR.test(OH)) { throw InvalidIso8601Date; } @@ -218,8 +218,9 @@ export function parseDateTime(value: string): Date | TZDate { throw InvalidIso8601Date; } break; + /* istanbul ignore next */ default: - assert('Invalid ISO-8601 parser state'); + assert(false, 'Invalid ISO-8601 parser state'); } } @@ -238,7 +239,7 @@ export function parseDateTime(value: string): Date | TZDate { } return new TZDate( parseInt(Y, 10), - parseInt(M, 10), + parseInt(M, 10) - 1, parseInt(D, 10), parseInt(H, 10), parseInt(m, 10), @@ -248,7 +249,7 @@ export function parseDateTime(value: string): Date | TZDate { } return new Date( parseInt(Y, 10), - parseInt(M, 10), + parseInt(M, 10) - 1, parseInt(D, 10), parseInt(H, 10), parseInt(m, 10), From 3fef9e0097bf0bf607eac6e39bb8c4abb08a18ec Mon Sep 17 00:00:00 2001 From: Olaf Tomalka Date: Mon, 14 Oct 2024 19:39:20 +0200 Subject: [PATCH 3/8] Added assert string --- src/iso8601-date.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/iso8601-date.ts b/src/iso8601-date.ts index 2fe77ee73..8f232f1f1 100644 --- a/src/iso8601-date.ts +++ b/src/iso8601-date.ts @@ -224,7 +224,7 @@ export function parseDateTime(value: string): Date | TZDate { } } - assert(Y !== undefined); + assert(Y !== undefined, 'Invalid ISO-8601 parser state'); M = M ?? '01'; D = D ?? '01'; H = H ?? '00'; From 32582938548a23fed1634e828a2345c08b9018fc Mon Sep 17 00:00:00 2001 From: Olaf Tomalka Date: Mon, 14 Oct 2024 19:41:17 +0200 Subject: [PATCH 4/8] Typo --- src/iso8601-date.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/iso8601-date.test.ts b/src/iso8601-date.test.ts index 3a173309b..172a5034d 100644 --- a/src/iso8601-date.test.ts +++ b/src/iso8601-date.test.ts @@ -91,7 +91,7 @@ describe('parseDateTime', () => { '2020-01-01T23:59:59+13', '2020-01-01T23:59:59-11:60', '2020-01-01T23:59:59a', - ])('throws on invalid datetiime "%s"', (testIso) => { + ])('throws on invalid date time "%s"', (testIso) => { expect(() => parseDateTime(testIso)).toThrow(InvalidIso8601Date); }); }); From e1bdde617bc3e1cdddaf9302c51284fc4d4cb666 Mon Sep 17 00:00:00 2001 From: Olaf Tomalka Date: Mon, 14 Oct 2024 22:46:31 +0200 Subject: [PATCH 5/8] Rename and export from index --- src/index.ts | 1 + src/iso8601-date.test.ts | 8 ++++---- src/iso8601-date.ts | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/index.ts b/src/index.ts index d3f0813ce..5b1c7950f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,3 +18,4 @@ export * from './promise'; export * from './time'; export * from './transaction-types'; export * from './versions'; +export * from './iso8601-date'; diff --git a/src/iso8601-date.test.ts b/src/iso8601-date.test.ts index 172a5034d..dd63cb396 100644 --- a/src/iso8601-date.test.ts +++ b/src/iso8601-date.test.ts @@ -1,7 +1,7 @@ import { TZDate } from '@date-fns/tz'; import assert from 'assert'; -import { InvalidIso8601Date, parseDateTime } from './iso8601-date'; +import { InvalidIso8601Date, parseIso8601DateTime } from './iso8601-date'; describe('parseDateTime', () => { it.each([ @@ -19,7 +19,7 @@ describe('parseDateTime', () => { ['20300606T0603', new Date(2030, 5, 6, 6, 3)], ['20310707T070402', new Date(2031, 6, 7, 7, 4, 2)], ])('parses %s local-time correctly', (testIso, expectedDate) => { - const result = parseDateTime(testIso); + const result = parseIso8601DateTime(testIso); expect(result).toBeInstanceOf(Date); expect(result.toISOString()).toStrictEqual(expectedDate.toISOString()); @@ -44,7 +44,7 @@ describe('parseDateTime', () => { ['2023-04-04T04:04:04-01:01', new TZDate(2023, 3, 4, 4, 4, 4, '-01:01')], ['2023-04-04T04:04:04+02:02', new TZDate(2023, 3, 4, 4, 4, 4, '+02:02')], ])('parses %s with time-zone correctly', (testIso, expectedDate) => { - const result = parseDateTime(testIso); + const result = parseIso8601DateTime(testIso); assert(result instanceof TZDate); expect(result.timeZone).toStrictEqual(expectedDate.timeZone); @@ -92,6 +92,6 @@ describe('parseDateTime', () => { '2020-01-01T23:59:59-11:60', '2020-01-01T23:59:59a', ])('throws on invalid date time "%s"', (testIso) => { - expect(() => parseDateTime(testIso)).toThrow(InvalidIso8601Date); + expect(() => parseIso8601DateTime(testIso)).toThrow(InvalidIso8601Date); }); }); diff --git a/src/iso8601-date.ts b/src/iso8601-date.ts index 8f232f1f1..d031b30a9 100644 --- a/src/iso8601-date.ts +++ b/src/iso8601-date.ts @@ -32,7 +32,7 @@ export const InvalidIso8601Date = new Error('Invalid ISO-8601 date'); * @param value - An ISO-8601 formatted string. * @returns A date if value is in local-time, a TZDate if timezone data provided. */ -export function parseDateTime(value: string): Date | TZDate { +export function parseIso8601DateTime(value: string): Date | TZDate { let at = 0; let hasSeparators: boolean | null = null; let state: ParseDateState = ParseDateState.YEAR; From da164900c83bf209578adf901fccb8e3a91d2a8e Mon Sep 17 00:00:00 2001 From: Olaf Tomalka Date: Tue, 15 Oct 2024 15:06:29 +0200 Subject: [PATCH 6/8] * Updated naming conventions to match linting * Updated index tests --- src/index.test.ts | 2 + src/iso8601-date.ts | 190 ++++++++++++++++++++++---------------------- src/node.test.ts | 2 + 3 files changed, 98 insertions(+), 96 deletions(-) diff --git a/src/index.test.ts b/src/index.test.ts index 9e36ce705..525640355 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -27,6 +27,7 @@ describe('index', () => { "HexAddressStruct", "HexChecksumAddressStruct", "HexStruct", + "InvalidIso8601Date", "JsonRpcErrorStruct", "JsonRpcFailureStruct", "JsonRpcIdStruct", @@ -131,6 +132,7 @@ describe('index', () => { "object", "parseCaipAccountId", "parseCaipChainId", + "parseIso8601DateTime", "remove0x", "satisfiesVersionRange", "signedBigIntToBytes", diff --git a/src/iso8601-date.ts b/src/iso8601-date.ts index d031b30a9..cb4037982 100644 --- a/src/iso8601-date.ts +++ b/src/iso8601-date.ts @@ -3,16 +3,16 @@ import { TZDate } from '@date-fns/tz'; import { assert } from './assert'; enum ParseDateState { - YEAR = 1, - MONTH = 2, - DATE = 3, - HOUR = 4, - MINUTE = 5, - SECOND = 6, - Z = 7, - ZHOUR = 8, - ZMINUTE = 9, - END = 0, + Year = 1, + Month = 2, + CalendarDate = 3, + Hour = 4, + Minute = 5, + Second = 6, + Timezone = 7, + TimezoneHour = 8, + TimezoneMinute = 9, + End = 0, } const END = Symbol('END'); @@ -35,7 +35,7 @@ export const InvalidIso8601Date = new Error('Invalid ISO-8601 date'); export function parseIso8601DateTime(value: string): Date | TZDate { let at = 0; let hasSeparators: boolean | null = null; - let state: ParseDateState = ParseDateState.YEAR; + let state: ParseDateState = ParseDateState.Year; const consume = () => { if (at >= value.length) { @@ -72,38 +72,36 @@ export function parseIso8601DateTime(value: string): Date | TZDate { } }; - /* eslint-disable id-length */ - let Y; // year - let M; // month - let D; // date - let H; // hours - let m; // minutes - let S; // seconds - let Z = null; // null, "Z", "+", "-" - let OH; // offset hours - let Om; // offset minutes - /* eslint-enable id-length */ + let year: undefined | string; + let month: undefined | string; + let date: undefined | string; + let hours: undefined | string; + let minutes: undefined | string; + let seconds: undefined | string; + let timezone: null | string = null; // null, "Z", "+", "-" + let offsetHours: undefined | string; + let offsetMinutes: undefined | string; - while (state !== ParseDateState.END) { + while (state !== ParseDateState.End) { switch (state) { - case ParseDateState.YEAR: - Y = ''; + case ParseDateState.Year: + year = ''; for (let i = 0; i < 4; i++) { - Y += consume(); + year += consume(); } - if (!RE_YEAR.test(Y)) { + if (!RE_YEAR.test(year)) { throw InvalidIso8601Date; } if (peek() === END) { - state = ParseDateState.END; + state = ParseDateState.End; } else { consumeSeparator('-'); - state = ParseDateState.MONTH; + state = ParseDateState.Month; } break; - case ParseDateState.MONTH: - M = consume() + consume(); - if (!RE_MONTH.test(M)) { + case ParseDateState.Month: + month = consume() + consume(); + if (!RE_MONTH.test(month)) { throw InvalidIso8601Date; } @@ -112,107 +110,107 @@ export function parseIso8601DateTime(value: string): Date | TZDate { if (hasSeparators === false && (peek() === END || peek() === 'T')) { throw InvalidIso8601Date; } else if (peek() === END) { - state = ParseDateState.END; + state = ParseDateState.End; } else if (peek() === 'T') { - state = ParseDateState.HOUR; + state = ParseDateState.Hour; } else { consumeSeparator('-'); - state = ParseDateState.DATE; + state = ParseDateState.CalendarDate; } break; - case ParseDateState.DATE: - D = consume() + consume(); - if (!RE_DATE.test(D)) { + case ParseDateState.CalendarDate: + date = consume() + consume(); + if (!RE_DATE.test(date)) { throw InvalidIso8601Date; } if (peek() === END) { - state = ParseDateState.END; + state = ParseDateState.End; } else { - state = ParseDateState.HOUR; + state = ParseDateState.Hour; } break; - case ParseDateState.HOUR: + case ParseDateState.Hour: if (consume() !== 'T') { throw InvalidIso8601Date; } - H = consume() + consume(); - if (!RE_HOUR.test(H)) { + hours = consume() + consume(); + if (!RE_HOUR.test(hours)) { throw InvalidIso8601Date; } if (peek() === END) { - state = ParseDateState.END; + state = ParseDateState.End; } else if (['Z', '-', '+'].includes(peek() as string)) { - state = ParseDateState.Z; + state = ParseDateState.Timezone; } else { consumeSeparator(':'); - state = ParseDateState.MINUTE; + state = ParseDateState.Minute; } break; - case ParseDateState.MINUTE: - m = consume() + consume(); - if (!RE_MINUTE_SECOND.test(m)) { + case ParseDateState.Minute: + minutes = consume() + consume(); + if (!RE_MINUTE_SECOND.test(minutes)) { throw InvalidIso8601Date; } if (peek() === END) { - state = ParseDateState.END; + state = ParseDateState.End; } else if (['Z', '-', '+'].includes(peek() as string)) { - state = ParseDateState.Z; + state = ParseDateState.Timezone; } else { consumeSeparator(':'); - state = ParseDateState.SECOND; + state = ParseDateState.Second; } break; - case ParseDateState.SECOND: - S = consume() + consume(); - if (!RE_MINUTE_SECOND.test(S)) { + case ParseDateState.Second: + seconds = consume() + consume(); + if (!RE_MINUTE_SECOND.test(seconds)) { throw InvalidIso8601Date; } if (peek() === END) { - state = ParseDateState.END; + state = ParseDateState.End; } else { - state = ParseDateState.Z; + state = ParseDateState.Timezone; } break; - case ParseDateState.Z: - Z = consume(); + case ParseDateState.Timezone: + timezone = consume(); - if (Z === 'Z') { + if (timezone === 'Z') { if (peek() !== END) { throw InvalidIso8601Date; } - state = ParseDateState.END; - } else if (['-', '+'].includes(Z)) { - state = ParseDateState.ZHOUR; + state = ParseDateState.End; + } else if (['-', '+'].includes(timezone)) { + state = ParseDateState.TimezoneHour; } else { throw InvalidIso8601Date; } break; - case ParseDateState.ZHOUR: - OH = consume() + consume(); - if (!RE_Z_HOUR.test(OH)) { + case ParseDateState.TimezoneHour: + offsetHours = consume() + consume(); + if (!RE_Z_HOUR.test(offsetHours)) { throw InvalidIso8601Date; } if (peek() === END) { - state = ParseDateState.END; + state = ParseDateState.End; } else { consumeSeparator(':'); - state = ParseDateState.ZMINUTE; + state = ParseDateState.TimezoneMinute; } break; - case ParseDateState.ZMINUTE: - Om = consume() + consume(); - if (!RE_MINUTE_SECOND.test(Om)) { + case ParseDateState.TimezoneMinute: + offsetMinutes = consume() + consume(); + if (!RE_MINUTE_SECOND.test(offsetMinutes)) { throw InvalidIso8601Date; } if (peek() === END) { - state = ParseDateState.END; + state = ParseDateState.End; } else { // Garbage at the end throw InvalidIso8601Date; @@ -224,35 +222,35 @@ export function parseIso8601DateTime(value: string): Date | TZDate { } } - assert(Y !== undefined, 'Invalid ISO-8601 parser state'); - M = M ?? '01'; - D = D ?? '01'; - H = H ?? '00'; - m = m ?? '00'; - S = S ?? '00'; - OH = OH ?? '00'; - Om = Om ?? '00'; + assert(year !== undefined, 'Invalid ISO-8601 parser state'); + month = month ?? '01'; + date = date ?? '01'; + hours = hours ?? '00'; + minutes = minutes ?? '00'; + seconds = seconds ?? '00'; + offsetHours = offsetHours ?? '00'; + offsetMinutes = offsetMinutes ?? '00'; - if (Z !== null) { - if (Z === 'Z') { - Z = '+'; + if (timezone !== null) { + if (timezone === 'Z') { + timezone = '+'; } return new TZDate( - parseInt(Y, 10), - parseInt(M, 10) - 1, - parseInt(D, 10), - parseInt(H, 10), - parseInt(m, 10), - parseInt(S, 10), - `${Z}${OH}:${Om}`, + parseInt(year, 10), + parseInt(month, 10) - 1, + parseInt(date, 10), + parseInt(hours, 10), + parseInt(minutes, 10), + parseInt(seconds, 10), + `${timezone}${offsetHours}:${offsetMinutes}`, ); } return new Date( - parseInt(Y, 10), - parseInt(M, 10) - 1, - parseInt(D, 10), - parseInt(H, 10), - parseInt(m, 10), - parseInt(S, 10), + parseInt(year, 10), + parseInt(month, 10) - 1, + parseInt(date, 10), + parseInt(hours, 10), + parseInt(minutes, 10), + parseInt(seconds, 10), ); } diff --git a/src/node.test.ts b/src/node.test.ts index 5f7d5ed4d..e01c4813f 100644 --- a/src/node.test.ts +++ b/src/node.test.ts @@ -27,6 +27,7 @@ describe('node', () => { "HexAddressStruct", "HexChecksumAddressStruct", "HexStruct", + "InvalidIso8601Date", "JsonRpcErrorStruct", "JsonRpcFailureStruct", "JsonRpcIdStruct", @@ -136,6 +137,7 @@ describe('node', () => { "object", "parseCaipAccountId", "parseCaipChainId", + "parseIso8601DateTime", "readFile", "readJsonFile", "remove0x", From da6557cbf1d248b3034b685d1db5887ee54e2d30 Mon Sep 17 00:00:00 2001 From: Olaf Tomalka Date: Tue, 15 Oct 2024 15:40:16 +0200 Subject: [PATCH 7/8] Update tsconfig to support date-fns --- tsconfig.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tsconfig.json b/tsconfig.json index 01e09f861..678cefba1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,11 @@ "noErrorTruncation": true, "noUncheckedIndexedAccess": true, "strict": true, - "target": "es2020" + "target": "es2020", + // TODO(ritave): A temporary measure to support date-fns + // which has a problem with CommonJS support + // https://github.com/date-fns/tz/issues/21 + "skipLibCheck": true }, "exclude": ["./dist/**/*"] } From 3b2f6f579ee2f9e72562393550fc787ac65f0f9a Mon Sep 17 00:00:00 2001 From: Olaf Tomalka Date: Tue, 15 Oct 2024 15:47:47 +0200 Subject: [PATCH 8/8] Use more precise TZDate import to support tree-shaking --- src/iso8601-date.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/iso8601-date.ts b/src/iso8601-date.ts index cb4037982..f47266cbe 100644 --- a/src/iso8601-date.ts +++ b/src/iso8601-date.ts @@ -1,4 +1,4 @@ -import { TZDate } from '@date-fns/tz'; +import { TZDate } from '@date-fns/tz/date'; import { assert } from './assert';