@atproto/syntax 0.6.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @atproto/syntax
2
2
 
3
+ ## 0.6.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [#4955](https://github.com/bluesky-social/atproto/pull/4955) [`20c5cc1`](https://github.com/bluesky-social/atproto/commit/20c5cc187aab538435c669c6e19a2d2f658af5f8) Thanks [@matthieusieben](https://github.com/matthieusieben)! - Add `isDatetimeStringLenient` datetime validation utility
8
+
3
9
  ## 0.6.0
4
10
 
5
11
  ### Minor Changes
@@ -80,6 +80,12 @@ export declare function asDatetimeString<I>(input: I): I & DatetimeString;
80
80
  * @see {@link DatetimeString}
81
81
  */
82
82
  export declare function isDatetimeString<I>(input: I): input is I & DatetimeString;
83
+ /**
84
+ * Matches any ISO-ish datetime string. This is a more lenient check than
85
+ * the strict {@link isDatetimeString} guard, which only allows datetimes that
86
+ * fully conform to the AT Protocol specification (e.g. must include timezone).
87
+ */
88
+ export declare function isDatetimeStringLenient<I>(input: I): input is I & DatetimeString;
83
89
  /**
84
90
  * Returns the input if it is a valid {@link DatetimeString} format string, or
85
91
  * `undefined` if it is not.
@@ -1 +1 @@
1
- {"version":3,"file":"datetime.d.ts","sourceRoot":"","sources":["../src/datetime.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,qBAAa,oBAAqB,SAAQ,KAAK;CAAG;AAElD;;;;;;GAMG;AACH,MAAM,MAAM,iBAAiB,GAE3B,GAAG,MAAM,IAAI,MAAM,IAAI,MAAM,IAAI,MAAM,IAAI,MAAM,IAAI,MAAM,IAAI,MAAM,GAAG,CAAA;AAE1E;;;;GAIG;AACH,MAAM,WAAW,WAAY,SAAQ,IAAI;IACvC,WAAW,IAAI,iBAAiB,CAAA;CACjC;AAED;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,IAAI,GAAG,OAAO,CAAC,IAAI,IAAI,WAAW,CAKzE;AAED;;GAEG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,IAAI,GAAG,WAAW,CAGrD;AAED;;GAEG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,IAAI,GAAG,IAAI,IAAI,WAAW,CAE7D;AAED;;GAEG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,IAAI,GAAG,WAAW,GAAG,SAAS,CAEjE;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,MAAM,cAAc,GAEtB,GAAG,MAAM,IAAI,MAAM,IAAI,MAAM,IAAI,MAAM,IAAI,MAAM,IAAI,MAAM,GAAG,GAC9D,GAAG,MAAM,IAAI,MAAM,IAAI,MAAM,IAAI,MAAM,IAAI,MAAM,IAAI,MAAM,GAAG,GAAG,GAAG,GAAG,GAAG,MAAM,IAAI,MAAM,EAAE,CAAA;AAEhG;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,CAAC,EACpC,KAAK,EAAE,CAAC,GACP,OAAO,CAAC,KAAK,IAAI,CAAC,GAAG,cAAc,CAKrC;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,GAAG,CAAC,GAAG,cAAc,CAGhE;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,GAAG,KAAK,IAAI,CAAC,GAAG,cAAc,CAEzE;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,EAChC,KAAK,EAAE,CAAC,GACP,SAAS,GAAG,CAAC,CAAC,GAAG,cAAc,CAAC,CAElC;AAED;;;;GAIG;AACH,wBAAgB,qBAAqB,IAAI,cAAc,CAEtD;AAED;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,IAAI,GAAG,cAAc,CAE3D;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,iBAAiB,CA8ClE;AAED;;;;;;;;GAQG;AACH,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,MAAM,GAAG,iBAAiB,CAMxE;AAGD,OAAO,EACL,oBAAoB,IAAI,mBAAmB,EAC3C,gBAAgB,IAAI,eAAe,GACpC,CAAA"}
1
+ {"version":3,"file":"datetime.d.ts","sourceRoot":"","sources":["../src/datetime.ts"],"names":[],"mappings":"AAMA;;;;GAIG;AACH,qBAAa,oBAAqB,SAAQ,KAAK;CAAG;AAElD;;;;;;GAMG;AACH,MAAM,MAAM,iBAAiB,GAE3B,GAAG,MAAM,IAAI,MAAM,IAAI,MAAM,IAAI,MAAM,IAAI,MAAM,IAAI,MAAM,IAAI,MAAM,GAAG,CAAA;AAE1E;;;;GAIG;AACH,MAAM,WAAW,WAAY,SAAQ,IAAI;IACvC,WAAW,IAAI,iBAAiB,CAAA;CACjC;AAED;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,IAAI,GAAG,OAAO,CAAC,IAAI,IAAI,WAAW,CAKzE;AAED;;GAEG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,IAAI,GAAG,WAAW,CAGrD;AAED;;GAEG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,IAAI,GAAG,IAAI,IAAI,WAAW,CAE7D;AAED;;GAEG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,IAAI,GAAG,WAAW,GAAG,SAAS,CAEjE;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,MAAM,cAAc,GAEtB,GAAG,MAAM,IAAI,MAAM,IAAI,MAAM,IAAI,MAAM,IAAI,MAAM,IAAI,MAAM,GAAG,GAC9D,GAAG,MAAM,IAAI,MAAM,IAAI,MAAM,IAAI,MAAM,IAAI,MAAM,IAAI,MAAM,GAAG,GAAG,GAAG,GAAG,GAAG,MAAM,IAAI,MAAM,EAAE,CAAA;AAEhG;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,CAAC,EACpC,KAAK,EAAE,CAAC,GACP,OAAO,CAAC,KAAK,IAAI,CAAC,GAAG,cAAc,CAKrC;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,GAAG,CAAC,GAAG,cAAc,CAGhE;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,GAAG,KAAK,IAAI,CAAC,GAAG,cAAc,CAEzE;AAED;;;;GAIG;AACH,wBAAgB,uBAAuB,CAAC,CAAC,EACvC,KAAK,EAAE,CAAC,GACP,KAAK,IAAI,CAAC,GAAG,cAAc,CAoB7B;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,EAChC,KAAK,EAAE,CAAC,GACP,SAAS,GAAG,CAAC,CAAC,GAAG,cAAc,CAAC,CAElC;AAED;;;;GAIG;AACH,wBAAgB,qBAAqB,IAAI,cAAc,CAEtD;AAED;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,IAAI,GAAG,cAAc,CAE3D;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,iBAAiB,CA8ClE;AAED;;;;;;;;GAQG;AACH,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,MAAM,GAAG,iBAAiB,CAMxE;AAGD,OAAO,EACL,oBAAoB,IAAI,mBAAmB,EAC3C,gBAAgB,IAAI,eAAe,GACpC,CAAA"}
package/dist/datetime.js CHANGED
@@ -1,3 +1,7 @@
1
+ import * as isoDatestringValidator from 'iso-datestring-validator';
2
+ // Node ESM interop wraps "iso-datestring-validator" as { default: { ... } }
3
+ // @TODO Remove "iso-datestring-validator" dependency
4
+ const { isValidISODateString } = ((m) => m.default ?? m)(isoDatestringValidator);
1
5
  /**
2
6
  * Indicates a date or string is not a valid representation of a datetime
3
7
  * according to the atproto
@@ -65,6 +69,32 @@ export function asDatetimeString(input) {
65
69
  export function isDatetimeString(input) {
66
70
  return parseString(input).success;
67
71
  }
72
+ /**
73
+ * Matches any ISO-ish datetime string. This is a more lenient check than
74
+ * the strict {@link isDatetimeString} guard, which only allows datetimes that
75
+ * fully conform to the AT Protocol specification (e.g. must include timezone).
76
+ */
77
+ export function isDatetimeStringLenient(input) {
78
+ // @NOTE the returned type assertion is inaccurate wrt. the DatetimeString
79
+ // type definition. A more accurate solution would be to use a branded type
80
+ // instead of a template literal for the "datetime" format
81
+ if (typeof input !== 'string')
82
+ return false;
83
+ try {
84
+ if (isValidISODateString(input))
85
+ return true;
86
+ }
87
+ catch {
88
+ // isValidISODateString can throw on some inputs.
89
+ }
90
+ // @NOTE The "iso-datestring-validator" implementation is *not* compliant with
91
+ // the AT Protocol datetime specification. In particular, it rejects some
92
+ // valid AT Protocol datetimes (eg: "1985-04-12T23:20:50.1235678912345Z",
93
+ // "1985-04-12T23:20:50.123+01:45", "1985-04-12T23:20:50.1234567890Z"). For
94
+ // this reason, we run "isDatetimeString" validation if "isValidISODateString"
95
+ // does not return true.
96
+ return isDatetimeString(input);
97
+ }
68
98
  /**
69
99
  * Returns the input if it is a valid {@link DatetimeString} format string, or
70
100
  * `undefined` if it is not.
@@ -1 +1 @@
1
- {"version":3,"file":"datetime.js","sourceRoot":"","sources":["../src/datetime.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Indicates a date or string is not a valid representation of a datetime\n * according to the atproto\n * {@link https://atproto.com/specs/lexicon#datetime specification}.\n */\nexport class InvalidDatetimeError extends Error {}\n\n/**\n * A subset of {@link DatetimeString} that represent valid datetime strings with\n * the format: `YYYY-MM-DDTHH:mm:ss.sssZ`, as returned by `Date.toISOString()\n * for dates between the years 0000 and 9999.\n *\n * @see {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString}\n */\nexport type ISODatetimeString =\n // @TODO Switch to branded types for more accurate type safety.\n `${string}-${string}-${string}T${string}:${string}:${string}.${string}Z`\n\n/**\n * Represents a {@link Date} that can be safely stringified into a valid atproto\n * {@link DatetimeString} using the {@link Date.toISOString toISOString()}\n * method.\n */\nexport interface AtprotoDate extends Date {\n toISOString(): ISODatetimeString\n}\n\n/**\n * @see {@link AtprotoDate}\n */\nexport function assertAtprotoDate(date: Date): asserts date is AtprotoDate {\n const res = parseDate(date)\n if (!res.success) {\n throw new InvalidDatetimeError(res.message)\n }\n}\n\n/**\n * @see {@link AtprotoDate}\n */\nexport function asAtprotoDate(date: Date): AtprotoDate {\n assertAtprotoDate(date)\n return date\n}\n\n/**\n * @see {@link AtprotoDate}\n */\nexport function isAtprotoDate(date: Date): date is AtprotoDate {\n return parseDate(date).success\n}\n\n/**\n * @see {@link AtprotoDate}\n */\nexport function ifAtprotoDate(date: Date): AtprotoDate | undefined {\n return isAtprotoDate(date) ? date : undefined\n}\n\n/**\n * Datetime strings in atproto data structures and API calls should meet the\n * {@link https://ijmacd.github.io/rfc3339-iso8601/ intersecting} requirements\n * of the RFC 3339, ISO 8601, and WHATWG HTML datetime standards.\n *\n * @note This literal template type is not accurate enough to ensure that a\n * string is a valid atproto datetime. The {@link DatetimeString} validation\n * functions ({@link assertDatetimeString}, {@link isDatetimeString}, etc)\n * should be used to validate that a string meets the atproto datetime\n * requirements, and the {@link toDatetimeString} function should be used to\n * convert a {@link Date} object into a valid {@link DatetimeString} string.\n *\n * @example \"2024-01-15T12:30:00Z\"\n * @example \"2024-01-15T12:30:00.000Z\"\n * @example \"2024-01-15T12:30:00+00:00\"\n * @example \"2024-01-15T11:30:00-01:00\"\n * @see {@link https://atproto.com/specs/lexicon#datetime atproto Lexicon datetime format}\n * @see {@link https://www.rfc-editor.org/rfc/rfc3339 RFC 3339}\n * @see {@link https://www.iso.org/iso-8601-date-and-time-format.html ISO 8601}\n */\nexport type DatetimeString =\n // @TODO Switch to branded types for more accurate type safety?\n | `${string}-${string}-${string}T${string}:${string}:${string}Z`\n | `${string}-${string}-${string}T${string}:${string}:${string}${'+' | '-'}${string}:${string}`\n\n/**\n * Validates that a string is a valid {@link DatetimeString} format string,\n * throwing an error if it is not.\n *\n * @throws InvalidDatetimeError if the input string does not meet the atproto 'datetime' format requirements.\n * @see {@link DatetimeString}\n */\nexport function assertDatetimeString<I>(\n input: I,\n): asserts input is I & DatetimeString {\n const result = parseString(input)\n if (!result.success) {\n throw new InvalidDatetimeError(result.message)\n }\n}\n\n/**\n * Casts a string to a {@link DatetimeString} if it is a valid datetime format\n * string, throwing an error if it is not.\n *\n * @throws InvalidDatetimeError if the input string does not meet the atproto 'datetime' format requirements.\n * @see {@link DatetimeString}\n */\nexport function asDatetimeString<I>(input: I): I & DatetimeString {\n assertDatetimeString(input)\n return input\n}\n\n/**\n * Checks if a string is a valid {@link DatetimeString} format string.\n *\n * @see {@link DatetimeString}\n */\nexport function isDatetimeString<I>(input: I): input is I & DatetimeString {\n return parseString(input).success\n}\n\n/**\n * Returns the input if it is a valid {@link DatetimeString} format string, or\n * `undefined` if it is not.\n *\n * @see {@link DatetimeString}\n */\nexport function ifDatetimeString<I>(\n input: I,\n): undefined | (I & DatetimeString) {\n return isDatetimeString(input) ? input : undefined\n}\n\n/**\n * Returns the current date and time as a {@link DatetimeString}.\n *\n * @see {@link DatetimeString}\n */\nexport function currentDatetimeString(): DatetimeString {\n return toDatetimeString(new Date())\n}\n\n/**\n * Converts any {@link Date} into a {@link DatetimeString} if possible, throwing\n * an error if the date is not a valid atproto datetime.\n *\n * This is short-hand for `asAtprotoDate(date).toISOString()`.\n *\n * @throws InvalidDatetimeError if the input date is not a valid atproto datetime (eg, it is too far in the future or past, or it normalizes to a negative year).\n * @see {@link DatetimeString}\n */\nexport function toDatetimeString(date: Date): DatetimeString {\n return asAtprotoDate(date).toISOString()\n}\n\n/**\n * Takes a flexible datetime string and normalizes its representation.\n *\n * This function will work with any valid value that can be parsed as a date. It\n * *additionally* is more flexible about accepting datetimes that are missing\n * timezone information, and normalizing them to a valid atproto datetime.\n *\n * One use-case is a consistent, sortable string. Another is to work with older\n * invalid createdAt datetimes.\n *\n * @note This function might return different normalized strings for the same\n * input depending on the timezone of the machine it is run on, since it will\n * attempt to parse the input \"as is\" if it fails to parse with an explicit\n * timezone.\n *\n * @returns ISODatetimeString - a valid atproto datetime with millisecond precision (3 sub-second digits) and UTC timezone with trailing 'Z' syntax.\n * @throws InvalidDatetimeError - if the input string could not be parsed as a datetime, even with permissive parsing.\n */\nexport function normalizeDatetime(dtStr: string): ISODatetimeString {\n if (\n // Explicit timezone offset\n /[+-]\\d\\d:?\\d\\d/.test(dtStr) ||\n // 'Z' timezone designator\n /\\dZ\\b/.test(dtStr) ||\n // Timezone abbreviation (eg. \"EST\", \"PST\", \"UTC\", \"GMT\", etc), as in:\n // > Tue Mar 17 2026 16:38:44 PST (Pacific Standard Time)\n /\\b[A-Z]{3,4}\\b/.test(dtStr)\n ) {\n // Since we do have a timezone designator, we can try parsing \"as is\" and\n // should get consistent results regardless of local timezone.\n\n // @NOTE NodeJS will reject dates with an un-recognized timezone designator\n // (like \"AFT\"), even if we add a well-known timezone abbreviation like\n // \"UTC\" or \"Z\".\n const date = new Date(dtStr)\n if (isAtprotoDate(date)) {\n return date.toISOString()\n }\n } else {\n // If there is no timezone information, try parsing as UTC using two\n // different syntaxes, falling back to parsing \"as is\".\n\n const dateZ = new Date(`${dtStr}Z`)\n if (isAtprotoDate(dateZ)) {\n return dateZ.toISOString()\n }\n\n const dateUTC = new Date(`${dtStr} UTC`)\n if (isAtprotoDate(dateUTC)) {\n return dateUTC.toISOString()\n }\n\n // Despite our best efforts to parse as a consistent value, appending \"Z\" or\n // \" UTC\" did not work, so we will try parsing \"as is\", which may yield\n // different results depending on the local timezone of the machine.\n const date = new Date(dtStr)\n if (isAtprotoDate(date)) {\n return date.toISOString()\n }\n }\n\n throw new InvalidDatetimeError(\n 'datetime did not parse as any timestamp format',\n )\n}\n\n/**\n * Variant of {@link normalizeDatetime} which always returns a valid datetime\n * string.\n *\n * If a {@link InvalidDatetimeError} is encountered, returns the UNIX epoch time\n * as a UTC datetime (`1970-01-01T00:00:00.000Z`).\n *\n * @see {@link normalizeDatetime}\n */\nexport function normalizeDatetimeAlways(dtStr: string): ISODatetimeString {\n try {\n return normalizeDatetime(dtStr)\n } catch (err) {\n return '1970-01-01T00:00:00.000Z'\n }\n}\n\n// Legacy exports (should we deprecate these ?)\nexport {\n assertDatetimeString as ensureValidDatetime,\n isDatetimeString as isValidDatetime,\n}\n\n// -----------------------------------------------------------------------------\n// ------------------------- Internal validation logic -------------------------\n// -----------------------------------------------------------------------------\n\n// Validation utils that allow avoiding try/catch for control flow (performance\n// optimization). Other syntax formats should also use this pattern to avoid\n// try/catch in their validation logic, at which point these utils can be moved\n// to a common internal utils.\ntype FailureResult = { success: false; message: string }\nconst failure = (m: string): FailureResult => ({ success: false, message: m })\ntype SuccessResult<V> = { success: true; value: V }\nconst success = <V>(v: V): SuccessResult<V> => ({ success: true, value: v })\ntype Result<V> = FailureResult | SuccessResult<V>\n\n/**\n * @see {@link https://www.rfc-editor.org/rfc/rfc3339#section-5.6 Internet Date/Time Format}\n *\n * @example\n * ```abnf\n * date-fullyear = 4DIGIT\n * date-month = 2DIGIT ; 01-12\n * date-mday = 2DIGIT ; 01-28, 01-29, 01-30, 01-31 based on\n * ; month/year\n * time-hour = 2DIGIT ; 00-23\n * time-minute = 2DIGIT ; 00-59\n * time-second = 2DIGIT ; 00-58, 00-59, 00-60 based on leap second\n * ; rules\n * time-secfrac = \".\" 1*DIGIT\n * time-numoffset = (\"+\" / \"-\") time-hour \":\" time-minute\n * time-offset = \"Z\" / time-numoffset\n * partial-time = time-hour \":\" time-minute \":\" time-second\n * [time-secfrac]\n * full-date = date-fullyear \"-\" date-month \"-\" date-mday\n * full-time = partial-time time-offset\n * date-time = full-date \"T\" full-time\n * ```\n */\nconst DATETIME_REGEX =\n /^(?<full_year>[0-9]{4})-(?<date_month>0[1-9]|1[012])-(?<date_mday>[0-2][0-9]|3[01])T(?<time_hour>[0-1][0-9]|2[0-3]):(?<time_minute>[0-5][0-9]):(?<time_second>[0-5][0-9]|60)(?<time_secfrac>\\.[0-9]+)?(?<time_offset>Z|(?<time_numoffset>[+-](?:[0-1][0-9]|2[0-3]):[0-5][0-9]))$/\n\n/**\n * Validates that the input is a datetime string according to atproto Lexicon\n * rules, and parses it into a Date object.\n */\nfunction parseString(input: unknown): Result<AtprotoDate> {\n // @NOTE Performing cheap tests first\n if (typeof input !== 'string') {\n return failure('datetime must be a string')\n }\n if (input.length > 64) {\n return failure('datetime is too long (64 chars max)')\n }\n if (input.endsWith('-00:00')) {\n return failure('datetime can not use \"-00:00\" for UTC timezone')\n }\n if (!DATETIME_REGEX.test(input)) {\n return failure(\n \"datetime is not in a valid format (must match RFC 3339 & ISO 8601 with 'Z' or ±hh:mm timezone)\",\n )\n }\n\n // must parse as ISO 8601; this also verifies semantics like leap seconds and\n // correct number of days in month, which the regex does not check for\n const date = new Date(input)\n\n return parseDate(date)\n}\n\n/**\n * Ensures that a Date object represents a valid datetime according to atproto\n * Lexicon rules. This ensures that `date.toISOString()` will produce a valid\n * datetime string that can be used where {@link DatetimeString} is expected.\n */\nfunction parseDate(date: Date): Result<AtprotoDate> {\n const fullYear = date.getUTCFullYear()\n // Ensures that the date is valid. We could check isNaN(date.getTime()) here\n // but since we'll check the year anyway, we just use that for the validity\n // check since an invalid date will have NaN year.\n if (Number.isNaN(fullYear)) {\n return failure('datetime did not parse as ISO 8601')\n }\n // Ensure that the ISO string representation does not start with ±YYYYYY\n if (fullYear < 0) {\n return failure('datetime normalized to a negative time')\n }\n if (fullYear > 9999) {\n return failure('datetime year is too far in the future')\n }\n return success(date as AtprotoDate)\n}\n"]}
1
+ {"version":3,"file":"datetime.js","sourceRoot":"","sources":["../src/datetime.ts"],"names":[],"mappings":"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","sourcesContent":["import * as isoDatestringValidator from 'iso-datestring-validator'\n\n// Node ESM interop wraps \"iso-datestring-validator\" as { default: { ... } }\n// @TODO Remove \"iso-datestring-validator\" dependency\nconst { isValidISODateString } = ((m) => m.default ?? m)(isoDatestringValidator)\n\n/**\n * Indicates a date or string is not a valid representation of a datetime\n * according to the atproto\n * {@link https://atproto.com/specs/lexicon#datetime specification}.\n */\nexport class InvalidDatetimeError extends Error {}\n\n/**\n * A subset of {@link DatetimeString} that represent valid datetime strings with\n * the format: `YYYY-MM-DDTHH:mm:ss.sssZ`, as returned by `Date.toISOString()\n * for dates between the years 0000 and 9999.\n *\n * @see {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString}\n */\nexport type ISODatetimeString =\n // @TODO Switch to branded types for more accurate type safety.\n `${string}-${string}-${string}T${string}:${string}:${string}.${string}Z`\n\n/**\n * Represents a {@link Date} that can be safely stringified into a valid atproto\n * {@link DatetimeString} using the {@link Date.toISOString toISOString()}\n * method.\n */\nexport interface AtprotoDate extends Date {\n toISOString(): ISODatetimeString\n}\n\n/**\n * @see {@link AtprotoDate}\n */\nexport function assertAtprotoDate(date: Date): asserts date is AtprotoDate {\n const res = parseDate(date)\n if (!res.success) {\n throw new InvalidDatetimeError(res.message)\n }\n}\n\n/**\n * @see {@link AtprotoDate}\n */\nexport function asAtprotoDate(date: Date): AtprotoDate {\n assertAtprotoDate(date)\n return date\n}\n\n/**\n * @see {@link AtprotoDate}\n */\nexport function isAtprotoDate(date: Date): date is AtprotoDate {\n return parseDate(date).success\n}\n\n/**\n * @see {@link AtprotoDate}\n */\nexport function ifAtprotoDate(date: Date): AtprotoDate | undefined {\n return isAtprotoDate(date) ? date : undefined\n}\n\n/**\n * Datetime strings in atproto data structures and API calls should meet the\n * {@link https://ijmacd.github.io/rfc3339-iso8601/ intersecting} requirements\n * of the RFC 3339, ISO 8601, and WHATWG HTML datetime standards.\n *\n * @note This literal template type is not accurate enough to ensure that a\n * string is a valid atproto datetime. The {@link DatetimeString} validation\n * functions ({@link assertDatetimeString}, {@link isDatetimeString}, etc)\n * should be used to validate that a string meets the atproto datetime\n * requirements, and the {@link toDatetimeString} function should be used to\n * convert a {@link Date} object into a valid {@link DatetimeString} string.\n *\n * @example \"2024-01-15T12:30:00Z\"\n * @example \"2024-01-15T12:30:00.000Z\"\n * @example \"2024-01-15T12:30:00+00:00\"\n * @example \"2024-01-15T11:30:00-01:00\"\n * @see {@link https://atproto.com/specs/lexicon#datetime atproto Lexicon datetime format}\n * @see {@link https://www.rfc-editor.org/rfc/rfc3339 RFC 3339}\n * @see {@link https://www.iso.org/iso-8601-date-and-time-format.html ISO 8601}\n */\nexport type DatetimeString =\n // @TODO Switch to branded types for more accurate type safety?\n | `${string}-${string}-${string}T${string}:${string}:${string}Z`\n | `${string}-${string}-${string}T${string}:${string}:${string}${'+' | '-'}${string}:${string}`\n\n/**\n * Validates that a string is a valid {@link DatetimeString} format string,\n * throwing an error if it is not.\n *\n * @throws InvalidDatetimeError if the input string does not meet the atproto 'datetime' format requirements.\n * @see {@link DatetimeString}\n */\nexport function assertDatetimeString<I>(\n input: I,\n): asserts input is I & DatetimeString {\n const result = parseString(input)\n if (!result.success) {\n throw new InvalidDatetimeError(result.message)\n }\n}\n\n/**\n * Casts a string to a {@link DatetimeString} if it is a valid datetime format\n * string, throwing an error if it is not.\n *\n * @throws InvalidDatetimeError if the input string does not meet the atproto 'datetime' format requirements.\n * @see {@link DatetimeString}\n */\nexport function asDatetimeString<I>(input: I): I & DatetimeString {\n assertDatetimeString(input)\n return input\n}\n\n/**\n * Checks if a string is a valid {@link DatetimeString} format string.\n *\n * @see {@link DatetimeString}\n */\nexport function isDatetimeString<I>(input: I): input is I & DatetimeString {\n return parseString(input).success\n}\n\n/**\n * Matches any ISO-ish datetime string. This is a more lenient check than\n * the strict {@link isDatetimeString} guard, which only allows datetimes that\n * fully conform to the AT Protocol specification (e.g. must include timezone).\n */\nexport function isDatetimeStringLenient<I>(\n input: I,\n): input is I & DatetimeString {\n // @NOTE the returned type assertion is inaccurate wrt. the DatetimeString\n // type definition. A more accurate solution would be to use a branded type\n // instead of a template literal for the \"datetime\" format\n\n if (typeof input !== 'string') return false\n\n try {\n if (isValidISODateString(input)) return true\n } catch {\n // isValidISODateString can throw on some inputs.\n }\n\n // @NOTE The \"iso-datestring-validator\" implementation is *not* compliant with\n // the AT Protocol datetime specification. In particular, it rejects some\n // valid AT Protocol datetimes (eg: \"1985-04-12T23:20:50.1235678912345Z\",\n // \"1985-04-12T23:20:50.123+01:45\", \"1985-04-12T23:20:50.1234567890Z\"). For\n // this reason, we run \"isDatetimeString\" validation if \"isValidISODateString\"\n // does not return true.\n return isDatetimeString(input)\n}\n\n/**\n * Returns the input if it is a valid {@link DatetimeString} format string, or\n * `undefined` if it is not.\n *\n * @see {@link DatetimeString}\n */\nexport function ifDatetimeString<I>(\n input: I,\n): undefined | (I & DatetimeString) {\n return isDatetimeString(input) ? input : undefined\n}\n\n/**\n * Returns the current date and time as a {@link DatetimeString}.\n *\n * @see {@link DatetimeString}\n */\nexport function currentDatetimeString(): DatetimeString {\n return toDatetimeString(new Date())\n}\n\n/**\n * Converts any {@link Date} into a {@link DatetimeString} if possible, throwing\n * an error if the date is not a valid atproto datetime.\n *\n * This is short-hand for `asAtprotoDate(date).toISOString()`.\n *\n * @throws InvalidDatetimeError if the input date is not a valid atproto datetime (eg, it is too far in the future or past, or it normalizes to a negative year).\n * @see {@link DatetimeString}\n */\nexport function toDatetimeString(date: Date): DatetimeString {\n return asAtprotoDate(date).toISOString()\n}\n\n/**\n * Takes a flexible datetime string and normalizes its representation.\n *\n * This function will work with any valid value that can be parsed as a date. It\n * *additionally* is more flexible about accepting datetimes that are missing\n * timezone information, and normalizing them to a valid atproto datetime.\n *\n * One use-case is a consistent, sortable string. Another is to work with older\n * invalid createdAt datetimes.\n *\n * @note This function might return different normalized strings for the same\n * input depending on the timezone of the machine it is run on, since it will\n * attempt to parse the input \"as is\" if it fails to parse with an explicit\n * timezone.\n *\n * @returns ISODatetimeString - a valid atproto datetime with millisecond precision (3 sub-second digits) and UTC timezone with trailing 'Z' syntax.\n * @throws InvalidDatetimeError - if the input string could not be parsed as a datetime, even with permissive parsing.\n */\nexport function normalizeDatetime(dtStr: string): ISODatetimeString {\n if (\n // Explicit timezone offset\n /[+-]\\d\\d:?\\d\\d/.test(dtStr) ||\n // 'Z' timezone designator\n /\\dZ\\b/.test(dtStr) ||\n // Timezone abbreviation (eg. \"EST\", \"PST\", \"UTC\", \"GMT\", etc), as in:\n // > Tue Mar 17 2026 16:38:44 PST (Pacific Standard Time)\n /\\b[A-Z]{3,4}\\b/.test(dtStr)\n ) {\n // Since we do have a timezone designator, we can try parsing \"as is\" and\n // should get consistent results regardless of local timezone.\n\n // @NOTE NodeJS will reject dates with an un-recognized timezone designator\n // (like \"AFT\"), even if we add a well-known timezone abbreviation like\n // \"UTC\" or \"Z\".\n const date = new Date(dtStr)\n if (isAtprotoDate(date)) {\n return date.toISOString()\n }\n } else {\n // If there is no timezone information, try parsing as UTC using two\n // different syntaxes, falling back to parsing \"as is\".\n\n const dateZ = new Date(`${dtStr}Z`)\n if (isAtprotoDate(dateZ)) {\n return dateZ.toISOString()\n }\n\n const dateUTC = new Date(`${dtStr} UTC`)\n if (isAtprotoDate(dateUTC)) {\n return dateUTC.toISOString()\n }\n\n // Despite our best efforts to parse as a consistent value, appending \"Z\" or\n // \" UTC\" did not work, so we will try parsing \"as is\", which may yield\n // different results depending on the local timezone of the machine.\n const date = new Date(dtStr)\n if (isAtprotoDate(date)) {\n return date.toISOString()\n }\n }\n\n throw new InvalidDatetimeError(\n 'datetime did not parse as any timestamp format',\n )\n}\n\n/**\n * Variant of {@link normalizeDatetime} which always returns a valid datetime\n * string.\n *\n * If a {@link InvalidDatetimeError} is encountered, returns the UNIX epoch time\n * as a UTC datetime (`1970-01-01T00:00:00.000Z`).\n *\n * @see {@link normalizeDatetime}\n */\nexport function normalizeDatetimeAlways(dtStr: string): ISODatetimeString {\n try {\n return normalizeDatetime(dtStr)\n } catch (err) {\n return '1970-01-01T00:00:00.000Z'\n }\n}\n\n// Legacy exports (should we deprecate these ?)\nexport {\n assertDatetimeString as ensureValidDatetime,\n isDatetimeString as isValidDatetime,\n}\n\n// -----------------------------------------------------------------------------\n// ------------------------- Internal validation logic -------------------------\n// -----------------------------------------------------------------------------\n\n// Validation utils that allow avoiding try/catch for control flow (performance\n// optimization). Other syntax formats should also use this pattern to avoid\n// try/catch in their validation logic, at which point these utils can be moved\n// to a common internal utils.\ntype FailureResult = { success: false; message: string }\nconst failure = (m: string): FailureResult => ({ success: false, message: m })\ntype SuccessResult<V> = { success: true; value: V }\nconst success = <V>(v: V): SuccessResult<V> => ({ success: true, value: v })\ntype Result<V> = FailureResult | SuccessResult<V>\n\n/**\n * @see {@link https://www.rfc-editor.org/rfc/rfc3339#section-5.6 Internet Date/Time Format}\n *\n * @example\n * ```abnf\n * date-fullyear = 4DIGIT\n * date-month = 2DIGIT ; 01-12\n * date-mday = 2DIGIT ; 01-28, 01-29, 01-30, 01-31 based on\n * ; month/year\n * time-hour = 2DIGIT ; 00-23\n * time-minute = 2DIGIT ; 00-59\n * time-second = 2DIGIT ; 00-58, 00-59, 00-60 based on leap second\n * ; rules\n * time-secfrac = \".\" 1*DIGIT\n * time-numoffset = (\"+\" / \"-\") time-hour \":\" time-minute\n * time-offset = \"Z\" / time-numoffset\n * partial-time = time-hour \":\" time-minute \":\" time-second\n * [time-secfrac]\n * full-date = date-fullyear \"-\" date-month \"-\" date-mday\n * full-time = partial-time time-offset\n * date-time = full-date \"T\" full-time\n * ```\n */\nconst DATETIME_REGEX =\n /^(?<full_year>[0-9]{4})-(?<date_month>0[1-9]|1[012])-(?<date_mday>[0-2][0-9]|3[01])T(?<time_hour>[0-1][0-9]|2[0-3]):(?<time_minute>[0-5][0-9]):(?<time_second>[0-5][0-9]|60)(?<time_secfrac>\\.[0-9]+)?(?<time_offset>Z|(?<time_numoffset>[+-](?:[0-1][0-9]|2[0-3]):[0-5][0-9]))$/\n\n/**\n * Validates that the input is a datetime string according to atproto Lexicon\n * rules, and parses it into a Date object.\n */\nfunction parseString(input: unknown): Result<AtprotoDate> {\n // @NOTE Performing cheap tests first\n if (typeof input !== 'string') {\n return failure('datetime must be a string')\n }\n if (input.length > 64) {\n return failure('datetime is too long (64 chars max)')\n }\n if (input.endsWith('-00:00')) {\n return failure('datetime can not use \"-00:00\" for UTC timezone')\n }\n if (!DATETIME_REGEX.test(input)) {\n return failure(\n \"datetime is not in a valid format (must match RFC 3339 & ISO 8601 with 'Z' or ±hh:mm timezone)\",\n )\n }\n\n // must parse as ISO 8601; this also verifies semantics like leap seconds and\n // correct number of days in month, which the regex does not check for\n const date = new Date(input)\n\n return parseDate(date)\n}\n\n/**\n * Ensures that a Date object represents a valid datetime according to atproto\n * Lexicon rules. This ensures that `date.toISOString()` will produce a valid\n * datetime string that can be used where {@link DatetimeString} is expected.\n */\nfunction parseDate(date: Date): Result<AtprotoDate> {\n const fullYear = date.getUTCFullYear()\n // Ensures that the date is valid. We could check isNaN(date.getTime()) here\n // but since we'll check the year anyway, we just use that for the validity\n // check since an invalid date will have NaN year.\n if (Number.isNaN(fullYear)) {\n return failure('datetime did not parse as ISO 8601')\n }\n // Ensure that the ISO string representation does not start with ±YYYYYY\n if (fullYear < 0) {\n return failure('datetime normalized to a negative time')\n }\n if (fullYear > 9999) {\n return failure('datetime year is too far in the future')\n }\n return success(date as AtprotoDate)\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atproto/syntax",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },
@@ -19,6 +19,7 @@
19
19
  "directory": "packages/syntax"
20
20
  },
