Skip to content

Commit 59a0991

Browse files
committed
Added support for timezone and calendar extensions to rfc3339 strings
1 parent dfeeae7 commit 59a0991

File tree

3 files changed

+147
-4
lines changed

3 files changed

+147
-4
lines changed

README.md

+37
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,43 @@ The following table illustrates the results of different Date/Time strings
157157
| `"Mon, 02 Jan 2017 06:00:00 -0800"` | rfc2822 | datetime |
158158
| `"Mon, 02 Jan 2017 06:00:00 PST"` | rfc2822 | datetime |
159159

160+
Timezone and Calendar extensions for rfc3339 date/times are also detected:
161+
162+
```js
163+
inferType("2022-02-28T11:06:00.092121729+08:00[Asia/Shanghai][u-ca=chinese]");
164+
```
165+
166+
Will result in
167+
168+
```json
169+
{
170+
"name": "string",
171+
"value": "2022-02-28T11:06:00.092121729+08:00[Asia/Shanghai][u-ca=chinese]",
172+
"format": {
173+
"name": "datetime",
174+
"parts": "datetime",
175+
"variant": "rfc3339",
176+
"extensions": ["timezone", "calendar"]
177+
}
178+
}
179+
```
180+
181+
This is useful for knowing when you can use `Temporal.ZonedDateTime` in the new [Temporal](https://tc39.es/proposal-temporal/docs/index.html) ECMAScript proposal:
182+
183+
```js
184+
const inferredType = inferType("2022-02-28T11:06:00.092121729+08:00[Asia/Shanghai][u-ca=chinese]");
185+
186+
if (
187+
inferredType.name === "string" &&
188+
inferredType.format.name === "datetime" &&
189+
inferredType.format.variant === "rfc3339" &&
190+
inferredType.format.extensions.includes("timezone")
191+
) {
192+
const zonedDateTime = Temporal.ZonedDateTime.from(inferredType.value);
193+
// Temporal.ZonedDateTime <2022-02-28T11:06:00.092121729+08:00[Asia/Shanghai][u-ca=chinese]>
194+
}
195+
```
196+
160197
JSON Infer Types also supports unix epoch timestamps
161198

162199
```js

src/formats/datetime.ts

+33-4
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
type Rfc3339Extensions = Array<"timezone" | "calendar"> | undefined;
2+
13
export type JSONDateTimeFormat = {
24
name: "datetime";
35
parts: "datetime" | "date" | "time";
46
variant: "rfc2822" | "rfc3339";
7+
extensions?: Rfc3339Extensions;
58
};
69

710
export function inferDatetime(value: string): JSONDateTimeFormat | undefined {
@@ -21,33 +24,37 @@ export function inferDatetime(value: string): JSONDateTimeFormat | undefined {
2124
}
2225

2326
const rfc3339WithYmd =
24-
/^([+-]\d{6}|\d{4})(?:-?(\d\d)(?:-?(\d\d))?)?(?:[T\s](\d\d)(?::?(\d\d)(?::?(\d\d)(?:[.,](\d{1,30}))?)?)?(?:(Z?)|([+-]\d\d)(?::?(\d\d))?))?$/;
27+
/^([+-]\d{6}|\d{4})(?:-?(\d\d)(?:-?(\d\d))?)?(?:[T\s](\d\d)(?::?(\d\d)(?::?(\d\d)(?:[.,](\d{1,30}))?)?)?(?:(Z?)|([+-]\d\d)(?::?(\d\d))?))?(?:\[([A-Za-z/_-]+)\])?(?:\[(u-ca=(?:buddhist|chinese|coptic|dangi|ethioaa|ethiopic|gregory|hebrew|indian|islamic|islamic-umalqura|islamic-tbla|islamic-civil|islamic-rgsa|islamicc|iso8601|japanese|persian|roc))\])?$/;
2528

2629
const rfc3339WithWeekIndex =
27-
/^(\d{4})-?W(\d\d)(?:-?(\d))?(?:[T\s](\d\d)(?::?(\d\d)(?::?(\d\d)(?:[.,](\d{1,30}))?)?)?(?:(Z?)|([+-]\d\d)(?::?(\d\d))?))?$/;
30+
/^(\d{4})-?W(\d\d)(?:-?(\d))?(?:[T\s](\d\d)(?::?(\d\d)(?::?(\d\d)(?:[.,](\d{1,30}))?)?)?(?:(Z?)|([+-]\d\d)(?::?(\d\d))?))?(?:\[([A-Za-z/_-]+)\])?(?:\[(u-ca=(?:buddhist|chinese|coptic|dangi|ethioaa|ethiopic|gregory|hebrew|indian|islamic|islamic-umalqura|islamic-tbla|islamic-civil|islamic-rgsa|islamicc|iso8601|japanese|persian|roc))\])?$/;
2831

2932
const rfc3339WithOrdinal =
30-
/^(\d{4})-?(\d{3})?(?:[T\s](\d\d)(?::?(\d\d)(?::?(\d\d)(?:[.,](\d{1,30}))?)?)?(?:(Z?)|([+-]\d\d)(?::?(\d\d))?))?$/;
33+
/^(\d{4})-?(\d{3})?(?:[T\s](\d\d)(?::?(\d\d)(?::?(\d\d)(?:[.,](\d{1,30}))?)?)?(?:(Z?)|([+-]\d\d)(?::?(\d\d))?))?(?:\[([A-Za-z/_-]+)\])?(?:\[(u-ca=(?:buddhist|chinese|coptic|dangi|ethioaa|ethiopic|gregory|hebrew|indian|islamic|islamic-umalqura|islamic-tbla|islamic-civil|islamic-rgsa|islamicc|iso8601|japanese|persian|roc))\])?$/;
3134

3235
const rfc3339TimeOnly =
33-
/^(?:(\d\d)(?::?(\d\d)(?::?(\d\d)(?:[.,](\d{1,30}))?)?)?(?:(Z?)|([+-]\d\d)(?::?(\d\d))?))?$/;
36+
/^(?:(\d\d)(?::?(\d\d)(?::?(\d\d)(?:[.,](\d{1,30}))?)?)?(?:(Z?)|([+-]\d\d)(?::?(\d\d))?))?(?:\[([A-Za-z/_-]+)\])?(?:\[(u-ca=(?:buddhist|chinese|coptic|dangi|ethioaa|ethiopic|gregory|hebrew|indian|islamic|islamic-umalqura|islamic-tbla|islamic-civil|islamic-rgsa|islamicc|iso8601|japanese|persian|roc))\])?$/;
3437

3538
const rfc3339 = [
3639
{
3740
matches: rfc3339WithYmd,
3841
parts: rfc3339Parts,
42+
extensions: rfc3339Extensions(11, 12),
3943
},
4044
{
4145
matches: rfc3339WithWeekIndex,
4246
parts: rfc3339Parts,
47+
extensions: rfc3339Extensions(11, 12),
4348
},
4449
{
4550
matches: rfc3339WithOrdinal,
4651
parts: rfc3339WithOrdinalParts,
52+
extensions: rfc3339Extensions(10, 11),
4753
},
4854
{
4955
matches: rfc3339TimeOnly,
5056
parts: () => <const>"time",
57+
extensions: rfc3339Extensions(8, 9),
5158
},
5259
];
5360

@@ -57,6 +64,7 @@ function inferRFC3339(value: string): JSONDateTimeFormat | undefined {
5764
return {
5865
matches: rfc.matches.exec(value),
5966
parts: rfc.parts,
67+
extensions: rfc.extensions,
6068
};
6169
})
6270
.filter((rfc) => rfc.matches !== null && rfc.matches.some((i) => i));
@@ -72,6 +80,8 @@ function inferRFC3339(value: string): JSONDateTimeFormat | undefined {
7280
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
7381
parts: rfc3339BestMatch.parts(rfc3339BestMatch.matches!),
7482
variant: "rfc3339",
83+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
84+
extensions: rfc3339BestMatch.extensions(rfc3339BestMatch.matches!),
7585
};
7686
}
7787

@@ -92,6 +102,25 @@ function rfc3339Parts(match: RegExpMatchArray): "datetime" | "date" {
92102
return "date";
93103
}
94104

105+
function rfc3339Extensions(
106+
timezoneIndex = 11,
107+
calendarIndex = 12,
108+
): (match: RegExpMatchArray) => Rfc3339Extensions {
109+
return (match: RegExpMatchArray): Rfc3339Extensions => {
110+
const extensions: Array<"timezone" | "calendar"> = [];
111+
112+
if (match[timezoneIndex] !== undefined) {
113+
extensions.push("timezone");
114+
}
115+
116+
if (match[calendarIndex] !== undefined) {
117+
extensions.push("calendar");
118+
}
119+
120+
return extensions.length > 0 ? extensions : undefined;
121+
};
122+
}
123+
95124
function rfc3339WithOrdinalParts(match: RegExpMatchArray): "datetime" | "date" {
96125
const dateParts = [1, 2];
97126
const timeParts = [3, 4, 5, 6];

tests/stringFormats.test.ts

+77
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,46 @@ describe("rfc3339", () => {
2727
});
2828
});
2929

30+
test.each([
31+
"2007-12-03T10:15:30+01:00[Europe/Paris]",
32+
"2016-05-25T09:08:34.123[America/North_Dakota/Beulah]",
33+
"2016-W21-3T09:24:15.123Z[America/Phoenix]",
34+
"1983-10-14T13:30Z[Europe/Isle_of_Man]",
35+
"2022-02-28T11:06:00.092121729+08:00[Asia/Shanghai]",
36+
])("%p should be inferred as an rfc3339 datetime with timezone extension", (value) => {
37+
expect(inferType(value)).toEqual({
38+
name: "string",
39+
value,
40+
format: {
41+
name: "datetime",
42+
parts: "datetime",
43+
variant: "rfc3339",
44+
extensions: ["timezone"],
45+
},
46+
});
47+
});
48+
49+
test.each([
50+
"2007-12-03T10:15:30+01:00[Europe/Paris][u-ca=hebrew]",
51+
"2016-05-25T09:08:34.123[America/North_Dakota/Beulah][u-ca=iso8601]",
52+
"2016-W21-3T09:24:15.123Z[America/Phoenix][u-ca=buddhist]",
53+
"1983-10-14T13:30Z[Europe/Isle_of_Man][u-ca=japanese]",
54+
])(
55+
"%p should be inferred as an rfc3339 datetime with timezone and calendar extension",
56+
(value) => {
57+
expect(inferType(value)).toEqual({
58+
name: "string",
59+
value,
60+
format: {
61+
name: "datetime",
62+
parts: "datetime",
63+
variant: "rfc3339",
64+
extensions: ["timezone", "calendar"],
65+
},
66+
});
67+
},
68+
);
69+
3070
test.each(["2016-05", "2016-05-25", "+002016-05-25", "2016-W21", "2016-W21-3", "2016-200"])(
3171
"%p should be inferred as an rfc3339 date",
3272
(value) => {
@@ -42,6 +82,26 @@ describe("rfc3339", () => {
4282
},
4383
);
4484

85+
test.each([
86+
"2016-05[Europe/Paris][u-ca=hebrew]",
87+
"2016-05-25[Europe/Isle_of_Man][u-ca=japanese]",
88+
"+002016-05-25[America/Phoenix][u-ca=buddhist]",
89+
"2016-W21[America/Phoenix][u-ca=buddhist]",
90+
"2016-W21-3[America/Phoenix][u-ca=buddhist]",
91+
"2016-200[America/Phoenix][u-ca=buddhist]",
92+
])("%p should be inferred as an rfc3339 date with timezone and calendar extensions", (value) => {
93+
expect(inferType(value)).toEqual({
94+
name: "string",
95+
value,
96+
format: {
97+
name: "datetime",
98+
parts: "date",
99+
variant: "rfc3339",
100+
extensions: ["timezone", "calendar"],
101+
},
102+
});
103+
});
104+
45105
test.each(["09:24:15.123Z", "09:24:15", "09:24"])(
46106
"%p should be inferred as an rfc3339 time",
47107
(value) => {
@@ -56,6 +116,23 @@ describe("rfc3339", () => {
56116
});
57117
},
58118
);
119+
120+
test.each([
121+
"09:24:15.123Z[Europe/Isle_of_Man][u-ca=japanese]",
122+
"09:24:15[America/Phoenix][u-ca=buddhist]",
123+
"09:24[Europe/Paris][u-ca=hebrew]",
124+
])("%p should be inferred as an rfc3339 time with timezone and calendar extensions", (value) => {
125+
expect(inferType(value)).toEqual({
126+
name: "string",
127+
value,
128+
format: {
129+
name: "datetime",
130+
parts: "time",
131+
variant: "rfc3339",
132+
extensions: ["timezone", "calendar"],
133+
},
134+
});
135+
});
59136
});
60137

61138
describe("rfc2822", () => {

0 commit comments

Comments
 (0)