temporal-fmt 0.9.80 → 0.9.82
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/README.md +302 -490
- package/dist/{chunk-Z245NHDT.js → chunk-4JMHI6E2.js} +6 -6
- package/dist/chunk-7XSIPXIR.js +129 -0
- package/dist/chunk-7XSIPXIR.js.map +1 -0
- package/dist/{chunk-DMJWJTFY.js → chunk-AF3LHBDQ.js} +2 -2
- package/dist/{chunk-WEVNKOQA.js → chunk-IEQCJPB5.js} +5 -5
- package/dist/{chunk-DFWTCRS3.js → chunk-JCJW2VOV.js} +10 -7
- package/dist/chunk-JCJW2VOV.js.map +1 -0
- package/dist/{chunk-HGRT37LT.js → chunk-KWOTFJNY.js} +3 -3
- package/dist/{chunk-64UB3WDT.js → chunk-NHNUFNQ7.js} +4 -4
- package/dist/{chunk-WP7TBCTT.js → chunk-TIMSK72I.js} +8 -8
- package/dist/{chunk-LHR5PCP4.js → chunk-UYKKGBZ3.js} +7 -7
- package/dist/{chunk-5KP5H7X2.js → chunk-W45J4E76.js} +6 -4
- package/dist/duration.js +3 -3
- package/dist/format.cjs +7 -4
- package/dist/format.cjs.map +1 -1
- package/dist/format.js +5 -5
- package/dist/index.cjs +147 -34
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +108 -87
- package/dist/index.js.map +1 -1
- package/dist/interval.cjs +7 -4
- package/dist/interval.cjs.map +1 -1
- package/dist/interval.js +7 -7
- package/dist/legacy/index.d.cts +23 -0
- package/dist/legacy/index.d.ts +23 -0
- package/dist/legacy/matchesFormat.d.cts +6 -0
- package/dist/legacy/matchesFormat.d.ts +6 -0
- package/dist/legacy.cjs +2156 -0
- package/dist/legacy.cjs.map +1 -0
- package/dist/legacy.d.cts +1 -0
- package/dist/legacy.d.ts +1 -0
- package/dist/legacy.js +31 -0
- package/dist/legacy.js.map +1 -0
- package/dist/parse.cjs +7 -4
- package/dist/parse.cjs.map +1 -1
- package/dist/parse.js +5 -5
- package/dist/relativeTime.cjs +6 -2
- package/dist/relativeTime.cjs.map +1 -1
- package/dist/relativeTime.js +4 -4
- package/dist/timezone.cjs +6 -2
- package/dist/timezone.cjs.map +1 -1
- package/dist/timezone.js +2 -2
- package/legacy.d.cts +1 -0
- package/legacy.d.ts +1 -0
- package/package.json +13 -1
- package/dist/chunk-DFWTCRS3.js.map +0 -1
- /package/dist/{chunk-Z245NHDT.js.map → chunk-4JMHI6E2.js.map} +0 -0
- /package/dist/{chunk-DMJWJTFY.js.map → chunk-AF3LHBDQ.js.map} +0 -0
- /package/dist/{chunk-WEVNKOQA.js.map → chunk-IEQCJPB5.js.map} +0 -0
- /package/dist/{chunk-HGRT37LT.js.map → chunk-KWOTFJNY.js.map} +0 -0
- /package/dist/{chunk-64UB3WDT.js.map → chunk-NHNUFNQ7.js.map} +0 -0
- /package/dist/{chunk-WP7TBCTT.js.map → chunk-TIMSK72I.js.map} +0 -0
- /package/dist/{chunk-LHR5PCP4.js.map → chunk-UYKKGBZ3.js.map} +0 -0
- /package/dist/{chunk-5KP5H7X2.js.map → chunk-W45J4E76.js.map} +0 -0
package/README.md
CHANGED
|
@@ -2,31 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
-brightgreen?style=flat-square)
|
|
4
4
|
|
|
5
|
-
Format and parse `Temporal` values (`PlainDate`, `PlainTime`, `PlainDateTime`,
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
Node 26
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
Zero dependencies. Native on Node 26+, or bring your own via a polyfill or
|
|
16
|
-
`setTemporal()`. Import from a subpath (`temporal-fmt/format`, `temporal-fmt/parse`,
|
|
17
|
-
etc.) to pull in only what you use — see [Subpath imports](#subpath-imports).
|
|
18
|
-
|
|
19
|
-
Locale-aware tokens need Node 20+ regardless of which path you use — native
|
|
20
|
-
on 26+, or falling back to the Temporal implementation's own
|
|
21
|
-
`toLocaleString()` otherwise. Untested below Node 20.
|
|
22
|
-
|
|
23
|
-
> **Just want `format`/`parse`, nothing else, ever?** Check out
|
|
24
|
-
> [`temporal-fmt-lite`](https://github.com/DirazCoder/temporal-fmt-lite) —
|
|
25
|
-
> a frozen snapshot of this library's original v0.8.2 surface: the same two
|
|
26
|
-
> functions, the same locale support, no recurrence/business-calendar
|
|
27
|
-
> weight to opt out of via subpaths, and no new tokens or exports getting
|
|
28
|
-
> added later. Security and correctness fixes only, no LTS promise. If you
|
|
29
|
-
> outgrow it, migrating to the full `temporal-fmt` is additive, not a rewrite.
|
|
5
|
+
Format and parse `Temporal` values (`PlainDate`, `PlainTime`, `PlainDateTime`, `ZonedDateTime`) using date-fns-style tokens. Bad input throws — no silent garbage. Locale-aware, zero dependencies.
|
|
6
|
+
|
|
7
|
+
Node 26 shipped `Temporal` without a string formatter. TC39's position: use `Intl.DateTimeFormat` and leave token-style formatting to userland. Fair enough — but if `'yyyy-MM-dd'` is muscle memory from date-fns or Day.js, this exists so you don't have to give that up.
|
|
8
|
+
|
|
9
|
+
Native on Node 26+. Below that, bring your own `Temporal` via a polyfill or `setTemporal()`. Import from subpaths to pull only what you use — see [Subpath imports](#subpath-imports).
|
|
10
|
+
|
|
11
|
+
Locale-aware tokens need Node 20+ regardless of path. Untested below Node 20.
|
|
12
|
+
|
|
13
|
+
> **Just want `format`/`parse`, nothing else, ever?** [`temporal-fmt-lite`](https://github.com/DirazCoder/temporal-fmt-lite) is a frozen snapshot of the 0.8.2 surface: same two functions, same locale support, no recurrence/business-calendar weight, no new tokens or exports coming. Security and correctness fixes only, no LTS promise. If you outgrow it, migrating to the full package is additive, not a rewrite.
|
|
30
14
|
|
|
31
15
|
## Install
|
|
32
16
|
|
|
@@ -41,16 +25,16 @@ import { format } from 'temporal-fmt/format';
|
|
|
41
25
|
import { parse } from 'temporal-fmt/parse';
|
|
42
26
|
|
|
43
27
|
const date = Temporal.PlainDate.from('2026-08-04');
|
|
44
|
-
format(date, 'yyyy-MM-dd');
|
|
28
|
+
format(date, 'yyyy-MM-dd'); // "2026-08-04"
|
|
45
29
|
|
|
46
|
-
parse('yyyy-MM-dd HH:mm', '2026-08-04 15:45');
|
|
30
|
+
parse('yyyy-MM-dd HH:mm', '2026-08-04 15:45'); // Temporal.PlainDateTime
|
|
47
31
|
```
|
|
48
32
|
|
|
49
|
-
|
|
33
|
+
`format(temporal, formatStr)` in, `parse(formatStr, input)` out — same shape as date-fns or Day.js. Import from the subpaths shown above, not the bare package: a bundler only ships what you actually call that way. Measured with esbuild: ~27KB for `format` alone via the subpath vs. ~68KB pulled from the bare entry.
|
|
50
34
|
|
|
51
|
-
Below Node 26, `Temporal` isn't global
|
|
35
|
+
Below Node 26, `Temporal` isn't global — see [Providing `Temporal`](#providing-temporal). On Node 26+ the snippet above just works.
|
|
52
36
|
|
|
53
|
-
The package
|
|
37
|
+
The package is large on npm (locales, recurrence, business calendars, timezone disambiguation, an analyzer, config layers), but none of that ships in your bundle unless you import it. Everything lives behind its own subpath — see [Subpath imports](#subpath-imports).
|
|
54
38
|
|
|
55
39
|
## Contents
|
|
56
40
|
|
|
@@ -87,23 +71,19 @@ The package looks large on npm — locales, recurrence, business calendars, time
|
|
|
87
71
|
|
|
88
72
|
### Node 26+
|
|
89
73
|
|
|
90
|
-
Temporal is native and used automatically.
|
|
74
|
+
`Temporal` is native and used automatically.
|
|
91
75
|
|
|
92
76
|
### Polyfill
|
|
93
77
|
|
|
94
|
-
Use a polyfill like [`temporal-polyfill`](https://github.com/fullcalendar/temporal-polyfill) to put Temporal on the global namespace.
|
|
95
|
-
|
|
96
78
|
```js
|
|
97
79
|
import 'temporal-polyfill/global'
|
|
98
80
|
import { format } from 'temporal-fmt/format';
|
|
99
81
|
import { parse } from 'temporal-fmt/parse';
|
|
100
|
-
|
|
101
|
-
parse(...);
|
|
102
82
|
```
|
|
103
83
|
|
|
104
84
|
### Bring your own
|
|
105
85
|
|
|
106
|
-
|
|
86
|
+
Call `setTemporal()` once, before your first `format()`/`parse()` call:
|
|
107
87
|
|
|
108
88
|
```js
|
|
109
89
|
import { Temporal } from 'temporal-polyfill/full';
|
|
@@ -111,12 +91,12 @@ import { setTemporal } from 'temporal-fmt';
|
|
|
111
91
|
import { format } from 'temporal-fmt/format';
|
|
112
92
|
import { parse } from 'temporal-fmt/parse';
|
|
113
93
|
|
|
114
|
-
setTemporal(Temporal);
|
|
94
|
+
setTemporal(Temporal);
|
|
115
95
|
```
|
|
116
96
|
|
|
117
|
-
`setTemporal()` takes precedence over native or global `Temporal
|
|
97
|
+
`setTemporal()` takes precedence over native or global `Temporal`. Call it with no argument to clear the override. Useful for libraries that don't want to touch the global namespace.
|
|
118
98
|
|
|
119
|
-
|
|
99
|
+
Functions that construct a value from scratch need this: `parse()`, `parseISO()`, `resolveZoned()`, and a few others. Functions that only read fields off an existing value (`format()`, `compare()`, `add()`) don't touch the namespace at all.
|
|
120
100
|
|
|
121
101
|
## Formatting
|
|
122
102
|
|
|
@@ -124,8 +104,8 @@ Anything that constructs a `Temporal` value from scratch needs this — `parse()
|
|
|
124
104
|
import { format } from 'temporal-fmt/format';
|
|
125
105
|
|
|
126
106
|
const date = Temporal.PlainDate.from('2026-08-04');
|
|
127
|
-
format(date, 'yyyy-MM-dd');
|
|
128
|
-
format(date, 'MMMM d, yyyy');
|
|
107
|
+
format(date, 'yyyy-MM-dd'); // "2026-08-04"
|
|
108
|
+
format(date, 'MMMM d, yyyy'); // "August 4, 2026"
|
|
129
109
|
|
|
130
110
|
const dt = Temporal.PlainDateTime.from('2026-08-04T15:45:30');
|
|
131
111
|
format(dt, "MMM d, yyyy 'at' h:mm a"); // "Aug 4, 2026 at 3:45 PM"
|
|
@@ -134,86 +114,73 @@ const zdt = Temporal.ZonedDateTime.from('2026-08-04T15:45:30-04:00[America/New_Y
|
|
|
134
114
|
format(zdt, 'yyyy-MM-dd HH:mm zzz'); // "2026-08-04 15:45 America/New_York"
|
|
135
115
|
```
|
|
136
116
|
|
|
137
|
-
Wrap literal text in single quotes
|
|
117
|
+
Wrap literal text in single quotes. For a literal single quote in output, use `''`.
|
|
138
118
|
|
|
139
|
-
|
|
119
|
+
Using a token the input type doesn't support — `HH` on a `PlainDate`, say — throws a descriptive error instead of silently emitting `undefined`.
|
|
140
120
|
|
|
141
|
-
- `format(temporal, formatStr, options?)` —
|
|
142
|
-
- `formatToParts(temporal, formatStr, options?)` —
|
|
143
|
-
- `compileFormat(formatStr)` — pre-tokenizes
|
|
121
|
+
- `format(temporal, formatStr, options?)` — returns a string.
|
|
122
|
+
- `formatToParts(temporal, formatStr, options?)` — returns `{ type: 'literal' | 'token', value, token? }[]`. Mirrors `Intl.DateTimeFormat.formatToParts`, useful for styling individual pieces.
|
|
123
|
+
- `compileFormat(formatStr)` — pre-tokenizes once, returns an object with `.format()`/`.formatToParts()`, `.pieces`, and `.formatStr`. Format strings are already LRU-cached internally (500 entries), so this mainly buys up-front validation — a bad format string throws at compile time rather than on first use.
|
|
144
124
|
|
|
145
125
|
## Parsing
|
|
146
126
|
|
|
147
|
-
`parse()` builds a
|
|
127
|
+
`parse()` builds a `PlainDate`, `PlainTime`, `PlainDateTime`, or `ZonedDateTime` depending on which tokens are present:
|
|
148
128
|
|
|
149
129
|
```js
|
|
150
130
|
import { parse } from 'temporal-fmt/parse';
|
|
151
131
|
|
|
152
|
-
parse('yyyy-MM-dd HH:mm', '2026-08-04 15:45');
|
|
153
|
-
parse('yyyy-MM', '2026-08-04T15:45:30');
|
|
154
|
-
parse('yyyy-MM-dd', '2026-02-30');
|
|
132
|
+
parse('yyyy-MM-dd HH:mm', '2026-08-04 15:45'); // Temporal.PlainDateTime
|
|
133
|
+
parse('yyyy-MM', '2026-08-04T15:45:30'); // throws — shape mismatch
|
|
134
|
+
parse('yyyy-MM-dd', '2026-02-30'); // throws — not a real date
|
|
155
135
|
```
|
|
156
136
|
|
|
157
|
-
Because the return type depends on which tokens are present
|
|
137
|
+
Because the return type depends on which tokens are present, you'll need `instanceof` or a type guard to narrow it in TypeScript — see [Type guards](#type-guards).
|
|
158
138
|
|
|
159
|
-
|
|
139
|
+
`parse()` also catches contradictions a regex would miss:
|
|
160
140
|
|
|
161
141
|
```js
|
|
162
|
-
parse('EEEE, yyyy-MM-dd', 'Tuesday, 2026-08-04'); // fine
|
|
163
|
-
parse('EEEE, yyyy-MM-dd', 'Monday, 2026-08-04'); // throws — it
|
|
142
|
+
parse('EEEE, yyyy-MM-dd', 'Tuesday, 2026-08-04'); // fine
|
|
143
|
+
parse('EEEE, yyyy-MM-dd', 'Monday, 2026-08-04'); // throws — it's not a Monday
|
|
164
144
|
```
|
|
165
145
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
Three other entry points, same underlying logic, different failure handling:
|
|
146
|
+
Other entry points:
|
|
169
147
|
|
|
170
148
|
- `safeParse(formatStr, input, options?)` — never throws. Returns `{ ok: true, value }` or `{ ok: false, error: TemporalFmtError }`.
|
|
171
|
-
- `tryParse(formatStr, input, options?)` — never throws. Returns the value
|
|
172
|
-
- `parseToParts(formatStr, input, options?)` — returns
|
|
149
|
+
- `tryParse(formatStr, input, options?)` — never throws. Returns the value or `undefined`.
|
|
150
|
+
- `parseToParts(formatStr, input, options?)` — returns matched token groups with their positions in the input string.
|
|
173
151
|
- `compileParser(formatStr, options?)` — pre-compiles a parser for repeated use against the same format string.
|
|
174
152
|
|
|
175
|
-
|
|
153
|
+
### Parsing notes
|
|
176
154
|
|
|
177
|
-
- **`yy` (2-digit year)** follows
|
|
178
|
-
- **`hh`/`h`
|
|
179
|
-
- **`HH`/`H`
|
|
180
|
-
- **`a`
|
|
181
|
-
- **`S
|
|
182
|
-
- **`MMMM`/`MMM` assume a 12-month calendar.**
|
|
155
|
+
- **`yy` (2-digit year)** follows POSIX strptime: `00–68` → `2000–2068`, `69–99` → `1900–1999`.
|
|
156
|
+
- **`hh`/`h` without `a` throws.** Mixing `HH`/`H` with `hh`/`h` also throws — `parse()` won't pick one.
|
|
157
|
+
- **`HH`/`H` with `a` is allowed and cross-checked.** `parse('HH:mm a', '13:05 PM')` succeeds; `parse('HH:mm a', '01:05 PM')` throws — the AM/PM contradicts the hour.
|
|
158
|
+
- **`a` is case-insensitive** — `pm`, `Pm`, `PM` all work. Month and weekday names stay case-sensitive.
|
|
159
|
+
- **`S`–`SSSSSSSSS`** go to nanosecond precision. Format truncates to the requested width; parse right-pads short input (`SSSSSSSSS` reading `.5` → 500ms worth of nanoseconds).
|
|
160
|
+
- **`MMMM`/`MMM` assume a 12-month calendar.** Numeric tokens are unaffected.
|
|
183
161
|
|
|
184
162
|
### Ambiguous input and lenient mode
|
|
185
163
|
|
|
186
|
-
By default, `parse()` throws when a glued numeric run
|
|
187
|
-
|
|
188
|
-
```js
|
|
189
|
-
parse('yyyy-Md', '2026-121') // throws — ambiguous
|
|
190
|
-
```
|
|
191
|
-
|
|
192
|
-
Pass `{ lenient: true }` to opt into a documented heuristic instead:
|
|
164
|
+
By default, `parse()` throws when a glued numeric run has more than one valid split:
|
|
193
165
|
|
|
194
166
|
```js
|
|
167
|
+
parse('yyyy-Md', '2026-121') // throws — ambiguous
|
|
195
168
|
parse('yyyy-Md', '2026-121', { lenient: true }).toString() // '2026-12-01'
|
|
196
169
|
```
|
|
197
170
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
Ambiguity is also easy to avoid outright — the same fixes the heuristic's reasoning hints at: zero-pad the tokens (`MM`/`dd`) or put a literal separator between them (`M/d`), and there's only ever one split to find. Unambiguous glued runs parse fine in strict mode: `"85"` against `yyyy-Md` has exactly one valid split (month 8, day 5), so only genuinely ambiguous input throws. One structural case always throws regardless of ambiguity: a glued run in a format string with no `yyyy` at all (`Md`, `dM`, `Hm` on their own) can never produce a date, since `parse()` needs year, month, and day together to build one (the incomplete-date error in [Typed errors](#typed-errors)).
|
|
171
|
+
Lenient heuristic: if one token in the ambiguous run is `d`, prefer the split where day ≤ 12. When that doesn't narrow it down, falls back to the first valid split. Avoid ambiguity entirely by zero-padding (`MM`/`dd`) or adding a separator (`M/d`).
|
|
201
172
|
|
|
202
173
|
### Offset tokens (`X`/`XX`/`XXX`/`x`/`xx`/`xxx`)
|
|
203
174
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
Uppercase (`X`/`XX`/`XXX`) collapses `+00:00` to `Z` for UTC. Lowercase (`x`/`xx`/`xxx`) always emits a numeric offset, even for UTC (`+00`, `+0000`, `+00:00`). The single-letter forms (`X`/`x`) drop minutes when they're zero (`+05` rather than `+0500`) and append them with no colon when non-zero (`+0530`) — the LDML spec's "hours required, minutes optional when zero" rule.
|
|
207
|
-
|
|
208
|
-
On parse, an offset token needs a full date and time to anchor the instant — same rule `zzz` enforces. With an offset token and no `zzz`, the resulting `ZonedDateTime`'s `timeZoneId` is the offset string itself (e.g. `"+09:00"`). With **both** `zzz` and an offset token, it's a cross-check: `zzz` wins for the result's `timeZoneId` (the IANA name is the meaningful label), and the offset token's value must match that zone's actual offset at the parsed instant. Disagreement throws rather than silently picking one — `parse('yyyy-MM-dd HH:mm zzz XXX', '2026-08-04 15:45 America/New_York +09:00')` throws, because August in New York is `-04:00`, not `+09:00`.
|
|
175
|
+
Uppercase collapses `+00:00` to `Z`; lowercase never emits `Z`. Single-letter forms drop minutes when zero (`+05`, `+0530`).
|
|
209
176
|
|
|
210
|
-
|
|
177
|
+
On parse, an offset token needs a full date and time. Without `zzz`, the result's `timeZoneId` is the offset string itself. With both `zzz` and an offset token, `zzz` wins for `timeZoneId` and the offset is cross-checked — a mismatch throws.
|
|
211
178
|
|
|
212
|
-
|
|
179
|
+
Sub-minute offsets (pre-railway local-mean-time zones): only `xxx` passes them through. The others throw rather than silently truncating. Range: `-12:00` to `+14:00`.
|
|
213
180
|
|
|
214
181
|
## Locales
|
|
215
182
|
|
|
216
|
-
Pass a BCP 47
|
|
183
|
+
Pass a BCP 47 tag via `options.locale`. Defaults to `'en-US'`.
|
|
217
184
|
|
|
218
185
|
```js
|
|
219
186
|
format(date, 'MMMM d, yyyy', { locale: 'fr-FR' }); // "août 4, 2026"
|
|
@@ -221,30 +188,22 @@ format(date, 'EEEE d MMMM', { locale: 'ar-EG' }); // Arabic weekday/month nam
|
|
|
221
188
|
format(dt, 'h:mm a', { locale: 'ja-JP' }); // "3:45 午後"
|
|
222
189
|
```
|
|
223
190
|
|
|
224
|
-
|
|
191
|
+
Named-vocabulary tokens (`MMMM`, `MMM`, `EEEE`, `EEE`, `a`) go through `Intl.DateTimeFormat`, so non-Gregorian calendars work when the `Temporal` object already carries one:
|
|
225
192
|
|
|
226
193
|
```js
|
|
227
194
|
const hebrewDate = date.withCalendar('hebrew');
|
|
228
195
|
format(hebrewDate, 'MMMM d, yyyy'); // "Av 21, 5786"
|
|
229
196
|
```
|
|
230
197
|
|
|
231
|
-
Numeric tokens
|
|
198
|
+
Numeric tokens read directly off the object's fields and never touch `Intl`. Same locale handling applies to `parse()`.
|
|
232
199
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
```js
|
|
236
|
-
parse('MMMM d, yyyy', 'août 4, 2026', { locale: 'fr-FR' });
|
|
237
|
-
parse('h:mm a', '3:45 午後', { locale: 'ja-JP' });
|
|
238
|
-
parse('yyyy-MM-dd', '5786-11-21', { locale: 'en-u-ca-hebrew' }); // -u-ca- extension parses into that calendar
|
|
239
|
-
```
|
|
240
|
-
|
|
241
|
-
**Numeric fields come out in Western (0–9) digits by default**, regardless of locale — passing `{ locale: 'ar-EG' }` alone doesn't switch `yyyy`/`MM`/`dd` to Arabic-Indic digits, only the named-vocabulary tokens above. That's on purpose: logs, APIs, and filenames reading this output back in generally want boring ASCII digits, so locale doesn't silently drag the numeric tokens along with it. If you specifically want localized digits on the numeric pieces, that's a separate, explicit opt-in — see [Numbering systems](#numbering-systems) below.
|
|
200
|
+
**Numeric output is always Western digits by default**, regardless of locale. `{ locale: 'ar-EG' }` alone doesn't switch `yyyy`/`MM`/`dd` to Arabic-Indic digits — see [Numbering systems](#numbering-systems) for the opt-in.
|
|
242
201
|
|
|
243
202
|
### Registering custom vocabulary
|
|
244
203
|
|
|
245
|
-
`Intl` doesn't cover every
|
|
204
|
+
`Intl` doesn't cover every case well — a 13-month Hebrew leap year is the standing example.
|
|
246
205
|
|
|
247
|
-
**`registerLocaleVocab(locale, vocab)`** —
|
|
206
|
+
**`registerLocaleVocab(locale, vocab)`** — months, weekdays, day periods only:
|
|
248
207
|
|
|
249
208
|
```js
|
|
250
209
|
import { registerLocaleVocab } from 'temporal-fmt';
|
|
@@ -256,24 +215,20 @@ registerLocaleVocab('en-u-ca-hebrew-leap', {
|
|
|
256
215
|
weekdayShort: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
|
|
257
216
|
dayPeriod: ['AM', 'PM'],
|
|
258
217
|
});
|
|
259
|
-
|
|
260
|
-
const date = Temporal.PlainDate.from('2026-08-04').withCalendar('hebrew');
|
|
261
|
-
format(date, 'MMMM d, yyyy', { locale: 'en-u-ca-hebrew-leap' }); // "Av 4, 5786" (or similar)
|
|
262
218
|
```
|
|
263
219
|
|
|
264
|
-
**`registerLocale(locale, vocab)`** —
|
|
220
|
+
**`registerLocale(locale, vocab)`** — everything above plus quarters, eras, ordinals, duration units, and relative-time language:
|
|
265
221
|
|
|
266
222
|
```js
|
|
267
223
|
import { registerLocale } from 'temporal-fmt';
|
|
268
224
|
|
|
269
225
|
registerLocale('test-locale-1', {
|
|
270
|
-
// base vocab (required)
|
|
271
226
|
monthLong: [/* 12 entries */],
|
|
272
227
|
monthShort: [/* 12 entries */],
|
|
273
228
|
weekdayLong: [/* 7 entries */],
|
|
274
229
|
weekdayShort: [/* 7 entries */],
|
|
275
230
|
dayPeriod: ['AM', 'PM'],
|
|
276
|
-
// extended vocab
|
|
231
|
+
// optional extended vocab:
|
|
277
232
|
quartersLong: ['First', 'Second', 'Third', 'Fourth'],
|
|
278
233
|
quartersShort: ['Q1', 'Q2', 'Q3', 'Q4'],
|
|
279
234
|
erasLong: ['BCE', 'CE'],
|
|
@@ -284,9 +239,9 @@ registerLocale('test-locale-1', {
|
|
|
284
239
|
});
|
|
285
240
|
```
|
|
286
241
|
|
|
287
|
-
`getLocale(locale)` returns the registered
|
|
242
|
+
`getLocale(locale)` returns the registered vocab or falls back to `Intl`. `hasLocale(locale)` checks without pulling the fallback.
|
|
288
243
|
|
|
289
|
-
Validation
|
|
244
|
+
Validation is strict: wrong array lengths, empty strings, duplicate entries, and identical AM/PM values all throw at registration time. Registering invalidates the cache for that locale key immediately.
|
|
290
245
|
|
|
291
246
|
## Tokens
|
|
292
247
|
|
|
@@ -301,7 +256,7 @@ Validation on both functions is strict: wrong array lengths (12 months, 7 weekda
|
|
|
301
256
|
| `dd` | Two-digit day-of-month, zero-padded | `04` |
|
|
302
257
|
| `d` | One- or two-digit day-of-month | `4` |
|
|
303
258
|
| `do` | Ordinal day-of-month, English suffix. Format-only | `4th` |
|
|
304
|
-
| `EEEE` | Long weekday name, locale-aware. Cross-checked
|
|
259
|
+
| `EEEE` | Long weekday name, locale-aware. Cross-checked on parse | `Tuesday` |
|
|
305
260
|
| `EEE` | Short weekday name, locale-aware. Cross-checked on parse | `Tue` |
|
|
306
261
|
| `HH` | Two-digit hour, 24-hour, zero-padded | `15` |
|
|
307
262
|
| `H` | One- or two-digit hour, 24-hour | `15` |
|
|
@@ -311,9 +266,9 @@ Validation on both functions is strict: wrong array lengths (12 months, 7 weekda
|
|
|
311
266
|
| `m` | One- or two-digit minute | `45` |
|
|
312
267
|
| `ss` | Two-digit second, zero-padded | `30` |
|
|
313
268
|
| `s` | One- or two-digit second | `30` |
|
|
314
|
-
| `S
|
|
269
|
+
| `S`…`SSSSSSSSS` | Fractional second, 1–9 digits (tenths through nanoseconds) | `SSS` → `000` |
|
|
315
270
|
| `a` | Day period, locale-aware, case-insensitive on parse | `PM` |
|
|
316
|
-
| `zzz` | IANA time zone
|
|
271
|
+
| `zzz` | IANA time zone ID, or fixed offset. Needs full date+time on parse | `America/New_York` |
|
|
317
272
|
| `zzzz` | Localized long time zone name. Format-only | `Eastern Standard Time` |
|
|
318
273
|
| `z` | Localized short time zone name. Format-only | `EST` |
|
|
319
274
|
| `X` | UTC offset, short, `Z` for UTC | `+05` / `+0530` / `Z` |
|
|
@@ -322,7 +277,7 @@ Validation on both functions is strict: wrong array lengths (12 months, 7 weekda
|
|
|
322
277
|
| `x` | Same as `X`, never `Z` | `+05` / `+0530` / `+00` |
|
|
323
278
|
| `xx` | Same as `XX`, never `Z` | `+0500` / `+0000` |
|
|
324
279
|
| `xxx` | Same as `XXX`, never `Z` | `+05:00` / `+00:00` |
|
|
325
|
-
| `Q` | Quarter
|
|
280
|
+
| `Q` | Quarter digit (1–4). Cross-checked against month on parse | `3` |
|
|
326
281
|
| `QQQ` | Quarter with "Q" prefix. Cross-checked on parse | `Q3` |
|
|
327
282
|
| `ww` | ISO 8601 week number (01–53). Format-only | `32` |
|
|
328
283
|
| `RRRR` | ISO 8601 week-numbering year. Format-only | `2026` |
|
|
@@ -336,21 +291,27 @@ Validation on both functions is strict: wrong array lengths (12 months, 7 weekda
|
|
|
336
291
|
| `GGGG` | Long era name, locale-aware. Format-only | `Anno Domini` |
|
|
337
292
|
| `G` | Short era name, locale-aware. Format-only | `AD` |
|
|
338
293
|
|
|
339
|
-
Every token's structured metadata
|
|
294
|
+
Every token's structured metadata (round-trip safety, locale/calendar/timezone sensitivity, supported types) is available via `tokenInfo(name)` or `TOKEN_METADATA` — see [Introspection and the analyzer](#introspection-and-the-analyzer).
|
|
340
295
|
|
|
341
|
-
**`do`** is format-only —
|
|
296
|
+
**`do`** is format-only — the ordinal suffix isn't structurally distinguishable from adjacent literal text on parse. English-only by design: `Intl.DateTimeFormat` has no part type for ordinals.
|
|
342
297
|
|
|
343
|
-
**`Q`/`QQQ`**
|
|
298
|
+
**`Q`/`QQQ`** format and parse, cross-checking against any month/date tokens in the same string.
|
|
344
299
|
|
|
345
|
-
**`ww`/`RRRR`** are format-only
|
|
300
|
+
**`ww`/`RRRR`** are format-only. `RRRR` is the ISO week-numbering year, not the calendar year — they diverge at year boundaries:
|
|
346
301
|
|
|
347
|
-
|
|
302
|
+
```
|
|
303
|
+
format(PlainDate.from('2026-12-31'), 'ww RRRR') // "53 2026"
|
|
304
|
+
format(PlainDate.from('2027-01-01'), 'ww RRRR') // "53 2026" — still ISO year 2026
|
|
305
|
+
format(PlainDate.from('2027-01-04'), 'ww RRRR') // "01 2027"
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
**`LLLL`/`LLL`/`cccc`/`ccc`** are stand-alone forms (nominative case in Slavic locales). They render identically to `MMMM`/`MMM`/`EEEE`/`EEE` in most locales.
|
|
348
309
|
|
|
349
310
|
## Duration formatting
|
|
350
311
|
|
|
351
|
-
`formatDuration(duration, formatStr, options?)` formats a `Temporal.Duration`
|
|
312
|
+
`formatDuration(duration, formatStr, options?)` formats a `Temporal.Duration` or plain field bag `{ years, months, weeks, days, hours, minutes, seconds, milliseconds }`.
|
|
352
313
|
|
|
353
|
-
Each unit has three forms
|
|
314
|
+
Each unit has three forms:
|
|
354
315
|
|
|
355
316
|
| Token | Form | Example |
|
|
356
317
|
|-------|------|---------|
|
|
@@ -363,46 +324,41 @@ Each unit has three forms, increasing in verbosity:
|
|
|
363
324
|
| `s` / `ss` / `sss` | numeric / short / long — seconds | `2` / `2s` / `2 seconds` |
|
|
364
325
|
| `S` / `SS` / `SSS` | numeric / short / long — milliseconds | `2` / `2ms` / `2 milliseconds` |
|
|
365
326
|
|
|
366
|
-
Short and long forms are plural-aware
|
|
327
|
+
Short and long forms are plural-aware.
|
|
367
328
|
|
|
368
329
|
```js
|
|
369
330
|
import { formatDuration } from 'temporal-fmt';
|
|
370
331
|
|
|
371
|
-
formatDuration({ years: 2, months: 3 }, 'yyy ooo')
|
|
372
|
-
formatDuration({ hours: 2, minutes: 30 }, 'hhh mmm')
|
|
373
|
-
formatDuration({ hours: 2, minutes: 30 }, 'h:mm')
|
|
332
|
+
formatDuration({ years: 2, months: 3 }, 'yyy ooo') // "2 years 3 months"
|
|
333
|
+
formatDuration({ hours: 2, minutes: 30 }, 'hhh mmm') // "2 hours 30 minutes"
|
|
334
|
+
formatDuration({ hours: 2, minutes: 30 }, 'h:mm') // "2:30"
|
|
374
335
|
```
|
|
375
336
|
|
|
376
|
-
|
|
337
|
+
Zero-value units are omitted by default — trailing literal separators from the format string are the caller's problem. Pass `{ showZeroValues: true }` to render them anyway.
|
|
377
338
|
|
|
378
|
-
|
|
339
|
+
Pass `{ locale }` to localize short/long unit names via `Intl.NumberFormat`'s `style: 'unit'` mode. Numeric-only tokens always stay ASCII digits. Note: `locale: 'en-US'` explicit ≠ omitting locale — `Intl`'s spacing differs from the built-in English table (`"2 hr"` vs `"2h"`).
|
|
379
340
|
|
|
380
341
|
```js
|
|
381
342
|
formatDuration({ hours: 2, minutes: 30 }, 'hhh mmm', { locale: 'fr-FR' }) // "2 heures 30 minutes"
|
|
382
|
-
formatDuration({ hours: 2, minutes: 30 }, 'hhh mmm', { locale: 'es-ES' }) // "2 horas 30 minutos"
|
|
383
343
|
formatDuration({ hours: 2, minutes: 30 }, 'hhh mmm', { locale: 'de-DE' }) // "2 Stunden 30 Minuten"
|
|
384
344
|
```
|
|
385
345
|
|
|
386
|
-
Without a `locale`, output comes from a hand-rolled English singular/plural table — this is the default path and it's additive, so existing calls with no `locale` are unaffected. Note that passing `locale: 'en-US'` explicitly isn't identical to omitting it — `Intl`'s spacing conventions differ from the hand-rolled table (`"2 hr"` vs `"2h"`). Pick whichever matches what you need.
|
|
387
|
-
|
|
388
346
|
### Other duration functions
|
|
389
347
|
|
|
390
|
-
- `formatDurationToParts(duration, formatStr, options?)` —
|
|
391
|
-
- `parseDuration(input, formatStr, options?)` —
|
|
392
|
-
- `parseISODuration(input)` / `formatISODuration(duration)` —
|
|
393
|
-
- `balanceDuration(duration)` — normalizes fields
|
|
394
|
-
- `totalDuration(duration, unit)` — sums
|
|
395
|
-
- `compareDuration(a, b)` — `-1`/`0`/`1
|
|
396
|
-
- `addDuration(a, b)` / `subtractDuration(a, b)
|
|
397
|
-
- `roundDuration(duration, options)` —
|
|
348
|
+
- `formatDurationToParts(duration, formatStr, options?)` — returns parts instead of a joined string.
|
|
349
|
+
- `parseDuration(input, formatStr, options?)` — inverse of `formatDuration`.
|
|
350
|
+
- `parseISODuration(input)` / `formatISODuration(duration)` — ISO 8601 duration grammar (`P1Y2M3DT4H5M6S`).
|
|
351
|
+
- `balanceDuration(duration)` — normalizes fields (90 minutes → 1 hour 30 minutes).
|
|
352
|
+
- `totalDuration(duration, unit)` — sums to a single number in the target unit.
|
|
353
|
+
- `compareDuration(a, b)` — `-1`/`0`/`1`.
|
|
354
|
+
- `addDuration(a, b)` / `subtractDuration(a, b)`.
|
|
355
|
+
- `roundDuration(duration, options)` — see [Date arithmetic, comparison, and rounding](#date-arithmetic-comparison-and-rounding).
|
|
398
356
|
|
|
399
357
|
## Relative time
|
|
400
358
|
|
|
401
|
-
Two different things live under this heading — pick based on what you want back.
|
|
402
|
-
|
|
403
359
|
### `formatDistance` — "3 days ago"
|
|
404
360
|
|
|
405
|
-
`formatDistance(date1, date2, options?)` returns a human-readable relative-time string
|
|
361
|
+
`formatDistance(date1, date2, options?)` returns a human-readable relative-time string via `Intl.RelativeTimeFormat`.
|
|
406
362
|
|
|
407
363
|
```js
|
|
408
364
|
import { formatDistance } from 'temporal-fmt';
|
|
@@ -410,126 +366,110 @@ import { formatDistance } from 'temporal-fmt';
|
|
|
410
366
|
const today = Temporal.PlainDate.from('2026-08-04');
|
|
411
367
|
const yesterday = Temporal.PlainDate.from('2026-08-03');
|
|
412
368
|
|
|
413
|
-
formatDistance(today, yesterday)
|
|
369
|
+
formatDistance(today, yesterday) // "yesterday"
|
|
414
370
|
formatDistance(today, yesterday, { numeric: 'always' }) // "1 day ago"
|
|
415
371
|
formatDistance(today, today) // "now"
|
|
416
372
|
formatDistance(today, today.add({ days: 2 }), { locale: 'fr-FR' }) // "dans 2 jours"
|
|
417
373
|
```
|
|
418
374
|
|
|
419
|
-
|
|
375
|
+
Direction: `diff = date1 - date2`. Positive → "in X". Negative → "X ago".
|
|
420
376
|
|
|
421
|
-
|
|
377
|
+
Default unit-selection cutoffs:
|
|
422
378
|
|
|
423
379
|
| abs(diff) | Unit | Default cutoff |
|
|
424
|
-
|
|
380
|
+
|-----------|------|----------------|
|
|
425
381
|
| < 60 seconds | seconds | `seconds: 60` |
|
|
426
382
|
| < 60 minutes | minutes | `minutes: 60` |
|
|
427
383
|
| < 24 hours | hours | `hours: 24` |
|
|
428
384
|
| < 30 days | days | `days: 30` |
|
|
429
|
-
| < 365 days | months | `months: 365` (
|
|
385
|
+
| < 365 days | months | `months: 365` (in days) |
|
|
430
386
|
| otherwise | years | — |
|
|
431
387
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
```js
|
|
435
|
-
formatDistance(in5d, today) // "in 5 days" (default cutoffs)
|
|
436
|
-
formatDistance(in14d, today, { cutoffs: { days: 10 } }) // "this month" (14d > 10d)
|
|
437
|
-
formatDistance(in200d, today, { cutoffs: { months: 100 } }) // "this year" (200d > 100d)
|
|
438
|
-
formatDistance(in30d, today) // "next month" (right at the default 30d boundary)
|
|
439
|
-
```
|
|
388
|
+
Override any subset per call via `{ cutoffs }`. The `months` cutoff is in days because a month isn't a fixed length. Non-monotonic boundaries and non-positive values throw.
|
|
440
389
|
|
|
441
|
-
Accepts `PlainDate`, `PlainDateTime`, or `ZonedDateTime`. A `PlainDate` is treated as midnight when diffed against a `PlainDateTime`. Throws on `PlainTime`
|
|
390
|
+
Accepts `PlainDate`, `PlainDateTime`, or `ZonedDateTime`. A `PlainDate` is treated as midnight when diffed against a `PlainDateTime`. Throws on `PlainTime` and partial-date shapes.
|
|
442
391
|
|
|
443
|
-
`formatDistanceToNow(date, options?)`
|
|
392
|
+
`formatDistanceToNow(date, options?)` reads the system clock at call time:
|
|
444
393
|
|
|
445
394
|
```js
|
|
446
395
|
import { formatDistanceToNow } from 'temporal-fmt';
|
|
447
396
|
|
|
448
397
|
formatDistanceToNow(threeHoursAgo) // "3 hours ago"
|
|
449
|
-
formatDistanceToNow(tomorrow) // "in 1 day"
|
|
398
|
+
formatDistanceToNow(tomorrow) // "in 1 day"
|
|
450
399
|
```
|
|
451
400
|
|
|
452
|
-
|
|
401
|
+
Reference is captured fresh on every call, never cached.
|
|
453
402
|
|
|
454
|
-
### `formatRelative` / `formatRelativeToNow`
|
|
403
|
+
### `formatRelative` / `formatRelativeToNow`
|
|
455
404
|
|
|
456
|
-
|
|
405
|
+
Returns calendar-relative labels ("yesterday", "last week") rather than numeric distance. `formatRelativeToNow(date, options?)` is `formatRelative(date, now)`.
|
|
457
406
|
|
|
458
407
|
## Natural-language date parsing
|
|
459
408
|
|
|
460
|
-
`parseRelative(input, referenceDate, options?)` resolves
|
|
409
|
+
`parseRelative(input, referenceDate, options?)` resolves relative-date phrases against a reference date, returning a `Temporal.PlainDate`. English by default; pass `{ locale: 'es' }`, `'fr'`, or `'de'` for those languages.
|
|
461
410
|
|
|
462
|
-
Supported
|
|
411
|
+
Supported:
|
|
463
412
|
|
|
464
|
-
-
|
|
465
|
-
-
|
|
466
|
-
-
|
|
467
|
-
-
|
|
413
|
+
- weekday references: "next Tuesday", "last Friday", "this Monday"
|
|
414
|
+
- relative day offsets: "today", "tomorrow", "yesterday"
|
|
415
|
+
- relative unit offsets: "in 3 days", "2 weeks ago", "in 1 month", "1 year ago"
|
|
416
|
+
- month-day without year: "March 5th", "Aug 4" (resolves to next occurrence)
|
|
468
417
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
| Phrase class | en | es | fr | de |
|
|
472
|
-
|--------------|----|----|----|----|
|
|
418
|
+
| Phrase | en | es | fr | de |
|
|
419
|
+
|--------|----|----|----|----|
|
|
473
420
|
| today | `today` | `hoy` | `aujourd'hui` | `heute` |
|
|
474
421
|
| tomorrow | `tomorrow` | `mañana` | `demain` | `morgen` |
|
|
475
422
|
| yesterday | `yesterday` | `ayer` | `hier` | `gestern` |
|
|
476
|
-
| next Tuesday | `next Tuesday` | `el próximo martes`
|
|
423
|
+
| next Tuesday | `next Tuesday` | `el próximo martes` | `mardi prochain` | `nächsten Dienstag` |
|
|
477
424
|
| last Tuesday | `last Tuesday` | `el martes pasado` | `mardi dernier` | `letzten Dienstag` |
|
|
478
425
|
| this Wednesday | `this Wednesday` | `este miércoles` | `ce mercredi` | `diesen Mittwoch` |
|
|
479
426
|
| in 3 days | `in 3 days` | `en 3 días` | `dans 3 jours` | `in 3 Tagen` |
|
|
480
427
|
| 2 weeks ago | `2 weeks ago` | `hace 2 semanas` | `il y a 2 semaines` | `vor 2 Wochen` |
|
|
481
428
|
| March 5 | `March 5th` | `5 de marzo` | `5 mars` | `5. März` |
|
|
482
429
|
|
|
483
|
-
Diacritics are stripped before matching
|
|
430
|
+
Diacritics are stripped before matching, so `"miercoles"` matches `"miércoles"`. German umlaut transliterations are expanded (`ä`→`ae`, etc.).
|
|
484
431
|
|
|
485
432
|
```js
|
|
486
433
|
import { parseRelative } from 'temporal-fmt';
|
|
487
434
|
|
|
488
435
|
const today = Temporal.PlainDate.from('2026-08-04'); // Tuesday
|
|
489
|
-
parseRelative('today', today).toString()
|
|
490
|
-
parseRelative('
|
|
491
|
-
parseRelative('next Tuesday', today).toString() // '2026-08-11' (7 days out, not today)
|
|
436
|
+
parseRelative('today', today).toString() // '2026-08-04'
|
|
437
|
+
parseRelative('next Tuesday', today).toString() // '2026-08-11' — 7 days, not today
|
|
492
438
|
parseRelative('last Friday', today).toString() // '2026-07-31'
|
|
493
439
|
parseRelative('in 3 days', today).toString() // '2026-08-07'
|
|
494
|
-
parseRelative('
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
parseRelative('
|
|
498
|
-
parseRelative('
|
|
499
|
-
parseRelative('demain', today, { locale: 'fr-FR' }).toString() // '2026-08-05'
|
|
500
|
-
parseRelative('mardi prochain', today, { locale: 'fr-FR' }).toString() // '2026-08-11'
|
|
501
|
-
parseRelative('morgen', today, { locale: 'de-DE' }).toString() // '2026-08-05'
|
|
502
|
-
parseRelative('nächsten Dienstag', today, { locale: 'de-DE' }).toString() // '2026-08-11'
|
|
440
|
+
parseRelative('March 5th', today).toString() // '2027-03-05' — next occurrence
|
|
441
|
+
|
|
442
|
+
parseRelative('mañana', today, { locale: 'es-ES' }).toString() // '2026-08-05'
|
|
443
|
+
parseRelative('demain', today, { locale: 'fr-FR' }).toString() // '2026-08-05'
|
|
444
|
+
parseRelative('morgen', today, { locale: 'de-DE' }).toString() // '2026-08-05'
|
|
503
445
|
```
|
|
504
446
|
|
|
505
|
-
**
|
|
447
|
+
**Documented ambiguous-case choices:**
|
|
506
448
|
|
|
507
|
-
-
|
|
508
|
-
-
|
|
509
|
-
-
|
|
510
|
-
-
|
|
449
|
+
- "next Tuesday" on a Tuesday = 7 days out. "this Tuesday" handles the same-week case.
|
|
450
|
+
- "last Tuesday" on a Tuesday = 7 days ago.
|
|
451
|
+
- "March 5th" without a year = next occurrence. Past dates this year roll to next year.
|
|
452
|
+
- Bare number phrases ("5 days" without "in"/"ago") throw — same strict contract as `parse()`.
|
|
511
453
|
|
|
512
|
-
|
|
454
|
+
This "next X on X = 7 days" convention holds across all four supported languages by design.
|
|
513
455
|
|
|
514
|
-
`parseRelative` throws a descriptive error for
|
|
456
|
+
`parseRelative` throws a descriptive error for unrecognized phrases. Accepts `PlainDate`, `PlainDateTime`, or `ZonedDateTime` as reference; throws on `PlainTime`.
|
|
515
457
|
|
|
516
458
|
### Typo tolerance (`{ fuzzy: true }`)
|
|
517
459
|
|
|
518
|
-
Strict matching is the default for the same reason `parse()` throws on ambiguous input rather than guessing — a wrong guess that looks plausible is worse than a hard stop. But if you're taking free-text input from a person instead of a controlled phrase set, typos are routine, not exceptional. `{ fuzzy: true }` opts into correcting them:
|
|
519
|
-
|
|
520
460
|
```js
|
|
521
461
|
parseRelative('tommorow', today, { fuzzy: true }).toString() // '2026-08-05'
|
|
522
462
|
parseRelative('next tuesady', today, { fuzzy: true }).toString() // '2026-08-11'
|
|
523
|
-
parseRelative('tommorow', today); // throws
|
|
463
|
+
parseRelative('tommorow', today); // throws
|
|
524
464
|
```
|
|
525
465
|
|
|
526
|
-
|
|
466
|
+
Tokenizes the input on whitespace and corrects any word that isn't an exact vocabulary match, using edit distance 2. Covers dropped letters (distance 1) and transposed pairs (distance 2) without over-correcting.
|
|
527
467
|
|
|
528
|
-
Numbers are never
|
|
468
|
+
Numbers are never corrected. `"5 dyas"` → corrects to `"5 days"` → still throws the usual past-or-future ambiguity error.
|
|
529
469
|
|
|
530
|
-
Fuzzy mode is English-only
|
|
470
|
+
Fuzzy mode is English-only. Other grammars have enough positional variation that one word-substitution corrector doesn't fit without per-language tuning. Combining `{ fuzzy: true }` with a non-English locale throws a clear error rather than silently skipping the correction pass.
|
|
531
471
|
|
|
532
|
-
|
|
472
|
+
`registerRelativeGrammar(grammar)` registers a new language. `listRegisteredGrammars()` lists what's currently registered.
|
|
533
473
|
|
|
534
474
|
## Date arithmetic, comparison, and rounding
|
|
535
475
|
|
|
@@ -543,9 +483,9 @@ subtract(date, 1, 'months');
|
|
|
543
483
|
difference(a, b, 'hours');
|
|
544
484
|
```
|
|
545
485
|
|
|
546
|
-
- `add(value, amount, unit)` / `subtract(value, amount, unit)` — `unit
|
|
547
|
-
- Per-unit wrappers
|
|
548
|
-
- `difference(a, b, unit)` — integer count of unit boundaries crossed
|
|
486
|
+
- `add(value, amount, unit)` / `subtract(value, amount, unit)` — `unit`: `'years' | 'months' | 'weeks' | 'days' | 'hours' | 'minutes' | 'seconds' | 'milliseconds'`.
|
|
487
|
+
- Per-unit wrappers: `addYears`, `addMonths`, `addWeeks`, `addDays`, `addHours`, `addMinutes`, `addSeconds`, `addMilliseconds`, and matching `subtract*` set.
|
|
488
|
+
- `difference(a, b, unit)` — integer count of unit boundaries crossed.
|
|
549
489
|
- Per-unit wrappers: `differenceInYears`, `differenceInMonths`, `differenceInWeeks`, `differenceInDays`, `differenceInHours`, `differenceInMinutes`, `differenceInSeconds`, `differenceInMilliseconds`.
|
|
550
490
|
|
|
551
491
|
### Comparison
|
|
@@ -553,7 +493,7 @@ difference(a, b, 'hours');
|
|
|
553
493
|
```js
|
|
554
494
|
import { compare, isBefore, isSameDay, isWeekend } from 'temporal-fmt';
|
|
555
495
|
|
|
556
|
-
compare(a, b);
|
|
496
|
+
compare(a, b); // -1 / 0 / 1
|
|
557
497
|
isBefore(a, b);
|
|
558
498
|
isSameDay(a, b);
|
|
559
499
|
isWeekend(date);
|
|
@@ -562,13 +502,13 @@ isWeekend(date);
|
|
|
562
502
|
- `compare(a, b)` → `-1`/`0`/`1`.
|
|
563
503
|
- `isEqual`, `isBefore`, `isAfter`.
|
|
564
504
|
- `min(values)`, `max(values)`, `clamp(value, lo, hi)`, `isBetween(value, lo, hi)`.
|
|
565
|
-
-
|
|
505
|
+
- `isToday`, `isTomorrow`, `isYesterday`, `isSameDay`, `isSameWeek`, `isSameMonth`, `isSameQuarter`, `isSameYear`, `isWeekend`, `isWeekday`.
|
|
566
506
|
|
|
567
507
|
### Rounding
|
|
568
508
|
|
|
569
|
-
- `round(value, options)
|
|
509
|
+
- `round(value, options)`.
|
|
570
510
|
- `floor(value, unit, increment?)`, `ceil(value, unit, increment?)`, `truncate(value, unit, increment?)`.
|
|
571
|
-
- `roundDuration(duration, options)` —
|
|
511
|
+
- `roundDuration(duration, options)` — throws for calendar-bound units (months, years) without a `relativeTo` reference.
|
|
572
512
|
|
|
573
513
|
### Calendar utilities
|
|
574
514
|
|
|
@@ -581,24 +521,22 @@ getQuarter(date);
|
|
|
581
521
|
```
|
|
582
522
|
|
|
583
523
|
- `daysInMonth(value)`, `daysInYear(value)`, `monthsInYear(value)`.
|
|
584
|
-
- `isLeapYear(value)`, `isLeapMonth(value)` (
|
|
524
|
+
- `isLeapYear(value)`, `isLeapMonth(value)` (always `false` in Gregorian).
|
|
585
525
|
- `dayOfYear(value)`, `weekOfYear(value)`, `weekYear(value)`.
|
|
586
526
|
- `getQuarter(value, options?)`, `getMonth(value)`, `getWeekday(value)`.
|
|
587
|
-
- `startOf(value, unit)` / `endOf(value, unit)` — `unit
|
|
527
|
+
- `startOf(value, unit)` / `endOf(value, unit)` — `unit`: `'day' | 'month' | 'year' | 'hour' | 'minute' | 'second'`.
|
|
588
528
|
|
|
589
|
-
`getQuarter` defaults to calendar quarters (Jan–Mar = Q1
|
|
529
|
+
`getQuarter` defaults to calendar quarters (Jan–Mar = Q1). Pass `{ startMonth }` to shift the fiscal year start:
|
|
590
530
|
|
|
591
531
|
```js
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
getQuarter({ month: 8 }); // 3 — calendar quarter (default)
|
|
595
|
-
getQuarter({ month: 8 }, { startMonth: 7 }); // 1 — fiscal year starting July (UK/India-style)
|
|
532
|
+
getQuarter({ month: 8 }); // 3 — calendar quarter
|
|
533
|
+
getQuarter({ month: 8 }, { startMonth: 7 }); // 1 — fiscal year starting July
|
|
596
534
|
getQuarter({ month: 8 }, { startMonth: 10 }); // 4 — fiscal year starting October (Apple's FY)
|
|
597
535
|
```
|
|
598
536
|
|
|
599
|
-
`startMonth` must be
|
|
537
|
+
`startMonth` must be 1–12; anything else throws.
|
|
600
538
|
|
|
601
|
-
**Gregorian-only
|
|
539
|
+
**Gregorian-only limitation**: `daysInMonth`, `daysInYear`, `isLeapYear`, `monthsInYear`, `isLeapMonth`, `dayOfYear`, `weekOfYear`, and `weekYear` all use Gregorian rules and give wrong answers on non-Gregorian calendars. For those, use the `Temporal` object's own calendar-aware properties:
|
|
602
540
|
|
|
603
541
|
```js
|
|
604
542
|
const pd = Temporal.PlainDate.from('5784-05-10[u-ca=hebrew]');
|
|
@@ -606,7 +544,7 @@ pd.daysInMonth; // 30 (Sivan)
|
|
|
606
544
|
pd.monthsInYear; // 13 (leap year)
|
|
607
545
|
```
|
|
608
546
|
|
|
609
|
-
Locale-aware
|
|
547
|
+
Locale-aware formatting tokens (`MMMM`, `MMM`, `EEEE`, `EEE`, `a`) go through `Intl.DateTimeFormat` and do respect `calendarId`:
|
|
610
548
|
|
|
611
549
|
```js
|
|
612
550
|
format(Temporal.PlainDate.from('5784-05-10[u-ca=hebrew]'), 'MMMM d, yyyy'); // "Sivan 10, 5784"
|
|
@@ -623,14 +561,14 @@ overlaps(ivA, ivB);
|
|
|
623
561
|
formatRange(iv, 'MMM d');
|
|
624
562
|
```
|
|
625
563
|
|
|
626
|
-
- `interval(start, end, bounds?)` — `bounds
|
|
564
|
+
- `interval(start, end, bounds?)` — `bounds`: `'closed'` (default) | `'open'` | `'half-open-start'` | `'half-open-end'`.
|
|
627
565
|
- `contains(iv, value)`, `overlaps(a, b)`, `intersects(a, b)`.
|
|
628
|
-
- `isBefore(a, b)` / `isAfter(a, b)` —
|
|
629
|
-
- `intersection(a, b)`, `union(a, b)` — return `null` when
|
|
630
|
-
- `difference(a, b)` / `subtract(a, b)` — imported as `intervalDifference`/`intervalSubtract` from the main entry
|
|
631
|
-
- `mergeIntervals(intervals)` — combines
|
|
566
|
+
- `isBefore(a, b)` / `isAfter(a, b)` — imported as `intervalIsBefore`/`intervalIsAfter` from the main entry to avoid collision with the date versions.
|
|
567
|
+
- `intersection(a, b)`, `union(a, b)` — return `null` when intervals don't overlap enough to combine.
|
|
568
|
+
- `difference(a, b)` / `subtract(a, b)` — imported as `intervalDifference`/`intervalSubtract` from the main entry.
|
|
569
|
+
- `mergeIntervals(intervals)` — combines overlapping intervals into their union set.
|
|
632
570
|
- `splitInterval(iv, n)` — splits one interval into `n` equal sub-intervals.
|
|
633
|
-
- `formatRange(iv, formatStr, options?)` / `formatRangeToParts(iv, formatStr, options?)` —
|
|
571
|
+
- `formatRange(iv, formatStr, options?)` / `formatRangeToParts(iv, formatStr, options?)` — uses `Intl.DateTimeFormat.formatRange` where available.
|
|
634
572
|
|
|
635
573
|
## Recurrence
|
|
636
574
|
|
|
@@ -639,19 +577,19 @@ import { recurrence, take, between } from 'temporal-fmt';
|
|
|
639
577
|
|
|
640
578
|
const rule = { freq: 'weekly', interval: 1, count: 10 };
|
|
641
579
|
const iter = recurrence(startDate, rule);
|
|
642
|
-
take(iter, 5);
|
|
643
|
-
between(startDate, rule, rangeStart, rangeEnd);
|
|
580
|
+
take(iter, 5);
|
|
581
|
+
between(startDate, rule, rangeStart, rangeEnd);
|
|
644
582
|
```
|
|
645
583
|
|
|
646
|
-
- `recurrence(start, rule)` — returns an iterator with `next()`/`previous()`. `rule.freq
|
|
584
|
+
- `recurrence(start, rule)` — returns an iterator with `next()`/`previous()`. `rule.freq`: `'secondly' | 'minutely' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly'`. Also supports `interval`, `count`, `until`, weekday/month-day/positional constraints, and exclusions/inclusions (RFC 5545 RRULE model).
|
|
647
585
|
- `take(iter, n)` — collects the next `n` occurrences.
|
|
648
586
|
- `skip(iter, n)` — advances past `n` occurrences.
|
|
649
|
-
- `between(start, rule, rangeStart, rangeEnd)` — all occurrences within a range
|
|
650
|
-
- `parseRRule(input)` / `formatRRule(rule)` — parse/format
|
|
587
|
+
- `between(start, rule, rangeStart, rangeEnd)` — all occurrences within a range.
|
|
588
|
+
- `parseRRule(input)` / `formatRRule(rule)` — parse/format RFC 5545 RRULE text.
|
|
651
589
|
|
|
652
590
|
## Business calendars and holidays
|
|
653
591
|
|
|
654
|
-
|
|
592
|
+
A business-day calendar (weekends, working hours, half days) and a holiday calendar (specific excluded dates) are separate — compose them by passing a holiday calendar's dates to a business calendar's options.
|
|
655
593
|
|
|
656
594
|
```js
|
|
657
595
|
import { createBusinessCalendar, isBusinessDay, addBusinessDays } from 'temporal-fmt';
|
|
@@ -661,7 +599,7 @@ isBusinessDay(cal, someDate);
|
|
|
661
599
|
addBusinessDays(cal, someDate, 5);
|
|
662
600
|
```
|
|
663
601
|
|
|
664
|
-
- `createBusinessCalendar(options?)
|
|
602
|
+
- `createBusinessCalendar(options?)`.
|
|
665
603
|
- `isBusinessDay(cal, value)`.
|
|
666
604
|
- `addBusinessDays(cal, value, n)` / `subtractBusinessDays(cal, value, n)`.
|
|
667
605
|
- `differenceInBusinessDays(cal, a, b)`.
|
|
@@ -679,12 +617,12 @@ holidays.isHoliday(someDate);
|
|
|
679
617
|
nextHoliday(holidays, someDate);
|
|
680
618
|
```
|
|
681
619
|
|
|
682
|
-
- `createHolidayCalendar(specs)` — each spec is
|
|
683
|
-
- The returned calendar has `.isHoliday(value)` and `.holidaysBetween(start, end)` as methods
|
|
684
|
-
- `nextHoliday(cal, value)` / `previousHoliday(cal, value)` —
|
|
685
|
-
- `holidaysBetween(cal, start, end)` — standalone wrapper
|
|
620
|
+
- `createHolidayCalendar(specs)` — each spec is a fixed `{ month, day }` or a `compute(year) => { month, day }` function for floating holidays.
|
|
621
|
+
- The returned calendar has `.isHoliday(value)` and `.holidaysBetween(start, end)` as methods.
|
|
622
|
+
- `nextHoliday(cal, value)` / `previousHoliday(cal, value)` — capped at a 5-year lookahead/lookbehind.
|
|
623
|
+
- `holidaysBetween(cal, start, end)` — standalone wrapper around `cal.holidaysBetween()`.
|
|
686
624
|
|
|
687
|
-
Country-specific holiday datasets are
|
|
625
|
+
Country-specific holiday datasets are out of scope — `createHolidayCalendar` is the abstraction; populating it is the caller's job.
|
|
688
626
|
|
|
689
627
|
## Time zones
|
|
690
628
|
|
|
@@ -698,10 +636,10 @@ getTransitions(rangeStart, rangeEnd);
|
|
|
698
636
|
|
|
699
637
|
- `resolveZoned(fields, timeZone, options?)` — constructs a `ZonedDateTime` with an explicit disambiguation mode for gaps/overlaps: `'compatible' | 'earlier' | 'later' | 'reject'`.
|
|
700
638
|
- `getTimeZone(value)`, `getOffset(value)`, `getOffsetNanoseconds(value)`.
|
|
701
|
-
- `isDST(value)` —
|
|
702
|
-
- `getNextTransition(value)` / `getPreviousTransition(value)
|
|
703
|
-
- `getTransitions(start, end)` — every transition within a range.
|
|
704
|
-
- `possibleInstantsFor(fields, timeZone)` —
|
|
639
|
+
- `isDST(value)` — heuristic: compares the current offset against the January offset. Correct for Northern Hemisphere; Southern Hemisphere zones have DST in January, so it can read backwards there.
|
|
640
|
+
- `getNextTransition(value)` / `getPreviousTransition(value)`.
|
|
641
|
+
- `getTransitions(start, end)` — every DST/offset transition within a range.
|
|
642
|
+
- `possibleInstantsFor(fields, timeZone)` — empty for a DST gap, two for an overlap, one otherwise.
|
|
705
643
|
|
|
706
644
|
## Serialization
|
|
707
645
|
|
|
@@ -714,16 +652,14 @@ fromUnixMilliseconds(1754321130000);
|
|
|
714
652
|
```
|
|
715
653
|
|
|
716
654
|
- `parseISO(input)` / `formatISO(value)` — ISO 8601.
|
|
717
|
-
- `parseRFC3339(input)` / `formatRFC3339(value)` — RFC 3339
|
|
718
|
-
- `parseRFC2822(input)` / `formatRFC2822(value)` — RFC 2822 (email/HTTP
|
|
719
|
-
- `parseHTTPDate(input)` / `formatHTTPDate(value)` —
|
|
720
|
-
- `parseSQL(input)` / `formatSQL(value)` — SQL `DATETIME`/`TIMESTAMP
|
|
721
|
-
- Epoch conversions
|
|
655
|
+
- `parseRFC3339(input)` / `formatRFC3339(value)` — RFC 3339.
|
|
656
|
+
- `parseRFC2822(input)` / `formatRFC2822(value)` — RFC 2822 (email/HTTP headers).
|
|
657
|
+
- `parseHTTPDate(input)` / `formatHTTPDate(value)` — HTTP-date headers.
|
|
658
|
+
- `parseSQL(input)` / `formatSQL(value)` — SQL `DATETIME`/`TIMESTAMP`.
|
|
659
|
+
- Epoch conversions: `fromUnixSeconds`, `fromUnixMilliseconds`, `fromUnixMicroseconds`, `fromUnixNanoseconds`, `toUnixSeconds`, `toUnixMilliseconds`, `toUnixMicroseconds`, `toUnixNanoseconds`.
|
|
722
660
|
|
|
723
661
|
## Introspection and the analyzer
|
|
724
662
|
|
|
725
|
-
Every token carries structured metadata, and there's a small analysis layer over format strings themselves.
|
|
726
|
-
|
|
727
663
|
```js
|
|
728
664
|
import { tokenInfo, listTokens, analyzeFormat, explainFormat, isValidFormat } from 'temporal-fmt';
|
|
729
665
|
|
|
@@ -744,66 +680,75 @@ analyzeFormat("MMMM d, yyyy 'at' h:mm a");
|
|
|
744
680
|
// calendarSensitive, timezoneSensitive, ambiguous, roundTripSafe, warnings }
|
|
745
681
|
```
|
|
746
682
|
|
|
747
|
-
- `analyzeFormat(formatStr)` —
|
|
748
|
-
- `explainFormat(formatStr)` —
|
|
749
|
-
- `tokenInfo(name)` — metadata for one token, or `undefined` if
|
|
750
|
-
- `listTokens()` —
|
|
751
|
-
- `isValidFormat(formatStr)` — `true`
|
|
752
|
-
- `validateFormat(formatStr)` — throws on
|
|
753
|
-
- `tokenizeFormat(formatStr)` —
|
|
754
|
-
- `fieldForToken(token)` — which `TemporalLike` field a
|
|
683
|
+
- `analyzeFormat(formatStr)` — full structured report.
|
|
684
|
+
- `explainFormat(formatStr)` — human-readable version of the same.
|
|
685
|
+
- `tokenInfo(name)` — metadata for one token, or `undefined` if unrecognized.
|
|
686
|
+
- `listTokens()` — all recognized tokens with metadata.
|
|
687
|
+
- `isValidFormat(formatStr)` — `true` if the tokenizer accepts the string.
|
|
688
|
+
- `validateFormat(formatStr)` — throws on invalid input, otherwise returns the `analyzeFormat` report.
|
|
689
|
+
- `tokenizeFormat(formatStr)` — raw literal/token piece list.
|
|
690
|
+
- `fieldForToken(token)` — which `TemporalLike` field a token reads.
|
|
755
691
|
|
|
756
|
-
`TOKEN_METADATA
|
|
692
|
+
`TOKEN_METADATA`, `ALL_TOKEN_NAMES`, and `FORMAT_ONLY_TOKENS` are also exported directly for tooling that wants the raw table (`do`, `ww`, `RRRR`, `D`/`DD`/`DDD`, `LLLL`/`LLL`, `cccc`/`ccc`, `GGGG`/`G`, `zzzz`/`z`).
|
|
757
693
|
|
|
758
|
-
**Round-trip safety
|
|
694
|
+
**Round-trip safety by token family:**
|
|
759
695
|
|
|
760
696
|
| Family | Tokens | Round-trip safe | Why |
|
|
761
|
-
|
|
697
|
+
|--------|--------|-----------------|-----|
|
|
762
698
|
| Year | `yyyy` | yes | Preserves sign for BCE. |
|
|
763
|
-
| Year | `yy` | no | Century is lost —
|
|
764
|
-
| Month | `MMMM`, `MMM`, `MM`, `M` | yes |
|
|
699
|
+
| Year | `yy` | no | Century is lost — re-derived via the `00–68`/`69–99` rule, not guaranteed to match. |
|
|
700
|
+
| Month | `MMMM`, `MMM`, `MM`, `M` | yes | Both numeric and name forms round-trip; name forms are locale-sensitive. |
|
|
765
701
|
| Day | `dd`, `d` | yes | |
|
|
766
|
-
| Day | `do` | no | Format-only
|
|
767
|
-
| Weekday | `EEEE`, `EEE` | yes | Cross-checked
|
|
768
|
-
| ISO week | `ww`, `RRRR` | n/a | Format-only — a week number
|
|
702
|
+
| Day | `do` | no | Format-only. |
|
|
703
|
+
| Weekday | `EEEE`, `EEE` | yes | Cross-checked on parse — a mismatch throws rather than round-tripping wrong. |
|
|
704
|
+
| ISO week | `ww`, `RRRR` | n/a | Format-only — a week number can't reconstruct a specific date alone. |
|
|
769
705
|
|
|
770
706
|
## Typed errors
|
|
771
707
|
|
|
772
|
-
|
|
708
|
+
Main API errors are typed `TemporalFmtError` subclasses. All inherit `code`, `input`, `format`, `token`, `position`, `expected`, `actual`, `reason`.
|
|
709
|
+
|
|
710
|
+
> **Deprecated compatibility API:** `temporal-fmt/legacy` is retained to help existing code migrate. No removal date announced, but not recommended for new code. Use the main API.
|
|
711
|
+
|
|
712
|
+
For code that still depends on plain-`Error` identity or `err.name === 'Error'`:
|
|
773
713
|
|
|
774
|
-
|
|
714
|
+
```js
|
|
715
|
+
import { format, parse } from 'temporal-fmt';
|
|
716
|
+
import { format as legacyFormat, parse as legacyParse } from 'temporal-fmt/legacy';
|
|
717
|
+
```
|
|
718
|
+
|
|
719
|
+
`temporal-fmt/legacy` also provides `formatToParts`, `compileFormat`, `safeParse`, `tryParse`, `parseToParts`, `compileParser`, and `matchesFormat`. `matchesFormat` is also available from the package root for older imports — it checks token shape and vocabulary, not date validity, and is retained for compatibility with existing scripts.
|
|
775
720
|
|
|
776
721
|
| Class | Code | Fires when |
|
|
777
722
|
|-------|------|------------|
|
|
778
723
|
| `FormatSyntaxError` | `FORMAT_SYNTAX_ERROR` | Unterminated quote, format string over the length cap, other syntax issues. |
|
|
779
724
|
| `UnknownTokenError` | `UNKNOWN_TOKEN` | An unrecognized letter run was encountered. |
|
|
780
725
|
| `ParseMismatchError` | `PARSE_MISMATCH` | Input doesn't match the format's shape — generic catch-all. |
|
|
781
|
-
| `InvalidDateError` | `INVALID_DATE` |
|
|
726
|
+
| `InvalidDateError` | `INVALID_DATE` | Valid structure but nonexistent date (Feb 30), or a weekday/quarter that contradicts the date. |
|
|
782
727
|
| `InvalidTimeError` | `INVALID_TIME` | Time out of range (hour 25, etc). |
|
|
783
|
-
| `InvalidOffsetError` | `INVALID_OFFSET` | Offset malformed or outside
|
|
728
|
+
| `InvalidOffsetError` | `INVALID_OFFSET` | Offset malformed or outside `-12:00` to `+14:00`. |
|
|
784
729
|
| `InvalidTimeZoneError` | `INVALID_TIME_ZONE` | Not a recognized IANA name or fixed offset. |
|
|
785
730
|
| `InvalidCalendarError` | `INVALID_CALENDAR` | Unsupported calendar. |
|
|
786
|
-
| `AmbiguousInputError` | `AMBIGUOUS_INPUT` | Input has more than one valid reading
|
|
731
|
+
| `AmbiguousInputError` | `AMBIGUOUS_INPUT` | Input has more than one valid reading. |
|
|
787
732
|
| `InvalidLocaleError` | `INVALID_LOCALE` | Not a valid BCP-47 tag, or an unsupported numbering system. |
|
|
788
|
-
| `InvalidDurationError` | `INVALID_DURATION` | Duration string doesn't match
|
|
733
|
+
| `InvalidDurationError` | `INVALID_DURATION` | Duration string doesn't match ISO 8601 grammar, or a field is non-finite. |
|
|
789
734
|
|
|
790
|
-
###
|
|
735
|
+
### Common error messages
|
|
791
736
|
|
|
792
|
-
**"no valid pattern matches the format string and input shape"** — input doesn't match the format string
|
|
737
|
+
**"no valid pattern matches the format string and input shape"** — input doesn't match the format string. Usually a missing separator, wrong digit count, or `zzz` capturing something that isn't a real IANA zone ID.
|
|
793
738
|
|
|
794
|
-
**"token X requires Y, which this Temporal object doesn't have"** —
|
|
739
|
+
**"token X requires Y, which this Temporal object doesn't have"** — token reads a field the value doesn't carry. `format(plainDate, 'HH:mm')` throws because `PlainDate` has no hour field.
|
|
795
740
|
|
|
796
|
-
**"format string mixes 'yyyy' and 'yy' year representations"** — don't combine
|
|
741
|
+
**"format string mixes 'yyyy' and 'yy' year representations"** — don't combine both year tokens in one format string.
|
|
797
742
|
|
|
798
|
-
**"format string has an incomplete date — year, month, and day tokens must all be present together"** — a partial date
|
|
743
|
+
**"format string has an incomplete date — year, month, and day tokens must all be present together"** — a partial date can't become a Temporal value. Add the missing tokens.
|
|
799
744
|
|
|
800
|
-
**"X is ambiguous — N different ways to read tokens Y are all individually valid"** —
|
|
745
|
+
**"X is ambiguous — N different ways to read tokens Y are all individually valid"** — add a separator, zero-pad, or pass `{ lenient: true }`.
|
|
801
746
|
|
|
802
|
-
**"offset hours X
|
|
747
|
+
**"offset hours X out of range (max 14 — Kiritimati, Line Islands is +14:00)"** — offset's hour component is past the IANA range.
|
|
803
748
|
|
|
804
|
-
**"X has no such wall-clock time on this date — it falls in a DST gap"** — the
|
|
749
|
+
**"X has no such wall-clock time on this date — it falls in a DST gap"** — the time doesn't exist. Pick a different time or pass `{ disambiguation: 'compatible' }` to `resolveZoned`.
|
|
805
750
|
|
|
806
|
-
**"has both a 'zzz' zone (X) and an offset token (Y), but the zone's actual offset at this date/time is Z, not Y"** —
|
|
751
|
+
**"has both a 'zzz' zone (X) and an offset token (Y), but the zone's actual offset at this date/time is Z, not Y"** — zone name and explicit offset disagree at the parsed instant.
|
|
807
752
|
|
|
808
753
|
## Type guards
|
|
809
754
|
|
|
@@ -811,19 +756,19 @@ All subclasses inherit structured fields from `TemporalFmtError`: `code`, `input
|
|
|
811
756
|
import { isPlainDate, assertZonedDateTime } from 'temporal-fmt';
|
|
812
757
|
|
|
813
758
|
if (isPlainDate(value)) { /* narrowed */ }
|
|
814
|
-
assertZonedDateTime(value); // throws descriptively if
|
|
759
|
+
assertZonedDateTime(value); // throws descriptively if not
|
|
815
760
|
```
|
|
816
761
|
|
|
817
|
-
- `isTemporal
|
|
818
|
-
- `assertTemporal`, `assertInstant`, `assertPlainDate`, `assertPlainTime`, `assertPlainDateTime`, `assertZonedDateTime`, `assertPlainYearMonth`, `assertPlainMonthDay`, `assertDuration` — same checks, throwing descriptively on mismatch
|
|
762
|
+
- `isTemporal`, `isInstant`, `isPlainDate`, `isPlainTime`, `isPlainDateTime`, `isZonedDateTime`, `isPlainYearMonth`, `isPlainMonthDay`, `isDuration`.
|
|
763
|
+
- `assertTemporal`, `assertInstant`, `assertPlainDate`, `assertPlainTime`, `assertPlainDateTime`, `assertZonedDateTime`, `assertPlainYearMonth`, `assertPlainMonthDay`, `assertDuration` — same checks, throwing descriptively on mismatch. Useful for narrowing the `unknown` `parse()` returns.
|
|
819
764
|
|
|
820
765
|
## Config
|
|
821
766
|
|
|
822
|
-
`createConfig(overrides?)` returns a frozen config object bundling `locale`, `calendar`, `timeZone`, `numberingSystem`, week rules, rounding, disambiguation, overflow, `parseLenient`, and `durationShowZeroValues
|
|
767
|
+
`createConfig(overrides?)` returns a frozen config object bundling `locale`, `calendar`, `timeZone`, `numberingSystem`, week rules, rounding, disambiguation, overflow, `parseLenient`, and `durationShowZeroValues`. `mergeWithConfig(config, perCall)` folds config defaults into a per-call options object, with per-call options taking precedence.
|
|
823
768
|
|
|
824
769
|
## Numbering systems
|
|
825
770
|
|
|
826
|
-
|
|
771
|
+
Numeric tokens output Western digits by default. Override per call:
|
|
827
772
|
|
|
828
773
|
```js
|
|
829
774
|
import { format, parse } from 'temporal-fmt';
|
|
@@ -832,63 +777,52 @@ format(date, 'yyyy-MM-dd', { numberingSystem: 'arab' }); /
|
|
|
832
777
|
parse('yyyy-MM-dd', '٢٠٢٦-٠٨-٠٤', { parseNumberingSystem: 'arab' }).toString(); // '2026-08-04'
|
|
833
778
|
```
|
|
834
779
|
|
|
835
|
-
|
|
780
|
+
`numberingSystem` and `parseNumberingSystem` are separate options on purpose — you might want localized digits in output without expecting them back on input.
|
|
836
781
|
|
|
837
|
-
|
|
782
|
+
Pass `'auto'` to use whichever numeral system the locale itself uses:
|
|
838
783
|
|
|
839
784
|
```js
|
|
840
|
-
format(date, 'yyyy-MM-dd', { numberingSystem: 'auto', locale: 'ar-EG' }); // "٢٠٢٦-٠٨-٠٤"
|
|
785
|
+
format(date, 'yyyy-MM-dd', { numberingSystem: 'auto', locale: 'ar-EG' }); // "٢٠٢٦-٠٨-٠٤"
|
|
841
786
|
format(date, 'yyyy-MM-dd', { numberingSystem: 'auto', locale: 'bn-BD' }); // "২০২৬-০৮-০৪"
|
|
842
|
-
format(date, 'yyyy-MM-dd', { numberingSystem: 'auto' }); // "2026-08-04" — en-US
|
|
843
|
-
parse('yyyy-MM-dd', '٢٠٢٦-٠٨-٠٤', { parseNumberingSystem: 'auto', locale: 'ar-EG' }); // same resolution, input direction
|
|
787
|
+
format(date, 'yyyy-MM-dd', { numberingSystem: 'auto' }); // "2026-08-04" — en-US is latn
|
|
844
788
|
```
|
|
845
789
|
|
|
846
|
-
|
|
790
|
+
When the locale's native system isn't supported (Thai, Lao, Myanmar digits), `'auto'` falls back to `'latn'`. A malformed locale tag throws. Unset still means `'latn'` everywhere.
|
|
847
791
|
|
|
848
|
-
|
|
792
|
+
Direct conversion utilities:
|
|
849
793
|
|
|
850
794
|
```js
|
|
851
795
|
import { convertDigits, convertDigitsToAscii } from 'temporal-fmt';
|
|
852
796
|
|
|
853
|
-
convertDigits('2026', 'arab');
|
|
797
|
+
convertDigits('2026', 'arab'); // "٢٠٢٦"
|
|
854
798
|
convertDigitsToAscii('٢٠٢٦', 'arab'); // "2026"
|
|
855
799
|
```
|
|
856
800
|
|
|
857
|
-
- `convertDigits(s, system)`
|
|
858
|
-
- `
|
|
859
|
-
- `
|
|
860
|
-
- `SUPPORTED_NUMBERING_SYSTEMS` — the set of supported system names (`'latn' | 'arab' | 'deva' | 'beng' | 'guru' | 'gujr' | 'orya' | 'tamldec' | 'telu' | 'knda' | 'mlym' | 'fullwide' | 'hanidec'`).
|
|
801
|
+
- `convertDigits(s, system)`, `convertDigitsToAscii(s, system)`.
|
|
802
|
+
- `applyNumbering(s, options)` / `applyParseNumbering(s, options)` — the same helpers `format()`/`parse()` use internally, exposed directly.
|
|
803
|
+
- `SUPPORTED_NUMBERING_SYSTEMS` — `'latn' | 'arab' | 'deva' | 'beng' | 'guru' | 'gujr' | 'orya' | 'tamldec' | 'telu' | 'knda' | 'mlym' | 'fullwide' | 'hanidec'`.
|
|
861
804
|
|
|
862
805
|
## Extending with custom tokens
|
|
863
806
|
|
|
864
|
-
`createFormatter(options?)` builds a formatter
|
|
807
|
+
`createFormatter(options?)` builds a formatter with its own token(s). A custom token can override a built-in of the same name if you need different behavior, without forking the library.
|
|
865
808
|
|
|
866
809
|
## IDE tooling data
|
|
867
810
|
|
|
868
|
-
|
|
811
|
+
These functions feed editor tooling rather than application code:
|
|
869
812
|
|
|
870
813
|
- `getAutocompleteData()` — token autocomplete entries, grouped by family.
|
|
871
814
|
- `getHoverDocs()` — per-token hover documentation.
|
|
872
|
-
- `getInlineDiagnostics(formatStr)` — diagnostics with
|
|
873
|
-
- `previewFormat(formatStr, sample?)` —
|
|
874
|
-
- `getDocUrl(tokenName)` —
|
|
875
|
-
`DAYJS_TO_TEMPORAL_FMT` / `DATE_FNS_TO_TEMPORAL_FMT`
|
|
815
|
+
- `getInlineDiagnostics(formatStr)` — diagnostics with positions and suggested fixes.
|
|
816
|
+
- `previewFormat(formatStr, sample?)` — live preview string for a format.
|
|
817
|
+
- `getDocUrl(tokenName)` — documentation URL for a token (points at [Tokens](#tokens)).
|
|
818
|
+
- `DAYJS_TO_TEMPORAL_FMT` / `DATE_FNS_TO_TEMPORAL_FMT` — token conversion hints for migration tooling.
|
|
876
819
|
|
|
877
820
|
## Legacy mod documentation
|
|
878
821
|
|
|
879
|
-
|
|
880
|
-
does not discover, load, sandbox, or execute mods, and no mod loader or
|
|
881
|
-
permission-management CLI is included. This also removes the package CLI.
|
|
882
|
-
|
|
883
|
-
Historical documentation for prior versions is retained under
|
|
884
|
-
[`LEGACY_VERSION_MODS/`](./LEGACY_VERSION_MODS/). It describes interfaces and
|
|
885
|
-
behavior that are not available in 0.9.80 and later; do not use it as current
|
|
886
|
-
usage guidance.
|
|
822
|
+
Mod support was removed in 0.9.80 — the package no longer discovers, loads, sandboxes, or executes mods, and the CLI is gone with it. Historical documentation for prior versions is in [`LEGACY_VERSION_MODS/`](./LEGACY_VERSION_MODS/). It describes behavior that no longer exists; don't use it as current usage guidance. That folder will be dropped from `main` once 0.10.x ships, but stays on the `0.9.x` branch after it's cut.
|
|
887
823
|
|
|
888
824
|
## Subpath imports
|
|
889
825
|
|
|
890
|
-
Each capability area is also available as a subpath import, for anyone who wants a slice of the package rather than the whole thing:
|
|
891
|
-
|
|
892
826
|
```js
|
|
893
827
|
import { format } from 'temporal-fmt/format';
|
|
894
828
|
import { parse } from 'temporal-fmt/parse';
|
|
@@ -901,17 +835,15 @@ import { recurrence } from 'temporal-fmt/recurrence';
|
|
|
901
835
|
import { registerLocale } from 'temporal-fmt/locale';
|
|
902
836
|
```
|
|
903
837
|
|
|
904
|
-
|
|
838
|
+
Arithmetic, comparison, rounding, business calendars, holidays, serialization, config, type guards, typed errors, the analyzer, and IDE tooling data are only available from the main `temporal-fmt` entry — no dedicated subpaths for those yet.
|
|
905
839
|
|
|
906
|
-
`sideEffects: false` is set in `package.json
|
|
840
|
+
`sideEffects: false` is set in `package.json`. Measured with esbuild: `import { format } from 'temporal-fmt/format'` → ~27KB vs. ~68KB from the bare entry. If bundle size matters, use the subpath.
|
|
907
841
|
|
|
908
842
|
## Migrating from Day.js or date-fns
|
|
909
843
|
|
|
910
844
|
### Token mapping
|
|
911
845
|
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
| Source (Day.js / date-fns) | temporal-fmt | Notes |
|
|
846
|
+
| Day.js / date-fns | temporal-fmt | Notes |
|
|
915
847
|
|---|---|---|
|
|
916
848
|
| `YYYY` | `yyyy` | Lowercase here |
|
|
917
849
|
| `YY` | `yy` | Same |
|
|
@@ -921,11 +853,11 @@ Most date-fns/Day.js tokens map directly. The differences:
|
|
|
921
853
|
| `ddd` | `EEE` | Short weekday |
|
|
922
854
|
| `HH`, `H`, `mm`, `m`, `ss`, `s` | same | Identical |
|
|
923
855
|
| `A`, `a` | `a` | Always lowercase here |
|
|
924
|
-
| `Z` | `XXX` |
|
|
925
|
-
| `ZZ` | `XX` |
|
|
926
|
-
| `X` | not supported |
|
|
927
|
-
| `x` | not supported |
|
|
928
|
-
| `P` | not supported |
|
|
856
|
+
| `Z` | `XXX` | Offset with colon, `Z` for UTC |
|
|
857
|
+
| `ZZ` | `XX` | Offset, no colon |
|
|
858
|
+
| `X` | not supported | Use `fromUnixSeconds` |
|
|
859
|
+
| `x` | not supported | Use `fromUnixMilliseconds` |
|
|
860
|
+
| `P` | not supported | Write the format string out explicitly |
|
|
929
861
|
|
|
930
862
|
### API mapping
|
|
931
863
|
|
|
@@ -941,13 +873,13 @@ Most date-fns/Day.js tokens map directly. The differences:
|
|
|
941
873
|
| `dayjs().isYesterday()` | `isYesterday(date)` | `isYesterday(date)` |
|
|
942
874
|
| `dayjs().isTomorrow()` | `isTomorrow(date)` | `isTomorrow(date)` |
|
|
943
875
|
|
|
944
|
-
###
|
|
876
|
+
### Things that will bite you
|
|
945
877
|
|
|
946
|
-
1. **Strict parsing
|
|
947
|
-
2. **Cross-field validation.** Weekday, quarter, and offset are cross-checked against the parsed date
|
|
948
|
-
3. **No
|
|
949
|
-
4. **Type-preserving.** `format(plainDate, 'HH:mm')` throws — `PlainDate` has no hour field. Day.js would silently use 0.
|
|
950
|
-
5. **Temporal-native, not `Date`-native.**
|
|
878
|
+
1. **Strict parsing.** Day.js picks one reading of ambiguous input silently. This library throws. Add separators or use `{ lenient: true }`.
|
|
879
|
+
2. **Cross-field validation.** Weekday, quarter, and offset are cross-checked against the parsed date. A wrong weekday label throws.
|
|
880
|
+
3. **No "now" fallback.** Missing input doesn't fall back to the current time.
|
|
881
|
+
4. **Type-preserving.** `format(plainDate, 'HH:mm')` throws — `PlainDate` has no hour field. Day.js would silently use 0.
|
|
882
|
+
5. **Temporal-native, not `Date`-native.** Convert at the boundary:
|
|
951
883
|
|
|
952
884
|
```js
|
|
953
885
|
import { Temporal } from 'temporal-polyfill';
|
|
@@ -955,32 +887,28 @@ const pd = Temporal.PlainDate.from(jsDate.toISOString().slice(0, 10));
|
|
|
955
887
|
format(pd, 'yyyy-MM-dd');
|
|
956
888
|
```
|
|
957
889
|
|
|
958
|
-
### Common patterns
|
|
890
|
+
### Common patterns
|
|
959
891
|
|
|
960
892
|
```js
|
|
961
893
|
// CSV/log timestamp parsing
|
|
962
|
-
// Before: const d = dayjs(line, 'YYYY-MM-DD HH:mm:ss');
|
|
963
894
|
const d = parse('yyyy-MM-dd HH:mm:ss', line);
|
|
964
895
|
|
|
965
896
|
// Locale-aware formatting
|
|
966
|
-
// Before: dayjs(date).locale('fr').format('MMMM D, YYYY');
|
|
967
897
|
format(date, 'MMMM d, yyyy', { locale: 'fr-FR' }); // "août 4, 2026"
|
|
968
898
|
|
|
969
899
|
// Relative time
|
|
970
|
-
// Before: dayjs(date).fromNow(); // "3 days ago"
|
|
971
900
|
formatRelativeToNow(date); // "3 days ago"
|
|
972
901
|
|
|
973
902
|
// Date arithmetic
|
|
974
|
-
// Before: dayjs(date).add(7, 'day');
|
|
975
903
|
add(date, 7, 'days'); // or addDays(date, 7)
|
|
976
904
|
```
|
|
977
905
|
|
|
978
906
|
### Things that don't migrate cleanly
|
|
979
907
|
|
|
980
|
-
- Day.js's `
|
|
981
|
-
- Day.js's
|
|
982
|
-
- date-fns's
|
|
983
|
-
-
|
|
908
|
+
- **Day.js's `customParseFormat` plugin** — that plugin parses leniently. Audit callers relying on it.
|
|
909
|
+
- **Day.js's global locale** (`dayjs.locale('fr')`) — this library uses per-call `locale` options.
|
|
910
|
+
- **date-fns's locale objects** — this library takes BCP-47 strings.
|
|
911
|
+
- **`dayjs-timezone`** — use `Temporal.ZonedDateTime` and the `zzz`/offset tokens instead.
|
|
984
912
|
|
|
985
913
|
### Running both during migration
|
|
986
914
|
|
|
@@ -994,183 +922,67 @@ function formatDate(date, formatStr, opts) {
|
|
|
994
922
|
}
|
|
995
923
|
```
|
|
996
924
|
|
|
997
|
-
Migrate file by file,
|
|
925
|
+
Migrate file by file, drop the wrapper when nothing calls the old path anymore.
|
|
998
926
|
|
|
999
927
|
## Related tools
|
|
1000
928
|
|
|
1001
|
-
|
|
929
|
+
Both are deprecated and archived — single-maintainer bandwidth, no further updates — but the last published versions still work:
|
|
1002
930
|
|
|
1003
|
-
- [`eslint-plugin-temporal-fmt`](https://www.npmjs.com/package/eslint-plugin-temporal-fmt) *(deprecated, archived)* — lints format strings for common mistakes (e.g. `hh` without `a`).
|
|
1004
|
-
- [`temporal-fmt-codemod`](https://github.com/DirazCoder/temporal-fmt-codemod) *(deprecated, archived)* —
|
|
931
|
+
- [`eslint-plugin-temporal-fmt`](https://www.npmjs.com/package/eslint-plugin-temporal-fmt) *(deprecated, archived)* — lints format strings for common mistakes (e.g. `hh` without `a`). Backed by the same metadata as `analyzeFormat().warnings`.
|
|
932
|
+
- [`temporal-fmt-codemod`](https://github.com/DirazCoder/temporal-fmt-codemod) *(deprecated, archived)* — jscodeshift codemod that rewrites `dayjs(x).format(...)`/date-fns `format(...)` call sites across a codebase.
|
|
1005
933
|
|
|
1006
934
|
## Testing
|
|
1007
935
|
|
|
1008
|
-
|
|
936
|
+
1400+ test cases across hand-picked scenarios, fuzzing, and adversarial input in the `node:test` suite (`test/*.test.js`), plus a separate `vitest/` suite for internals, a conformance suite, smoke tests (CJS/ESM/bundler/nodenext), and type tests. If it's in this README, it has a test.
|
|
1009
937
|
|
|
1010
|
-
|
|
938
|
+
## Conformance fixtures
|
|
1011
939
|
|
|
1012
|
-
`fixtures.json` is a portable
|
|
1013
|
-
token-based Temporal formatters. It's written against a different
|
|
1014
|
-
library's token vocabulary, not temporal-fmt's — the fixtures are
|
|
1015
|
-
data, not code. Each case names an `op` (`format` / `parse` /
|
|
1016
|
-
`roundtrip`), an input, a pattern, and an expected result, so any
|
|
1017
|
-
library with a `format`/`parse` pair can be pointed at it.
|
|
940
|
+
`fixtures.json` is a portable test-vector set for token-based Temporal formatters, written against a different library's token vocabulary. Each case names an `op` (`format` / `parse` / `roundtrip`), an input, a pattern, and an expected result — any library with a `format`/`parse` pair can use it.
|
|
1018
941
|
|
|
1019
|
-
`test/conformance.test.js` is the temporal-fmt
|
|
1020
|
-
translates fixture patterns into temporal-fmt's actual tokens, runs
|
|
1021
|
-
the cases against `format()`/`parse()`, and checks the result.
|
|
942
|
+
`test/conformance.test.js` is the temporal-fmt adapter. It translates fixture patterns into temporal-fmt tokens, runs them against `format()`/`parse()`, and checks results.
|
|
1022
943
|
|
|
1023
|
-
|
|
944
|
+
### Why this is separate from adversarial and fuzz tests
|
|
1024
945
|
|
|
1025
|
-
|
|
1026
|
-
hostile input — clean throw or correct value, never a crash or a
|
|
1027
|
-
silently wrong one. The reference point there is the library's own
|
|
1028
|
-
logic.
|
|
946
|
+
`test/adversarial.test.js` and `test/fuzz.test.js` check internal consistency under hostile input — clean throw or correct value, never a crash or silent wrong result. The reference point is the library's own logic.
|
|
1029
947
|
|
|
1030
|
-
|
|
1031
|
-
shared set of tricky-but-well-defined cases — DST transitions, leap
|
|
1032
|
-
years, offset rendering, calendar limits — where "correct" comes from
|
|
1033
|
-
the fixture, not from temporal-fmt's own code.
|
|
948
|
+
The conformance suite checks against an external set of tricky-but-well-defined cases — DST transitions, leap years, offset rendering, calendar limits — where "correct" comes from the fixture, not from this library's code.
|
|
1034
949
|
|
|
1035
|
-
|
|
950
|
+
### Pattern translation
|
|
1036
951
|
|
|
1037
952
|
Two fixture tokens don't exist in temporal-fmt:
|
|
1038
953
|
|
|
1039
954
|
| Fixture token | temporal-fmt equivalent | Why |
|
|
1040
955
|
|---|---|---|
|
|
1041
|
-
| `ZZ` (always-signed
|
|
1042
|
-
| `VV` (IANA zone
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
(`
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
One other flagged case no longer diverges:
|
|
1073
|
-
**`shape-mixing-H-and-a-rejected`**. `resolveHour()`
|
|
1074
|
-
(`src/parse.ts`) used to cross-check `H` against `a` instead of
|
|
1075
|
-
banning the combination outright, so `13:05 PM` was accepted (13:00 is
|
|
1076
|
-
consistent with PM) and only a genuine contradiction like `01:05 PM`
|
|
1077
|
-
threw. That choice has since been reverted — `H` and `a` are now
|
|
1078
|
-
refused together outright, unconditionally, matching the fixture. The
|
|
1079
|
-
fixture's own `"opinion"` text on that case still describes the old
|
|
1080
|
-
behavior; it's fixture data, not something this adapter edits, so
|
|
1081
|
-
treat the `opinionated` flag there as historical rather than current.
|
|
1082
|
-
|
|
1083
|
-
## History: divergences that have since been fixed
|
|
1084
|
-
|
|
1085
|
-
Everything below was once tracked in `KNOWN_FAILURES` at the top of
|
|
1086
|
-
`test/conformance.test.js`. That set is currently empty — every
|
|
1087
|
-
previously-found divergence has been resolved, either by fixing a
|
|
1088
|
-
real bug or by deliberately adopting the fixture's convention over a
|
|
1089
|
-
prior design choice. Kept here for context on what changed and why,
|
|
1090
|
-
in case any of it needs revisiting.
|
|
1091
|
-
|
|
1092
|
-
**Fixed — real bug: offset seconds were dropped, not rejected.**
|
|
1093
|
-
`formatOffset()` (`src/tokens.ts`) assumed every offset string was
|
|
1094
|
-
exactly 6 characters — sign, `HH`, `:`, `MM` — and never checked for a
|
|
1095
|
-
seconds component. Verified against a real `Temporal.ZonedDateTime`
|
|
1096
|
-
for a pre-1900 `America/New_York` date: the actual offset is
|
|
1097
|
-
`-04:56:02`, 9 characters, because pre-1883 New York ran on local mean
|
|
1098
|
-
time. The old code read that string's middle two digits as minutes,
|
|
1099
|
-
so `X` silently produced `-0402` (wrong) instead of refusing. Now:
|
|
1100
|
-
`X`/`XX`/`XXX`/`x`/`xx` throw when the offset has a seconds component
|
|
1101
|
-
(none of them have anywhere to put it), and `xxx` — the variant that
|
|
1102
|
-
plays `ZZ`'s "always-signed, never-Z" role — passes the full value
|
|
1103
|
-
through unchanged.
|
|
1104
|
-
- `offset-sub-minute-rejected-by-X`
|
|
1105
|
-
- `offset-sub-minute-passes-through-ZZ`
|
|
1106
|
-
|
|
1107
|
-
**Changed — offset-only `ZonedDateTime` construction, previously
|
|
1108
|
-
supported on purpose, is now refused.** `parse()` used to build a
|
|
1109
|
-
`ZonedDateTime` from an offset token alone, no `zzz` zone required.
|
|
1110
|
-
That was deliberate, not an oversight, but the fixture's position — an
|
|
1111
|
-
offset identifies a moment's distance from UTC, not a time zone, so
|
|
1112
|
-
building a `ZonedDateTime` from one alone papers over that distinction
|
|
1113
|
-
— was adopted instead. A pattern with an offset token and no `zzz` now
|
|
1114
|
-
throws; add `zzz` to the pattern (or parse into a
|
|
1115
|
-
`PlainDateTime`/`PlainDate`/`PlainTime` if a zone genuinely isn't
|
|
1116
|
-
needed).
|
|
1117
|
-
- `zone-required-for-zoneddatetime`
|
|
1118
|
-
- `zone-offset-token-rejected-on-plain-type`
|
|
1119
|
-
|
|
1120
|
-
**Added — `y` token (unpadded, variable-width year).** temporal-fmt
|
|
1121
|
-
previously had only `yyyy` (fixed 4 digits) and `yy` (2-digit,
|
|
1122
|
-
truncated). `y` formats and parses a year at any width, sign preserved
|
|
1123
|
-
for years before ISO year 0 — same semantics as `yyyy` minus the
|
|
1124
|
-
fixed width. It has no bounded fallback the way `yyyy` does when
|
|
1125
|
-
something digit-consuming follows (`yyyy` can fall back to an exact
|
|
1126
|
-
4-digit fragment in that case; `y` being unpadded is the entire point
|
|
1127
|
-
of the token, so there's no narrower shape that still means the same
|
|
1128
|
-
thing). `buildCapturingPattern()` (`src/parsePattern.ts`) refuses at
|
|
1129
|
-
build time to place `y` directly next to another digit-reading token
|
|
1130
|
-
or a digit-leading literal, rather than trying to estimate an
|
|
1131
|
-
ambiguity cost for an unbounded-width fragment — there's no finite
|
|
1132
|
-
number of "width choices" to charge for "any number of digits."
|
|
1133
|
-
- `extreme-year-max-supported`
|
|
1134
|
-
- `extreme-year-negative`
|
|
1135
|
-
- `extreme-year-past-max-rejected` — previously passed even without a
|
|
1136
|
-
real `y` token, because "no valid pattern matches" for the
|
|
1137
|
-
then-unrecognized token happened to also throw. Now genuinely tests
|
|
1138
|
-
275761 CE rejection, via the real max-year check on `y`'s parsed
|
|
1139
|
-
value.
|
|
1140
|
-
|
|
1141
|
-
**Fixed — misdiagnosed as a regex gap; the actual cause was `zzz`
|
|
1142
|
-
rejecting valid IANA zone aliases.** `offset-X-parse-accepts-four-digit`
|
|
1143
|
-
expects `X` to parse a 4-digit offset body (`+0530`) alongside a `zzz`
|
|
1144
|
-
zone. This was originally filed as "the capturing regex for `X` in
|
|
1145
|
-
`pattern.ts` doesn't offer the 4-digit shape as an alternative" — that
|
|
1146
|
-
diagnosis was wrong. The `X` regex fragment matches `+0530` correctly
|
|
1147
|
-
in isolation; the actual failure was `isValidTimeZone()`
|
|
1148
|
-
(`src/pattern.ts`) rejecting the fixture's zone name, `Asia/Kolkata`.
|
|
1149
|
-
`isValidTimeZone()` only checked `Intl.supportedValuesOf('timeZone')`,
|
|
1150
|
-
which lists canonical zone ids but not every IANA link/alias name —
|
|
1151
|
-
`Asia/Kolkata` is a legitimate, commonly-used alias for
|
|
1152
|
-
`Asia/Calcutta` that some ICU builds' `supportedValuesOf()` omits.
|
|
1153
|
-
Confirmed against `temporal-polyfill` directly:
|
|
1154
|
-
`Temporal.ZonedDateTime.from()` resolves `Asia/Kolkata` without
|
|
1155
|
-
complaint, so `parse()` was refusing input its own downstream
|
|
1156
|
-
construction step would have accepted. `isValidTimeZone()` now falls
|
|
1157
|
-
back to asking `Temporal` itself (a real `ZonedDateTime.from()` call)
|
|
1158
|
-
when the fast-path `Intl` lookup misses, rather than trusting only the
|
|
1159
|
-
`Intl` list.
|
|
1160
|
-
|
|
1161
|
-
## Adapter mapping notes
|
|
1162
|
-
|
|
1163
|
-
- `parse(formatStr, input, options)` takes `(formatStr, input)` —
|
|
1164
|
-
reversed from the fixture's `pattern`/`input` field order.
|
|
1165
|
-
- temporal-fmt does have a typed error hierarchy (`TemporalFmtError`
|
|
1166
|
-
and its subclasses in `src/errors.ts`), but the fixture's three
|
|
1167
|
-
`expect.throws` values (`"ParseError"`, `"FormatError"`,
|
|
1168
|
-
`"InvalidPatternError"`) don't map cleanly onto temporal-fmt's
|
|
1169
|
-
dozen-plus subclasses, so the adapter doesn't try — it just checks
|
|
1170
|
-
that something extending `Error` was thrown.
|
|
1171
|
-
- `target` in the fixture is informational only. temporal-fmt's
|
|
1172
|
-
`parse()` infers the result shape from which fields the pattern
|
|
1173
|
-
captures, so the adapter never passes `target` as an input.
|
|
956
|
+
| `ZZ` (always-signed, never `Z`) | `xxx` | Only uppercase `X`/`XX`/`XXX` collapses to `Z`. Lowercase never does — that's `ZZ`'s semantics. `XXX` was tried first and is wrong — it fails cases that expect `+00:00`, not `Z`. |
|
|
957
|
+
| `VV` (IANA zone ID) | `zzz` | temporal-fmt's only zone-identity token. |
|
|
958
|
+
|
|
959
|
+
Everything else in the fixture set (`yyyy`, `y`, `MM`, `dd`, `HH`, `mm`, `ss`, `S`–`SSSSSSSSS`, `h`, `a`, `X`/`XX`/`XXX`) already matches temporal-fmt's vocabulary.
|
|
960
|
+
|
|
961
|
+
### `opinionated` cases
|
|
962
|
+
|
|
963
|
+
Cases flagged `"opinionated": true` encode a design choice of the fixture's source library, not a fact about dates. The adapter still runs them — if temporal-fmt's behavior differs, it logs a divergence note instead of failing.
|
|
964
|
+
|
|
965
|
+
Two cases currently diverge, both `yy`-pivot: `extreme-year-two-digit-pivot-low` and `extreme-year-two-digit-pivot-high`. temporal-fmt refuses `yy` in any format string that isn't a complete date, so `parse("yy-MM", ...)` throws an incomplete-date error before the century-pivot question comes up. Both cases are documented design choices with their own tests.
|
|
966
|
+
|
|
967
|
+
One previously-flagged case no longer diverges: **`shape-mixing-H-and-a-rejected`**. `resolveHour()` used to cross-check `H` against `a` rather than banning the combination outright, so `13:05 PM` was accepted. That was reversed — `H` and `a` are now refused together, matching the fixture. The fixture's `"opinion"` text still describes the old behavior; treat it as historical.
|
|
968
|
+
|
|
969
|
+
### Fixed divergences
|
|
970
|
+
|
|
971
|
+
Everything below was once in `KNOWN_FAILURES`. That list is now empty.
|
|
972
|
+
|
|
973
|
+
**Offset seconds were dropped, not rejected.** `formatOffset()` assumed every offset was exactly 6 characters and never checked for a seconds component. A pre-1883 `America/New_York` date has offset `-04:56:02` — the old code silently produced `-0402`. Fixed: `X`/`XX`/`XXX`/`x`/`xx` throw when the offset has a seconds component; `xxx` passes it through.
|
|
974
|
+
|
|
975
|
+
**Offset-only `ZonedDateTime` construction was refused.** `parse()` previously built a `ZonedDateTime` from an offset token alone, no `zzz` required. Changed: an offset identifies a UTC distance, not a time zone. A pattern with an offset token and no `zzz` now throws.
|
|
976
|
+
|
|
977
|
+
**`y` token added.** temporal-fmt previously had only `yyyy` and `yy`. `y` formats and parses at any width, sign preserved for years before ISO year 0. `buildCapturingPattern()` refuses to place `y` directly next to another digit-reading token or digit-leading literal, rather than trying to cost-estimate an unbounded-width fragment.
|
|
978
|
+
|
|
979
|
+
**`zzz` was rejecting valid IANA aliases.** `offset-X-parse-accepts-four-digit` was misdiagnosed as a regex gap. The actual failure: `isValidTimeZone()` only checked `Intl.supportedValuesOf('timeZone')`, which lists canonical IDs but not all IANA link/alias names — `Asia/Kolkata` (a common alias for `Asia/Calcutta`) was rejected even though `Temporal.ZonedDateTime.from()` accepts it. Fix: `isValidTimeZone()` falls back to a real `ZonedDateTime.from()` call when the `Intl` fast path misses.
|
|
980
|
+
|
|
981
|
+
### Adapter notes
|
|
982
|
+
|
|
983
|
+
- `parse(formatStr, input, options)` takes `(formatStr, input)` — reversed from the fixture's field order.
|
|
984
|
+
- The fixture's three `expect.throws` values (`"ParseError"`, `"FormatError"`, `"InvalidPatternError"`) don't map cleanly onto temporal-fmt's subclasses. The adapter just checks that something extending `Error` was thrown.
|
|
985
|
+
- `target` in the fixture is informational. temporal-fmt's `parse()` infers the result shape from tokens, so the adapter never passes `target` as input.
|
|
1174
986
|
|
|
1175
987
|
## Contributing
|
|
1176
988
|
|
|
@@ -1182,12 +994,12 @@ npm run build
|
|
|
1182
994
|
npm run test:all
|
|
1183
995
|
```
|
|
1184
996
|
|
|
1185
|
-
`npm test` only
|
|
997
|
+
`npm test` runs only the `node:test` suite — it doesn't touch `vitest/`, which unit-tests internals directly. Run `npm run test:all` for everything CI runs (build + type tests + both suites).
|
|
1186
998
|
|
|
1187
|
-
|
|
999
|
+
Build notes if you're touching the toolchain:
|
|
1188
1000
|
|
|
1189
|
-
-
|
|
1190
|
-
- Tests
|
|
1001
|
+
- Requires TypeScript 7.0.2+. `.d.ts` generation runs as a separate `tsc` pass rather than through `tsup` — `tsup`'s dts step uses `rollup-plugin-dts`, which calls into TypeScript's compiler API and isn't stable on 7.x. `tsup.config.ts` sets `dts: false`; `npm run build` runs `tsup && tsc --declaration --emitDeclarationOnly`. Side effect: `dist/` has one `.d.ts` per source file rather than a single rolled-up `index.d.ts`.
|
|
1002
|
+
- Tests use `temporal-polyfill/full`, not the slim build — Hebrew-calendar tests need the full calendar data. `parse.test.js` configures `Temporal` via `setTemporal()` rather than mutating `globalThis.Temporal`.
|
|
1191
1003
|
|
|
1192
1004
|
## License
|
|
1193
1005
|
|