21
21
  "dependencies": {
22
+ "iso-datestring-validator": "^2.2.2",
22
23
  "tslib": "^2.8.1"
23
24
  },
24
25
  "devDependencies": {
package/src/datetime.ts CHANGED
@@ -1,3 +1,9 @@
1
+ import * as isoDatestringValidator from 'iso-datestring-validator'
2
+
3
+ // Node ESM interop wraps "iso-datestring-validator" as { default: { ... } }
4
+ // @TODO Remove "iso-datestring-validator" dependency
5
+ const { isValidISODateString } = ((m) => m.default ?? m)(isoDatestringValidator)
6
+
1
7
  /**
2
8
  * Indicates a date or string is not a valid representation of a datetime
3
9
  * according to the atproto
@@ -119,6 +125,35 @@ export function isDatetimeString<I>(input: I): input is I & DatetimeString {
119
125
  return parseString(input).success
120
126
  }
121
127
 
128
+ /**
129
+ * Matches any ISO-ish datetime string. This is a more lenient check than
130
+ * the strict {@link isDatetimeString} guard, which only allows datetimes that
131
+ * fully conform to the AT Protocol specification (e.g. must include timezone).
132
+ */
133
+ export function isDatetimeStringLenient<I>(
134
+ input: I,
135
+ ): input is I & DatetimeString {
136
+ // @NOTE the returned type assertion is inaccurate wrt. the DatetimeString
137
+ // type definition. A more accurate solution would be to use a branded type
138
+ // instead of a template literal for the "datetime" format
139
+
140
+ if (typeof input !== 'string') return false
141
+
142
+ try {
143
+ if (isValidISODateString(input)) return true
144
+ } catch {
145
+ // isValidISODateString can throw on some inputs.
146
+ }
147
+
148
+ // @NOTE The "iso-datestring-validator" implementation is *not* compliant with
149
+ // the AT Protocol datetime specification. In particular, it rejects some
150
+ // valid AT Protocol datetimes (eg: "1985-04-12T23:20:50.1235678912345Z",
151
+ // "1985-04-12T23:20:50.123+01:45", "1985-04-12T23:20:50.1234567890Z"). For
152
+ // this reason, we run "isDatetimeString" validation if "isValidISODateString"
153
+ // does not return true.
154
+ return isDatetimeString(input)
155
+ }
156
+
122
157
  /**
123
158
  * Returns the input if it is a valid {@link DatetimeString} format string, or
124
159
  * `undefined` if it is not.
@@ -3,7 +3,8 @@ import { describe, expect, it, test } from 'vitest'
3
3
  import {
4
4
  InvalidDatetimeError,
5
5
  ensureValidDatetime,
6
- isValidDatetime,
6
+ isDatetimeString,
7
+ isDatetimeStringLenient,
7
8
  normalizeDatetime,
8
9
  normalizeDatetimeAlways,
9
10
  } from '../src/index.js'
@@ -18,29 +19,38 @@ const interopInvalidParse = readLines(
18
19
  `${__dirname}/interop-files/datetime_parse_invalid.txt`,
19
20
  )
20
21
 
22
+ // These strings come from the test suite in "@atproto/lexicon", kept around
23
+ // to ensure compatibility with legacy implementation.
24
+ const legacyValid = [
25
+ '2022-12-12T00:50:36.809Z',
26
+ '2022-12-12T00:50:36Z',
27
+ '2022-12-12T00:50:36.8Z',
28
+ '2022-12-12T00:50:36.80Z',
29
+ '2022-12-12T00:50:36+00:00',
30
+ '2022-12-12T00:50:36.8+00:00',
31
+ '2022-12-11T19:50:36-05:00',
32
+ '2022-12-11T19:50:36.8-05:00',
33
+ '2022-12-11T19:50:36.80-05:00',
34
+ '2022-12-11T19:50:36.809-05:00',
35
+ ]
36
+
21
37
  describe(ensureValidDatetime, () => {
22
- describe('valid interop', () => {
23
- for (const dt of interopValid) {
24
- test(dt, () => {
25
- expect(() => ensureValidDatetime(dt)).not.toThrow()
26
- })
27
- }
38
+ describe('Interop valid', () => {
39
+ test.each(interopValid)('%s', (dt) => {
40
+ expect(() => ensureValidDatetime(dt)).not.toThrow()
41
+ })
28
42
  })
29
43
 
30
- describe('fails on interop (invalid syntax)', () => {
31
- for (const dt of interopInvalidSyntax) {
32
- test(dt, () => {
33
- expect(() => ensureValidDatetime(dt)).toThrow(InvalidDatetimeError)
34
- })
35
- }
44
+ describe('Interop invalid syntax', () => {
45
+ test.each(interopInvalidSyntax)('%s', (dt) => {
46
+ expect(() => ensureValidDatetime(dt)).toThrow(InvalidDatetimeError)
47
+ })
36
48
  })
37
49
 
38
- describe('fails on interop (invalid parse)', () => {
39
- for (const dt of interopInvalidParse) {
40
- test(dt, () => {
41
- expect(() => ensureValidDatetime(dt)).toThrow(InvalidDatetimeError)
42
- })
43
- }
50
+ describe('Interop invalid parse', () => {
51
+ test.each(interopInvalidParse)('%s', (dt) => {
52
+ expect(() => ensureValidDatetime(dt)).toThrow(InvalidDatetimeError)
53
+ })
44
54
  })
45
55
 
46
56
  it('rejects datetime that normalizes past year 9999 due to negative offset', () => {
@@ -52,64 +62,85 @@ describe(ensureValidDatetime, () => {
52
62
  })
53
63
  })
54
64
 
55
- describe(isValidDatetime, () => {
56
- describe('valid interop', () => {
57
- for (const dt of interopValid) {
58
- test(dt, () => {
59
- expect(isValidDatetime(dt)).toBe(true)
60
- })
61
- }
65
+ describe(isDatetimeString, () => {
66
+ describe('Interop valid', () => {
67
+ test.each(interopValid)('%s', (dt) => {
68
+ expect(isDatetimeString(dt)).toBe(true)
69
+ })
70
+ })
71
+
72
+ describe('Interop invalid syntax', () => {
73
+ test.each(interopInvalidSyntax)('%s', (dt) => {
74
+ expect(isDatetimeString(dt)).toBe(false)
75
+ })
62
76
  })
63
77
 
64
- describe('fails on interop (invalid syntax)', () => {
65
- for (const dt of interopInvalidSyntax) {
66
- test(dt, () => {
67
- expect(isValidDatetime(dt)).toBe(false)
68
- })
69
- }
78
+ describe('Interop invalid parse', () => {
79
+ test.each(interopInvalidParse)('%s', (dt) => {
80
+ expect(isDatetimeString(dt)).toBe(false)
81
+ })
70
82
  })
71
83
 
72
- describe('fails on interop (invalid parse)', () => {
73
- for (const dt of interopInvalidParse) {
74
- test(dt, () => {
75
- expect(isValidDatetime(dt)).toBe(false)
76
- })
77
- }
84
+ describe('succeeds on legacy valid', () => {
85
+ test.each(legacyValid)('%s', (dt) => {
86
+ expect(isDatetimeString(dt)).toBe(true)
87
+ })
78
88
  })
79
89
 
80
90
  it('rejects datetime that normalizes past year 9999 due to negative offset', () => {
81
91
  // 9999-12-31T23:59:00-00:01 is syntactically valid, but normalizing to
82
92
  // UTC advances it to 10000-01-01T00:00:00Z, which is out of range
83
- expect(isValidDatetime('9999-12-31T23:59:00-00:01')).toBe(false)
93
+ expect(isDatetimeString('9999-12-31T23:59:00-00:01')).toBe(false)
94
+ })
95
+ })
96
+
97
+ describe(isDatetimeStringLenient, () => {
98
+ describe('Interop valid', () => {
99
+ test.each(interopValid)('%s', (dt) => {
100
+ expect(isDatetimeStringLenient(dt)).toBe(true)
101
+ })
102
+ })
103
+
104
+ // Because of it leniency, the "isDatetimeStringLenient" implementation does
105
+ // not fail on some of the invalid syntax cases, so these tests are skipped.
106
+ describe.skip('Interop invalid syntax', () => {
107
+ test.each(interopInvalidSyntax)('%s', (dt) => {
108
+ expect(isDatetimeStringLenient(dt)).toBe(false)
109
+ })
110
+ })
111
+
112
+ describe('Interop invalid parse', () => {
113
+ test.each(interopInvalidParse)('%s', (dt) => {
114
+ expect(isDatetimeStringLenient(dt)).toBe(false)
115
+ })
116
+ })
117
+
118
+ describe('Legacy valid', () => {
119
+ test.each(legacyValid)('%s', (dt) => {
120
+ expect(isDatetimeStringLenient(dt)).toBe(true)
121
+ })
84
122
  })
85
123
  })
86
124
 
87
125
  describe(normalizeDatetime, () => {
88
- describe('valid interop', () => {
89
- for (const dt of interopValid) {
90
- test(dt, () => {
91
- expect(() => normalizeDatetime(dt)).not.toThrow()
92
- })
93
- }
126
+ describe('Interop valid', () => {
127
+ test.each(interopValid)('%s', (dt) => {
128
+ expect(() => normalizeDatetime(dt)).not.toThrow()
129
+ })
94
130
  })
95
131
 
96
132
  // @NOTE Normalize will actually succeed on some of the invalid syntax cases,
97
133
  // because it is more lenient than the regex validation.
134
+ describe.skip('Interop invalid syntax', () => {
135
+ test.each(interopInvalidSyntax)('%s', (dt) => {
136
+ expect(() => normalizeDatetime(dt)).toThrow(InvalidDatetimeError)
137
+ })
138
+ })
98
139
 
99
- // describe('fails on interop (invalid syntax)', () => {
100
- // for (const dt of interopInvalidSyntax) {
101
- // test(dt, () => {
102
- // expect(() => normalizeDatetime(dt)).toThrow(InvalidDatetimeError)
103
- // })
104
- // }
105
- // })
106
-
107
- describe('fails on interop (invalid parse)', () => {
108
- for (const dt of interopInvalidParse) {
109
- test(dt, () => {
110
- expect(() => normalizeDatetime(dt)).toThrow(InvalidDatetimeError)
111
- })
112
- }
140
+ describe('Interop invalid parse', () => {
141
+ test.each(interopInvalidParse)('%s', (dt) => {
142
+ expect(() => normalizeDatetime(dt)).toThrow(InvalidDatetimeError)
143
+ })
113
144
  })
114
145
 
115
146
  it('normalizes valid input', () => {
@@ -220,30 +251,24 @@ describe(normalizeDatetimeAlways, () => {
220
251
  )
221
252
  })
222
253
 
223
- describe('valid interop', () => {
224
- for (const dt of interopValid) {
225
- test(dt, () => {
226
- // @NOTE we can't test the returned value as some will normalize while others won't.
227
- expect(() => normalizeDatetimeAlways(dt)).not.toThrow()
228
- })
229
- }
254
+ describe('Interop valid', () => {
255
+ test.each(interopValid)('%s', (dt) => {
256
+ // @NOTE we can't test the returned value as some will normalize while others won't.
257
+ expect(() => normalizeDatetimeAlways(dt)).not.toThrow()
258
+ })
230
259
  })
231
260
 
232
- describe('succeeds on interop (invalid syntax)', () => {
233
- for (const dt of interopInvalidSyntax) {
234
- test(dt, () => {
235
- // @NOTE we can't test the returned value as some will normalize while others won't.
236
- expect(() => normalizeDatetimeAlways(dt)).not.toThrow()
237
- })
238
- }
261
+ describe('Interop invalid syntax', () => {
262
+ test.each(interopInvalidSyntax)('%s', (dt) => {
263
+ // @NOTE we can't test the returned value as some will normalize while others won't.
264
+ expect(() => normalizeDatetimeAlways(dt)).not.toThrow()
265
+ })
239
266
  })
240
267
 
241
- describe('succeeds on interop invalid parse', () => {
242
- for (const dt of interopInvalidParse) {
243
- test(dt, () => {
244
- expect(normalizeDatetimeAlways(dt)).toEqual('1970-01-01T00:00:00.000Z')
245
- })
246
- }
268
+ describe('Interop invalid parse', () => {
269
+ test.each(interopInvalidParse)('%s', (dt) => {
270
+ expect(normalizeDatetimeAlways(dt)).toEqual('1970-01-01T00:00:00.000Z')
271
+ })
247
272
  })
248
273
  })
249
274