diff --git a/src/date.ts b/src/date.ts index 0636baa0b..34f91694d 100644 --- a/src/date.ts +++ b/src/date.ts @@ -1,5 +1,4 @@ -// @ts-ignore -import isoParse from './util/isodate'; +import { parseIsoDate } from './util/parseIsoDate'; import { date as locale } from './locale'; import Ref from './Reference'; import type { AnyObject, DefaultThunk, Message } from './types'; @@ -51,7 +50,7 @@ export default class DateSchema< if (!ctx.spec.coerce || ctx.isType(value) || value === null) return value; - value = isoParse(value); + value = parseIsoDate(value); // 0 is a valid timestamp equivalent to 1970-01-01T00:00:00Z(unix epoch) or before. return !isNaN(value) ? new Date(value) : DateSchema.INVALID_DATE; diff --git a/src/util/isodate.js b/src/util/isodate.js deleted file mode 100644 index 8590bb915..000000000 --- a/src/util/isodate.js +++ /dev/null @@ -1,64 +0,0 @@ -/* eslint-disable */ -/** - * - * Date.parse with progressive enhancement for ISO 8601 - * NON-CONFORMANT EDITION. - * © 2011 Colin Snover - * Released under MIT license. - */ - -// 1 YYYY 2 MM 3 DD 4 HH 5 mm 6 ss 7 msec 8 Z 9 ± 10 tzHH 11 tzmm -var isoReg = /^(\d{4}|[+\-]\d{6})(?:-?(\d{2})(?:-?(\d{2}))?)?(?:[ T]?(\d{2}):?(\d{2})(?::?(\d{2})(?:[,\.](\d{1,}))?)?(?:(Z)|([+\-])(\d{2})(?::?(\d{2}))?)?)?$/; - -export default function parseIsoDate(date) { - var numericKeys = [1, 4, 5, 6, 7, 10, 11], - minutesOffset = 0, - timestamp, - struct; - - if ((struct = isoReg.exec(date))) { - // avoid NaN timestamps caused by “undefined” values being passed to Date.UTC - for (var i = 0, k; (k = numericKeys[i]); ++i) struct[k] = +struct[k] || 0; - - // allow undefined days and months - struct[2] = (+struct[2] || 1) - 1; - struct[3] = +struct[3] || 1; - - // allow arbitrary sub-second precision beyond milliseconds - struct[7] = struct[7] ? String(struct[7]).substr(0, 3) : 0; - - // timestamps without timezone identifiers should be considered local time - if ( - (struct[8] === undefined || struct[8] === '') && - (struct[9] === undefined || struct[9] === '') - ) - timestamp = +new Date( - struct[1], - struct[2], - struct[3], - struct[4], - struct[5], - struct[6], - struct[7], - ); - else { - if (struct[8] !== 'Z' && struct[9] !== undefined) { - minutesOffset = struct[10] * 60 + struct[11]; - - if (struct[9] === '+') minutesOffset = 0 - minutesOffset; - } - - timestamp = Date.UTC( - struct[1], - struct[2], - struct[3], - struct[4], - struct[5] + minutesOffset, - struct[6], - struct[7], - ); - } - } else timestamp = Date.parse ? Date.parse(date) : NaN; - - return timestamp; -} diff --git a/src/util/parseIsoDate.ts b/src/util/parseIsoDate.ts new file mode 100644 index 000000000..d53c5cc11 --- /dev/null +++ b/src/util/parseIsoDate.ts @@ -0,0 +1,68 @@ +/** + * This file is a modified version of the file from the following repository: + * Date.parse with progressive enhancement for ISO 8601 + * NON-CONFORMANT EDITION. + * © 2011 Colin Snover + * Released under MIT license. + */ + +// prettier-ignore +// 1 YYYY 2 MM 3 DD 4 HH 5 mm 6 ss 7 msec 8 Z 9 ± 10 tzHH 11 tzmm +const isoReg = /^(\d{4}|[+-]\d{6})(?:-?(\d{2})(?:-?(\d{2}))?)?(?:[ T]?(\d{2}):?(\d{2})(?::?(\d{2})(?:[,.](\d{1,}))?)?(?:(Z)|([+-])(\d{2})(?::?(\d{2}))?)?)?$/; + +function toNumber(str: string, defaultValue = 0) { + return Number(str) || defaultValue; +} + +export function parseIsoDate(date: string): number { + const regexResult = isoReg.exec(date); + if (!regexResult) return Date.parse ? Date.parse(date) : Number.NaN; + + // use of toNumber() avoids NaN timestamps caused by “undefined” + // values being passed to Date constructor + const struct = { + year: toNumber(regexResult[1]), + month: toNumber(regexResult[2], 1) - 1, + day: toNumber(regexResult[3], 1), + hour: toNumber(regexResult[4]), + minute: toNumber(regexResult[5]), + second: toNumber(regexResult[6]), + millisecond: regexResult[7] + ? // allow arbitrary sub-second precision beyond milliseconds + toNumber(regexResult[7].substring(0, 3)) + : 0, + z: regexResult[8] || undefined, + plusMinus: regexResult[9] || undefined, + hourOffset: toNumber(regexResult[10]), + minuteOffset: toNumber(regexResult[11]), + }; + + // timestamps without timezone identifiers should be considered local time + if (struct.z === undefined && struct.plusMinus === undefined) { + return new Date( + struct.year, + struct.month, + struct.day, + struct.hour, + struct.minute, + struct.second, + struct.millisecond, + ).valueOf(); + } + + let totalMinutesOffset = 0; + if (struct.z !== 'Z' && struct.plusMinus !== undefined) { + totalMinutesOffset = struct.hourOffset * 60 + struct.minuteOffset; + if (struct.plusMinus === '+') totalMinutesOffset = 0 - totalMinutesOffset; + } + + return Date.UTC( + struct.year, + struct.month, + struct.day, + struct.hour, + struct.minute + totalMinutesOffset, + struct.second, + struct.millisecond, + ); +} diff --git a/test/util/parseIsoDate.ts b/test/util/parseIsoDate.ts new file mode 100644 index 000000000..c908201c9 --- /dev/null +++ b/test/util/parseIsoDate.ts @@ -0,0 +1,245 @@ +/** + * This file is a modified version of the test file from the following repository: + * Date.parse with progressive enhancement for ISO 8601 + * NON-CONFORMANT EDITION. + * © 2011 Colin Snover + * Released under MIT license. + */ + +import { parseIsoDate } from '../../src/util/parseIsoDate'; + +const sixHours = 6 * 60 * 60 * 1000; +const sixHoursThirty = sixHours + 30 * 60 * 1000; +const epochLocalTime = new Date(1970, 0, 1, 0, 0, 0, 0).valueOf(); + +describe('plain date (no time)', () => { + describe('valid dates', () => { + test('Unix epoch', () => { + const result = parseIsoDate('1970-01-01'); + expect(result).toBe(epochLocalTime); + }); + test('2001', () => { + const result = parseIsoDate('2001'); + const expected = new Date(2001, 0, 1, 0, 0, 0, 0).valueOf(); + expect(result).toBe(expected); + }); + test('2001-02', () => { + const result = parseIsoDate('2001-02'); + const expected = new Date(2001, 1, 1, 0, 0, 0, 0).valueOf(); + expect(result).toBe(expected); + }); + test('2001-02-03', () => { + const result = parseIsoDate('2001-02-03'); + const expected = new Date(2001, 1, 3, 0, 0, 0, 0).valueOf(); + expect(result).toBe(expected); + }); + test('-002001', () => { + const result = parseIsoDate('-002001'); + const expected = new Date(-2001, 0, 1, 0, 0, 0, 0).valueOf(); + expect(result).toBe(expected); + }); + test('-002001-02', () => { + const result = parseIsoDate('-002001-02'); + const expected = new Date(-2001, 1, 1, 0, 0, 0, 0).valueOf(); + expect(result).toBe(expected); + }); + test('-002001-02-03', () => { + const result = parseIsoDate('-002001-02-03'); + const expected = new Date(-2001, 1, 3, 0, 0, 0, 0).valueOf(); + expect(result).toBe(expected); + }); + test('+010000-02', () => { + const result = parseIsoDate('+010000-02'); + const expected = new Date(10000, 1, 1, 0, 0, 0, 0).valueOf(); + expect(result).toBe(expected); + }); + test('+010000-02-03', () => { + const result = parseIsoDate('+010000-02-03'); + const expected = new Date(10000, 1, 3, 0, 0, 0, 0).valueOf(); + expect(result).toBe(expected); + }); + test('-010000-02', () => { + const result = parseIsoDate('-010000-02'); + const expected = new Date(-10000, 1, 1, 0, 0, 0, 0).valueOf(); + expect(result).toBe(expected); + }); + test('-010000-02-03', () => { + const result = parseIsoDate('-010000-02-03'); + const expected = new Date(-10000, 1, 3, 0, 0, 0, 0).valueOf(); + expect(result).toBe(expected); + }); + }); + + describe('invalid dates', () => { + test('invalid YYYY (non-digits)', () => { + expect(parseIsoDate('asdf')).toBeNaN(); + }); + test('invalid YYYY-MM-DD (non-digits)', () => { + expect(parseIsoDate('1970-as-df')).toBeNaN(); + }); + test('invalid YYYY-MM- (extra hyphen)', () => { + expect(parseIsoDate('1970-01-')).toBe(epochLocalTime); + }); + test('invalid YYYY-MM-DD (missing hyphens)', () => { + expect(parseIsoDate('19700101')).toBe(epochLocalTime); + }); + test('ambiguous YYYY-MM/YYYYYY (missing plus/minus or hyphen)', () => { + expect(parseIsoDate('197001')).toBe(epochLocalTime); + }); + }); +}); + +describe('date-time', () => { + describe('no time zone', () => { + test('2001-02-03T04:05', () => { + const result = parseIsoDate('2001-02-03T04:05'); + const expected = new Date(2001, 1, 3, 4, 5, 0, 0).valueOf(); + expect(result).toBe(expected); + }); + test('2001-02-03T04:05:06', () => { + const result = parseIsoDate('2001-02-03T04:05:06'); + const expected = new Date(2001, 1, 3, 4, 5, 6, 0).valueOf(); + expect(result).toBe(expected); + }); + test('2001-02-03T04:05:06.007', () => { + const result = parseIsoDate('2001-02-03T04:05:06.007'); + const expected = new Date(2001, 1, 3, 4, 5, 6, 7).valueOf(); + expect(result).toBe(expected); + }); + }); + + describe('Z time zone', () => { + test('2001-02-03T04:05Z', () => { + const result = parseIsoDate('2001-02-03T04:05Z'); + const expected = Date.UTC(2001, 1, 3, 4, 5, 0, 0); + expect(result).toBe(expected); + }); + test('2001-02-03T04:05:06Z', () => { + const result = parseIsoDate('2001-02-03T04:05:06Z'); + const expected = Date.UTC(2001, 1, 3, 4, 5, 6, 0); + expect(result).toBe(expected); + }); + test('2001-02-03T04:05:06.007Z', () => { + const result = parseIsoDate('2001-02-03T04:05:06.007Z'); + const expected = Date.UTC(2001, 1, 3, 4, 5, 6, 7); + expect(result).toBe(expected); + }); + }); + + describe('offset time zone', () => { + test('2001-02-03T04:05-00:00', () => { + const result = parseIsoDate('2001-02-03T04:05-00:00'); + const expected = Date.UTC(2001, 1, 3, 4, 5, 0, 0); + expect(result).toBe(expected); + }); + test('2001-02-03T04:05:06-00:00', () => { + const result = parseIsoDate('2001-02-03T04:05:06-00:00'); + const expected = Date.UTC(2001, 1, 3, 4, 5, 6, 0); + expect(result).toBe(expected); + }); + test('2001-02-03T04:05:06.007-00:00', () => { + const result = parseIsoDate('2001-02-03T04:05:06.007-00:00'); + const expected = Date.UTC(2001, 1, 3, 4, 5, 6, 7); + expect(result).toBe(expected); + }); + + test('2001-02-03T04:05+00:00', () => { + const result = parseIsoDate('2001-02-03T04:05+00:00'); + const expected = Date.UTC(2001, 1, 3, 4, 5, 0, 0); + expect(result).toBe(expected); + }); + test('2001-02-03T04:05:06+00:00', () => { + const result = parseIsoDate('2001-02-03T04:05:06+00:00'); + const expected = Date.UTC(2001, 1, 3, 4, 5, 6, 0); + expect(result).toBe(expected); + }); + test('2001-02-03T04:05:06.007+00:00', () => { + const result = parseIsoDate('2001-02-03T04:05:06.007+00:00'); + const expected = Date.UTC(2001, 1, 3, 4, 5, 6, 7); + expect(result).toBe(expected); + }); + + test('2001-02-03T04:05-06:30', () => { + const result = parseIsoDate('2001-02-03T04:05-06:30'); + const expected = Date.UTC(2001, 1, 3, 4, 5, 0, 0) + sixHoursThirty; + expect(result).toBe(expected); + }); + test('2001-02-03T04:05:06-06:30', () => { + const result = parseIsoDate('2001-02-03T04:05:06-06:30'); + const expected = Date.UTC(2001, 1, 3, 4, 5, 6, 0) + sixHoursThirty; + expect(result).toBe(expected); + }); + test('2001-02-03T04:05:06.007-06:30', () => { + const result = parseIsoDate('2001-02-03T04:05:06.007-06:30'); + const expected = Date.UTC(2001, 1, 3, 4, 5, 6, 7) + sixHoursThirty; + expect(result).toBe(expected); + }); + + test('2001-02-03T04:05+06:30', () => { + const result = parseIsoDate('2001-02-03T04:05+06:30'); + const expected = Date.UTC(2001, 1, 3, 4, 5, 0, 0) - sixHoursThirty; + expect(result).toBe(expected); + }); + test('2001-02-03T04:05:06+06:30', () => { + const result = parseIsoDate('2001-02-03T04:05:06+06:30'); + const expected = Date.UTC(2001, 1, 3, 4, 5, 6, 0) - sixHoursThirty; + expect(result).toBe(expected); + }); + test('2001-02-03T04:05:06.007+06:30', () => { + const result = parseIsoDate('2001-02-03T04:05:06.007+06:30'); + const expected = Date.UTC(2001, 1, 3, 4, 5, 6, 7) - sixHoursThirty; + expect(result).toBe(expected); + }); + }); + + describe('incomplete dates', () => { + test('2001T04:05:06.007', () => { + const result = parseIsoDate('2001T04:05:06.007'); + const expected = new Date(2001, 0, 1, 4, 5, 6, 7).valueOf(); + expect(result).toBe(expected); + }); + test('2001-02T04:05:06.007', () => { + const result = parseIsoDate('2001-02T04:05:06.007'); + const expected = new Date(2001, 1, 1, 4, 5, 6, 7).valueOf(); + expect(result).toBe(expected); + }); + + test('-010000T04:05', () => { + const result = parseIsoDate('-010000T04:05'); + const expected = new Date(-10000, 0, 1, 4, 5, 0, 0).valueOf(); + expect(result).toBe(expected); + }); + test('-010000-02T04:05', () => { + const result = parseIsoDate('-010000-02T04:05'); + const expected = new Date(-10000, 1, 1, 4, 5, 0, 0).valueOf(); + expect(result).toBe(expected); + }); + test('-010000-02-03T04:05', () => { + const result = parseIsoDate('-010000-02-03T04:05'); + const expected = new Date(-10000, 1, 3, 4, 5, 0, 0).valueOf(); + expect(result).toBe(expected); + }); + }); + + describe('invalid date-times', () => { + test('missing T', () => { + expect(parseIsoDate('1970-01-01 00:00:00')).toBe(epochLocalTime); + }); + test('too many characters in millisecond part', () => { + expect(parseIsoDate('1970-01-01T00:00:00.000000')).toBe(epochLocalTime); + }); + test('comma instead of dot', () => { + expect(parseIsoDate('1970-01-01T00:00:00,000')).toBe(epochLocalTime); + }); + test('missing colon in timezone part', () => { + const subject = '1970-01-01T00:00:00+0630'; + expect(parseIsoDate(subject)).toBe(Date.parse(subject)); + }); + test('missing colon in time part', () => { + expect(parseIsoDate('1970-01-01T0000')).toBe(epochLocalTime); + }); + test('msec with missing seconds', () => { + expect(parseIsoDate('1970-01-01T00:00.000')).toBeNaN(); + }); + }); +});