temporal-fmt 0.9.71 → 0.9.81
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 +304 -528
- package/dist/chunk-6FPOCDZV.js +129 -0
- package/dist/chunk-6FPOCDZV.js.map +1 -0
- package/dist/{chunk-CQJYB7E7.js → chunk-ACGCGXP5.js} +4 -10
- package/dist/chunk-ACGCGXP5.js.map +1 -0
- package/dist/chunk-E4DD23HH.js +241 -0
- package/dist/chunk-E4DD23HH.js.map +1 -0
- package/dist/{chunk-MXXTJ5CW.js → chunk-EEPLAE3H.js} +5 -3
- package/dist/{chunk-ZT3UXRAA.js → chunk-EXIJZ4JG.js} +3 -8
- package/dist/chunk-EXIJZ4JG.js.map +1 -0
- package/dist/{chunk-BQET6RUR.js → chunk-GRBQ5FRO.js} +6 -23
- package/dist/{chunk-BQET6RUR.js.map → chunk-GRBQ5FRO.js.map} +1 -1
- package/dist/{chunk-3FMUGYE2.js → chunk-KV6GMX4D.js} +4 -159
- package/dist/chunk-KV6GMX4D.js.map +1 -0
- package/dist/{chunk-3SC5AVSY.js → chunk-TDQYKYVJ.js} +5 -5
- package/dist/chunk-UGYSEYUQ.js +168 -0
- package/dist/chunk-UGYSEYUQ.js.map +1 -0
- package/dist/{chunk-LHR5PCP4.js → chunk-UYKKGBZ3.js} +7 -7
- package/dist/{chunk-64UB3WDT.js → chunk-YQGUTK4E.js} +4 -4
- package/dist/duration.js +3 -3
- package/dist/format.cjs +0 -17
- package/dist/format.cjs.map +1 -1
- package/dist/format.js +4 -4
- package/dist/index.cjs +3908 -4671
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +21 -103
- package/dist/index.d.ts +21 -103
- package/dist/index.js +2474 -893
- package/dist/index.js.map +1 -1
- package/dist/interval.cjs +106 -4773
- package/dist/interval.cjs.map +1 -1
- package/dist/interval.js +7 -12
- 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 +2153 -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 +0 -13
- package/dist/parse.cjs.map +1 -1
- package/dist/parse.js +4 -4
- package/dist/recurrence.js +2 -1
- package/dist/relativeTime.cjs.map +1 -1
- package/dist/relativeTime.js +4 -4
- package/dist/timezone.js +2 -2
- package/dist/tokens.d.cts +0 -4
- package/dist/tokens.d.ts +0 -4
- package/legacy.d.cts +1 -0
- package/legacy.d.ts +1 -0
- package/package.json +17 -21
- package/MODS.md +0 -148
- package/dist/chunk-3FMUGYE2.js.map +0 -1
- package/dist/chunk-CQJYB7E7.js.map +0 -1
- package/dist/chunk-ZT3UXRAA.js.map +0 -1
- package/dist/chunk-ZTIF4ZHX.js +0 -3041
- package/dist/chunk-ZTIF4ZHX.js.map +0 -1
- package/dist/modApi.d.cts +0 -147
- package/dist/modApi.d.ts +0 -147
- package/dist/runtime.d.cts +0 -206
- package/dist/runtime.d.ts +0 -206
- package/scripts/cli.mjs +0 -324
- package/scripts/loadMods.d.mts +0 -37
- package/scripts/loadMods.mjs +0 -801
- package/scripts/managePermissions.mjs +0 -126
- package/scripts/modConfig.mjs +0 -96
- package/scripts/modSandbox.d.mts +0 -13
- package/scripts/modSandbox.mjs +0 -855
- package/scripts/modWire.mjs +0 -241
- package/scripts/modWorker.mjs +0 -574
- package/scripts/semverRange.mjs +0 -157
- package/scripts/versions.json +0 -42
- /package/dist/{chunk-MXXTJ5CW.js.map → chunk-EEPLAE3H.js.map} +0 -0
- /package/dist/{chunk-3SC5AVSY.js.map → chunk-TDQYKYVJ.js.map} +0 -0
- /package/dist/{chunk-LHR5PCP4.js.map → chunk-UYKKGBZ3.js.map} +0 -0
- /package/dist/{chunk-64UB3WDT.js.map → chunk-YQGUTK4E.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 CLI/mods/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
|
|
|
@@ -75,8 +59,7 @@ The package looks large on npm — locales, recurrence, business calendars, time
|
|
|
75
59
|
- [Config](#config)
|
|
76
60
|
- [Extending with custom tokens](#extending-with-custom-tokens)
|
|
77
61
|
- [IDE tooling data](#ide-tooling-data)
|
|
78
|
-
- [
|
|
79
|
-
- [Mods (advanced, optional)](#mods-advanced-optional) — full guide in [MODS.md](./MODS.md)
|
|
62
|
+
- [Legacy mod documentation](#legacy-mod-documentation)
|
|
80
63
|
- [Subpath imports](#subpath-imports)
|
|
81
64
|
- [Migrating from Day.js or date-fns](#migrating-from-dayjs-or-date-fns)
|
|
82
65
|
- [Related tools](#related-tools)
|
|
@@ -88,23 +71,19 @@ The package looks large on npm — locales, recurrence, business calendars, time
|
|
|
88
71
|
|
|
89
72
|
### Node 26+
|
|
90
73
|
|
|
91
|
-
Temporal is native and used automatically.
|
|
74
|
+
`Temporal` is native and used automatically.
|
|
92
75
|
|
|
93
76
|
### Polyfill
|
|
94
77
|
|
|
95
|
-
Use a polyfill like [`temporal-polyfill`](https://github.com/fullcalendar/temporal-polyfill) to put Temporal on the global namespace.
|
|
96
|
-
|
|
97
78
|
```js
|
|
98
79
|
import 'temporal-polyfill/global'
|
|
99
80
|
import { format } from 'temporal-fmt/format';
|
|
100
81
|
import { parse } from 'temporal-fmt/parse';
|
|
101
|
-
|
|
102
|
-
parse(...);
|
|
103
82
|
```
|
|
104
83
|
|
|
105
84
|
### Bring your own
|
|
106
85
|
|
|
107
|
-
|
|
86
|
+
Call `setTemporal()` once, before your first `format()`/`parse()` call:
|
|
108
87
|
|
|
109
88
|
```js
|
|
110
89
|
import { Temporal } from 'temporal-polyfill/full';
|
|
@@ -112,12 +91,12 @@ import { setTemporal } from 'temporal-fmt';
|
|
|
112
91
|
import { format } from 'temporal-fmt/format';
|
|
113
92
|
import { parse } from 'temporal-fmt/parse';
|
|
114
93
|
|
|
115
|
-
setTemporal(Temporal);
|
|
94
|
+
setTemporal(Temporal);
|
|
116
95
|
```
|
|
117
96
|
|
|
118
|
-
`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.
|
|
119
98
|
|
|
120
|
-
|
|
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.
|
|
121
100
|
|
|
122
101
|
## Formatting
|
|
123
102
|
|
|
@@ -125,8 +104,8 @@ Anything that constructs a `Temporal` value from scratch needs this — `parse()
|
|
|
125
104
|
import { format } from 'temporal-fmt/format';
|
|
126
105
|
|
|
127
106
|
const date = Temporal.PlainDate.from('2026-08-04');
|
|
128
|
-
format(date, 'yyyy-MM-dd');
|
|
129
|
-
format(date, 'MMMM d, yyyy');
|
|
107
|
+
format(date, 'yyyy-MM-dd'); // "2026-08-04"
|
|
108
|
+
format(date, 'MMMM d, yyyy'); // "August 4, 2026"
|
|
130
109
|
|
|
131
110
|
const dt = Temporal.PlainDateTime.from('2026-08-04T15:45:30');
|
|
132
111
|
format(dt, "MMM d, yyyy 'at' h:mm a"); // "Aug 4, 2026 at 3:45 PM"
|
|
@@ -135,86 +114,73 @@ const zdt = Temporal.ZonedDateTime.from('2026-08-04T15:45:30-04:00[America/New_Y
|
|
|
135
114
|
format(zdt, 'yyyy-MM-dd HH:mm zzz'); // "2026-08-04 15:45 America/New_York"
|
|
136
115
|
```
|
|
137
116
|
|
|
138
|
-
Wrap literal text in single quotes
|
|
117
|
+
Wrap literal text in single quotes. For a literal single quote in output, use `''`.
|
|
139
118
|
|
|
140
|
-
|
|
119
|
+
Using a token the input type doesn't support — `HH` on a `PlainDate`, say — throws a descriptive error instead of silently emitting `undefined`.
|
|
141
120
|
|
|
142
|
-
- `format(temporal, formatStr, options?)` —
|
|
143
|
-
- `formatToParts(temporal, formatStr, options?)` —
|
|
144
|
-
- `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.
|
|
145
124
|
|
|
146
125
|
## Parsing
|
|
147
126
|
|
|
148
|
-
`parse()` builds a
|
|
127
|
+
`parse()` builds a `PlainDate`, `PlainTime`, `PlainDateTime`, or `ZonedDateTime` depending on which tokens are present:
|
|
149
128
|
|
|
150
129
|
```js
|
|
151
130
|
import { parse } from 'temporal-fmt/parse';
|
|
152
131
|
|
|
153
|
-
parse('yyyy-MM-dd HH:mm', '2026-08-04 15:45');
|
|
154
|
-
parse('yyyy-MM', '2026-08-04T15:45:30');
|
|
155
|
-
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
|
|
156
135
|
```
|
|
157
136
|
|
|
158
|
-
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).
|
|
159
138
|
|
|
160
|
-
|
|
139
|
+
`parse()` also catches contradictions a regex would miss:
|
|
161
140
|
|
|
162
141
|
```js
|
|
163
|
-
parse('EEEE, yyyy-MM-dd', 'Tuesday, 2026-08-04'); // fine
|
|
164
|
-
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
|
|
165
144
|
```
|
|
166
145
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
Three other entry points, same underlying logic, different failure handling:
|
|
146
|
+
Other entry points:
|
|
170
147
|
|
|
171
148
|
- `safeParse(formatStr, input, options?)` — never throws. Returns `{ ok: true, value }` or `{ ok: false, error: TemporalFmtError }`.
|
|
172
|
-
- `tryParse(formatStr, input, options?)` — never throws. Returns the value
|
|
173
|
-
- `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.
|
|
174
151
|
- `compileParser(formatStr, options?)` — pre-compiles a parser for repeated use against the same format string.
|
|
175
152
|
|
|
176
|
-
|
|
153
|
+
### Parsing notes
|
|
177
154
|
|
|
178
|
-
- **`yy` (2-digit year)** follows
|
|
179
|
-
- **`hh`/`h`
|
|
180
|
-
- **`HH`/`H`
|
|
181
|
-
- **`a`
|
|
182
|
-
- **`S
|
|
183
|
-
- **`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.
|
|
184
161
|
|
|
185
162
|
### Ambiguous input and lenient mode
|
|
186
163
|
|
|
187
|
-
By default, `parse()` throws when a glued numeric run
|
|
188
|
-
|
|
189
|
-
```js
|
|
190
|
-
parse('yyyy-Md', '2026-121') // throws — ambiguous
|
|
191
|
-
```
|
|
192
|
-
|
|
193
|
-
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:
|
|
194
165
|
|
|
195
166
|
```js
|
|
167
|
+
parse('yyyy-Md', '2026-121') // throws — ambiguous
|
|
196
168
|
parse('yyyy-Md', '2026-121', { lenient: true }).toString() // '2026-12-01'
|
|
197
169
|
```
|
|
198
170
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
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`).
|
|
202
172
|
|
|
203
173
|
### Offset tokens (`X`/`XX`/`XXX`/`x`/`xx`/`xxx`)
|
|
204
174
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
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.
|
|
175
|
+
Uppercase collapses `+00:00` to `Z`; lowercase never emits `Z`. Single-letter forms drop minutes when zero (`+05`, `+0530`).
|
|
208
176
|
|
|
209
|
-
On parse, an offset token needs a full date and time
|
|
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.
|
|
210
178
|
|
|
211
|
-
Sub-minute
|
|
212
|
-
|
|
213
|
-
Range: `-12:00` to `+14:00`, the IANA-supported range. Out-of-range values throw a descriptive error naming the bound.
|
|
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`.
|
|
214
180
|
|
|
215
181
|
## Locales
|
|
216
182
|
|
|
217
|
-
Pass a BCP 47
|
|
183
|
+
Pass a BCP 47 tag via `options.locale`. Defaults to `'en-US'`.
|
|
218
184
|
|
|
219
185
|
```js
|
|
220
186
|
format(date, 'MMMM d, yyyy', { locale: 'fr-FR' }); // "août 4, 2026"
|
|
@@ -222,30 +188,22 @@ format(date, 'EEEE d MMMM', { locale: 'ar-EG' }); // Arabic weekday/month nam
|
|
|
222
188
|
format(dt, 'h:mm a', { locale: 'ja-JP' }); // "3:45 午後"
|
|
223
189
|
```
|
|
224
190
|
|
|
225
|
-
|
|
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:
|
|
226
192
|
|
|
227
193
|
```js
|
|
228
194
|
const hebrewDate = date.withCalendar('hebrew');
|
|
229
195
|
format(hebrewDate, 'MMMM d, yyyy'); // "Av 21, 5786"
|
|
230
196
|
```
|
|
231
197
|
|
|
232
|
-
Numeric tokens
|
|
233
|
-
|
|
234
|
-
The same locale handling applies to `parse()`:
|
|
235
|
-
|
|
236
|
-
```js
|
|
237
|
-
parse('MMMM d, yyyy', 'août 4, 2026', { locale: 'fr-FR' });
|
|
238
|
-
parse('h:mm a', '3:45 午後', { locale: 'ja-JP' });
|
|
239
|
-
parse('yyyy-MM-dd', '5786-11-21', { locale: 'en-u-ca-hebrew' }); // -u-ca- extension parses into that calendar
|
|
240
|
-
```
|
|
198
|
+
Numeric tokens read directly off the object's fields and never touch `Intl`. Same locale handling applies to `parse()`.
|
|
241
199
|
|
|
242
|
-
**Numeric
|
|
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.
|
|
243
201
|
|
|
244
202
|
### Registering custom vocabulary
|
|
245
203
|
|
|
246
|
-
`Intl` doesn't cover every
|
|
204
|
+
`Intl` doesn't cover every case well — a 13-month Hebrew leap year is the standing example.
|
|
247
205
|
|
|
248
|
-
**`registerLocaleVocab(locale, vocab)`** —
|
|
206
|
+
**`registerLocaleVocab(locale, vocab)`** — months, weekdays, day periods only:
|
|
249
207
|
|
|
250
208
|
```js
|
|
251
209
|
import { registerLocaleVocab } from 'temporal-fmt';
|
|
@@ -257,24 +215,20 @@ registerLocaleVocab('en-u-ca-hebrew-leap', {
|
|
|
257
215
|
weekdayShort: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
|
|
258
216
|
dayPeriod: ['AM', 'PM'],
|
|
259
217
|
});
|
|
260
|
-
|
|
261
|
-
const date = Temporal.PlainDate.from('2026-08-04').withCalendar('hebrew');
|
|
262
|
-
format(date, 'MMMM d, yyyy', { locale: 'en-u-ca-hebrew-leap' }); // "Av 4, 5786" (or similar)
|
|
263
218
|
```
|
|
264
219
|
|
|
265
|
-
**`registerLocale(locale, vocab)`** —
|
|
220
|
+
**`registerLocale(locale, vocab)`** — everything above plus quarters, eras, ordinals, duration units, and relative-time language:
|
|
266
221
|
|
|
267
222
|
```js
|
|
268
223
|
import { registerLocale } from 'temporal-fmt';
|
|
269
224
|
|
|
270
225
|
registerLocale('test-locale-1', {
|
|
271
|
-
// base vocab (required)
|
|
272
226
|
monthLong: [/* 12 entries */],
|
|
273
227
|
monthShort: [/* 12 entries */],
|
|
274
228
|
weekdayLong: [/* 7 entries */],
|
|
275
229
|
weekdayShort: [/* 7 entries */],
|
|
276
230
|
dayPeriod: ['AM', 'PM'],
|
|
277
|
-
// extended vocab
|
|
231
|
+
// optional extended vocab:
|
|
278
232
|
quartersLong: ['First', 'Second', 'Third', 'Fourth'],
|
|
279
233
|
quartersShort: ['Q1', 'Q2', 'Q3', 'Q4'],
|
|
280
234
|
erasLong: ['BCE', 'CE'],
|
|
@@ -285,9 +239,9 @@ registerLocale('test-locale-1', {
|
|
|
285
239
|
});
|
|
286
240
|
```
|
|
287
241
|
|
|
288
|
-
`getLocale(locale)` returns the registered
|
|
242
|
+
`getLocale(locale)` returns the registered vocab or falls back to `Intl`. `hasLocale(locale)` checks without pulling the fallback.
|
|
289
243
|
|
|
290
|
-
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.
|
|
291
245
|
|
|
292
246
|
## Tokens
|
|
293
247
|
|
|
@@ -302,7 +256,7 @@ Validation on both functions is strict: wrong array lengths (12 months, 7 weekda
|
|
|
302
256
|
| `dd` | Two-digit day-of-month, zero-padded | `04` |
|
|
303
257
|
| `d` | One- or two-digit day-of-month | `4` |
|
|
304
258
|
| `do` | Ordinal day-of-month, English suffix. Format-only | `4th` |
|
|
305
|
-
| `EEEE` | Long weekday name, locale-aware. Cross-checked
|
|
259
|
+
| `EEEE` | Long weekday name, locale-aware. Cross-checked on parse | `Tuesday` |
|
|
306
260
|
| `EEE` | Short weekday name, locale-aware. Cross-checked on parse | `Tue` |
|
|
307
261
|
| `HH` | Two-digit hour, 24-hour, zero-padded | `15` |
|
|
308
262
|
| `H` | One- or two-digit hour, 24-hour | `15` |
|
|
@@ -312,9 +266,9 @@ Validation on both functions is strict: wrong array lengths (12 months, 7 weekda
|
|
|
312
266
|
| `m` | One- or two-digit minute | `45` |
|
|
313
267
|
| `ss` | Two-digit second, zero-padded | `30` |
|
|
314
268
|
| `s` | One- or two-digit second | `30` |
|
|
315
|
-
| `S
|
|
269
|
+
| `S`…`SSSSSSSSS` | Fractional second, 1–9 digits (tenths through nanoseconds) | `SSS` → `000` |
|
|
316
270
|
| `a` | Day period, locale-aware, case-insensitive on parse | `PM` |
|
|
317
|
-
| `zzz` | IANA time zone
|
|
271
|
+
| `zzz` | IANA time zone ID, or fixed offset. Needs full date+time on parse | `America/New_York` |
|
|
318
272
|
| `zzzz` | Localized long time zone name. Format-only | `Eastern Standard Time` |
|
|
319
273
|
| `z` | Localized short time zone name. Format-only | `EST` |
|
|
320
274
|
| `X` | UTC offset, short, `Z` for UTC | `+05` / `+0530` / `Z` |
|
|
@@ -323,7 +277,7 @@ Validation on both functions is strict: wrong array lengths (12 months, 7 weekda
|
|
|
323
277
|
| `x` | Same as `X`, never `Z` | `+05` / `+0530` / `+00` |
|
|
324
278
|
| `xx` | Same as `XX`, never `Z` | `+0500` / `+0000` |
|
|
325
279
|
| `xxx` | Same as `XXX`, never `Z` | `+05:00` / `+00:00` |
|
|
326
|
-
| `Q` | Quarter
|
|
280
|
+
| `Q` | Quarter digit (1–4). Cross-checked against month on parse | `3` |
|
|
327
281
|
| `QQQ` | Quarter with "Q" prefix. Cross-checked on parse | `Q3` |
|
|
328
282
|
| `ww` | ISO 8601 week number (01–53). Format-only | `32` |
|
|
329
283
|
| `RRRR` | ISO 8601 week-numbering year. Format-only | `2026` |
|
|
@@ -337,21 +291,27 @@ Validation on both functions is strict: wrong array lengths (12 months, 7 weekda
|
|
|
337
291
|
| `GGGG` | Long era name, locale-aware. Format-only | `Anno Domini` |
|
|
338
292
|
| `G` | Short era name, locale-aware. Format-only | `AD` |
|
|
339
293
|
|
|
340
|
-
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).
|
|
295
|
+
|
|
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.
|
|
341
297
|
|
|
342
|
-
**`
|
|
298
|
+
**`Q`/`QQQ`** format and parse, cross-checking against any month/date tokens in the same string.
|
|
343
299
|
|
|
344
|
-
**`
|
|
300
|
+
**`ww`/`RRRR`** are format-only. `RRRR` is the ISO week-numbering year, not the calendar year — they diverge at year boundaries:
|
|
345
301
|
|
|
346
|
-
|
|
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
|
+
```
|
|
347
307
|
|
|
348
|
-
**`LLLL`/`LLL`/`cccc`/`ccc`** are
|
|
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.
|
|
349
309
|
|
|
350
310
|
## Duration formatting
|
|
351
311
|
|
|
352
|
-
`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 }`.
|
|
353
313
|
|
|
354
|
-
Each unit has three forms
|
|
314
|
+
Each unit has three forms:
|
|
355
315
|
|
|
356
316
|
| Token | Form | Example |
|
|
357
317
|
|-------|------|---------|
|
|
@@ -364,46 +324,41 @@ Each unit has three forms, increasing in verbosity:
|
|
|
364
324
|
| `s` / `ss` / `sss` | numeric / short / long — seconds | `2` / `2s` / `2 seconds` |
|
|
365
325
|
| `S` / `SS` / `SSS` | numeric / short / long — milliseconds | `2` / `2ms` / `2 milliseconds` |
|
|
366
326
|
|
|
367
|
-
Short and long forms are plural-aware
|
|
327
|
+
Short and long forms are plural-aware.
|
|
368
328
|
|
|
369
329
|
```js
|
|
370
330
|
import { formatDuration } from 'temporal-fmt';
|
|
371
331
|
|
|
372
|
-
formatDuration({ years: 2, months: 3 }, 'yyy ooo')
|
|
373
|
-
formatDuration({ hours: 2, minutes: 30 }, 'hhh mmm')
|
|
374
|
-
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"
|
|
375
335
|
```
|
|
376
336
|
|
|
377
|
-
|
|
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.
|
|
378
338
|
|
|
379
|
-
|
|
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"`).
|
|
380
340
|
|
|
381
341
|
```js
|
|
382
342
|
formatDuration({ hours: 2, minutes: 30 }, 'hhh mmm', { locale: 'fr-FR' }) // "2 heures 30 minutes"
|
|
383
|
-
formatDuration({ hours: 2, minutes: 30 }, 'hhh mmm', { locale: 'es-ES' }) // "2 horas 30 minutos"
|
|
384
343
|
formatDuration({ hours: 2, minutes: 30 }, 'hhh mmm', { locale: 'de-DE' }) // "2 Stunden 30 Minuten"
|
|
385
344
|
```
|
|
386
345
|
|
|
387
|
-
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.
|
|
388
|
-
|
|
389
346
|
### Other duration functions
|
|
390
347
|
|
|
391
|
-
- `formatDurationToParts(duration, formatStr, options?)` —
|
|
392
|
-
- `parseDuration(input, formatStr, options?)` —
|
|
393
|
-
- `parseISODuration(input)` / `formatISODuration(duration)` —
|
|
394
|
-
- `balanceDuration(duration)` — normalizes fields
|
|
395
|
-
- `totalDuration(duration, unit)` — sums
|
|
396
|
-
- `compareDuration(a, b)` — `-1`/`0`/`1
|
|
397
|
-
- `addDuration(a, b)` / `subtractDuration(a, b)
|
|
398
|
-
- `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).
|
|
399
356
|
|
|
400
357
|
## Relative time
|
|
401
358
|
|
|
402
|
-
Two different things live under this heading — pick based on what you want back.
|
|
403
|
-
|
|
404
359
|
### `formatDistance` — "3 days ago"
|
|
405
360
|
|
|
406
|
-
`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`.
|
|
407
362
|
|
|
408
363
|
```js
|
|
409
364
|
import { formatDistance } from 'temporal-fmt';
|
|
@@ -411,126 +366,110 @@ import { formatDistance } from 'temporal-fmt';
|
|
|
411
366
|
const today = Temporal.PlainDate.from('2026-08-04');
|
|
412
367
|
const yesterday = Temporal.PlainDate.from('2026-08-03');
|
|
413
368
|
|
|
414
|
-
formatDistance(today, yesterday)
|
|
369
|
+
formatDistance(today, yesterday) // "yesterday"
|
|
415
370
|
formatDistance(today, yesterday, { numeric: 'always' }) // "1 day ago"
|
|
416
371
|
formatDistance(today, today) // "now"
|
|
417
372
|
formatDistance(today, today.add({ days: 2 }), { locale: 'fr-FR' }) // "dans 2 jours"
|
|
418
373
|
```
|
|
419
374
|
|
|
420
|
-
|
|
375
|
+
Direction: `diff = date1 - date2`. Positive → "in X". Negative → "X ago".
|
|
421
376
|
|
|
422
|
-
|
|
377
|
+
Default unit-selection cutoffs:
|
|
423
378
|
|
|
424
379
|
| abs(diff) | Unit | Default cutoff |
|
|
425
|
-
|
|
380
|
+
|-----------|------|----------------|
|
|
426
381
|
| < 60 seconds | seconds | `seconds: 60` |
|
|
427
382
|
| < 60 minutes | minutes | `minutes: 60` |
|
|
428
383
|
| < 24 hours | hours | `hours: 24` |
|
|
429
384
|
| < 30 days | days | `days: 30` |
|
|
430
|
-
| < 365 days | months | `months: 365` (
|
|
385
|
+
| < 365 days | months | `months: 365` (in days) |
|
|
431
386
|
| otherwise | years | — |
|
|
432
387
|
|
|
433
|
-
|
|
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.
|
|
434
389
|
|
|
435
|
-
|
|
436
|
-
formatDistance(in5d, today) // "in 5 days" (default cutoffs)
|
|
437
|
-
formatDistance(in14d, today, { cutoffs: { days: 10 } }) // "this month" (14d > 10d)
|
|
438
|
-
formatDistance(in200d, today, { cutoffs: { months: 100 } }) // "this year" (200d > 100d)
|
|
439
|
-
formatDistance(in30d, today) // "next month" (right at the default 30d boundary)
|
|
440
|
-
```
|
|
390
|
+
Accepts `PlainDate`, `PlainDateTime`, or `ZonedDateTime`. A `PlainDate` is treated as midnight when diffed against a `PlainDateTime`. Throws on `PlainTime` and partial-date shapes.
|
|
441
391
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
`formatDistanceToNow(date, options?)` is `formatDistance(date, now)`, reading the system clock at call time so you don't have to build the reference value yourself:
|
|
392
|
+
`formatDistanceToNow(date, options?)` reads the system clock at call time:
|
|
445
393
|
|
|
446
394
|
```js
|
|
447
395
|
import { formatDistanceToNow } from 'temporal-fmt';
|
|
448
396
|
|
|
449
397
|
formatDistanceToNow(threeHoursAgo) // "3 hours ago"
|
|
450
|
-
formatDistanceToNow(tomorrow) // "in 1 day"
|
|
398
|
+
formatDistanceToNow(tomorrow) // "in 1 day"
|
|
451
399
|
```
|
|
452
400
|
|
|
453
|
-
|
|
401
|
+
Reference is captured fresh on every call, never cached.
|
|
454
402
|
|
|
455
|
-
### `formatRelative` / `formatRelativeToNow`
|
|
403
|
+
### `formatRelative` / `formatRelativeToNow`
|
|
456
404
|
|
|
457
|
-
|
|
405
|
+
Returns calendar-relative labels ("yesterday", "last week") rather than numeric distance. `formatRelativeToNow(date, options?)` is `formatRelative(date, now)`.
|
|
458
406
|
|
|
459
407
|
## Natural-language date parsing
|
|
460
408
|
|
|
461
|
-
`parseRelative(input, referenceDate, options?)` resolves
|
|
462
|
-
|
|
463
|
-
Supported phrase classes:
|
|
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.
|
|
464
410
|
|
|
465
|
-
|
|
466
|
-
- **relative day offsets**: "today", "tomorrow", "yesterday"
|
|
467
|
-
- **relative unit offsets**: "in 3 days", "2 weeks ago", "in 1 month", "1 year ago"
|
|
468
|
-
- **month-day without year**: "March 5th", "Aug 4" (resolved to the next occurrence)
|
|
411
|
+
Supported:
|
|
469
412
|
|
|
470
|
-
|
|
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)
|
|
471
417
|
|
|
472
|
-
| Phrase
|
|
473
|
-
|
|
418
|
+
| Phrase | en | es | fr | de |
|
|
419
|
+
|--------|----|----|----|----|
|
|
474
420
|
| today | `today` | `hoy` | `aujourd'hui` | `heute` |
|
|
475
421
|
| tomorrow | `tomorrow` | `mañana` | `demain` | `morgen` |
|
|
476
422
|
| yesterday | `yesterday` | `ayer` | `hier` | `gestern` |
|
|
477
|
-
| next Tuesday | `next Tuesday` | `el próximo martes`
|
|
423
|
+
| next Tuesday | `next Tuesday` | `el próximo martes` | `mardi prochain` | `nächsten Dienstag` |
|
|
478
424
|
| last Tuesday | `last Tuesday` | `el martes pasado` | `mardi dernier` | `letzten Dienstag` |
|
|
479
425
|
| this Wednesday | `this Wednesday` | `este miércoles` | `ce mercredi` | `diesen Mittwoch` |
|
|
480
426
|
| in 3 days | `in 3 days` | `en 3 días` | `dans 3 jours` | `in 3 Tagen` |
|
|
481
427
|
| 2 weeks ago | `2 weeks ago` | `hace 2 semanas` | `il y a 2 semaines` | `vor 2 Wochen` |
|
|
482
428
|
| March 5 | `March 5th` | `5 de marzo` | `5 mars` | `5. März` |
|
|
483
429
|
|
|
484
|
-
Diacritics are stripped before matching
|
|
430
|
+
Diacritics are stripped before matching, so `"miercoles"` matches `"miércoles"`. German umlaut transliterations are expanded (`ä`→`ae`, etc.).
|
|
485
431
|
|
|
486
432
|
```js
|
|
487
433
|
import { parseRelative } from 'temporal-fmt';
|
|
488
434
|
|
|
489
435
|
const today = Temporal.PlainDate.from('2026-08-04'); // Tuesday
|
|
490
|
-
parseRelative('today', today).toString()
|
|
491
|
-
parseRelative('
|
|
492
|
-
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
|
|
493
438
|
parseRelative('last Friday', today).toString() // '2026-07-31'
|
|
494
439
|
parseRelative('in 3 days', today).toString() // '2026-08-07'
|
|
495
|
-
parseRelative('
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
parseRelative('
|
|
499
|
-
parseRelative('
|
|
500
|
-
parseRelative('demain', today, { locale: 'fr-FR' }).toString() // '2026-08-05'
|
|
501
|
-
parseRelative('mardi prochain', today, { locale: 'fr-FR' }).toString() // '2026-08-11'
|
|
502
|
-
parseRelative('morgen', today, { locale: 'de-DE' }).toString() // '2026-08-05'
|
|
503
|
-
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'
|
|
504
445
|
```
|
|
505
446
|
|
|
506
|
-
**
|
|
447
|
+
**Documented ambiguous-case choices:**
|
|
507
448
|
|
|
508
|
-
-
|
|
509
|
-
-
|
|
510
|
-
-
|
|
511
|
-
-
|
|
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()`.
|
|
512
453
|
|
|
513
|
-
|
|
454
|
+
This "next X on X = 7 days" convention holds across all four supported languages by design.
|
|
514
455
|
|
|
515
|
-
`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`.
|
|
516
457
|
|
|
517
458
|
### Typo tolerance (`{ fuzzy: true }`)
|
|
518
459
|
|
|
519
|
-
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:
|
|
520
|
-
|
|
521
460
|
```js
|
|
522
461
|
parseRelative('tommorow', today, { fuzzy: true }).toString() // '2026-08-05'
|
|
523
462
|
parseRelative('next tuesady', today, { fuzzy: true }).toString() // '2026-08-11'
|
|
524
|
-
parseRelative('tommorow', today); // throws
|
|
463
|
+
parseRelative('tommorow', today); // throws
|
|
525
464
|
```
|
|
526
465
|
|
|
527
|
-
|
|
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.
|
|
528
467
|
|
|
529
|
-
Numbers are never
|
|
468
|
+
Numbers are never corrected. `"5 dyas"` → corrects to `"5 days"` → still throws the usual past-or-future ambiguity error.
|
|
530
469
|
|
|
531
|
-
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.
|
|
532
471
|
|
|
533
|
-
|
|
472
|
+
`registerRelativeGrammar(grammar)` registers a new language. `listRegisteredGrammars()` lists what's currently registered.
|
|
534
473
|
|
|
535
474
|
## Date arithmetic, comparison, and rounding
|
|
536
475
|
|
|
@@ -544,9 +483,9 @@ subtract(date, 1, 'months');
|
|
|
544
483
|
difference(a, b, 'hours');
|
|
545
484
|
```
|
|
546
485
|
|
|
547
|
-
- `add(value, amount, unit)` / `subtract(value, amount, unit)` — `unit
|
|
548
|
-
- Per-unit wrappers
|
|
549
|
-
- `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.
|
|
550
489
|
- Per-unit wrappers: `differenceInYears`, `differenceInMonths`, `differenceInWeeks`, `differenceInDays`, `differenceInHours`, `differenceInMinutes`, `differenceInSeconds`, `differenceInMilliseconds`.
|
|
551
490
|
|
|
552
491
|
### Comparison
|
|
@@ -554,7 +493,7 @@ difference(a, b, 'hours');
|
|
|
554
493
|
```js
|
|
555
494
|
import { compare, isBefore, isSameDay, isWeekend } from 'temporal-fmt';
|
|
556
495
|
|
|
557
|
-
compare(a, b);
|
|
496
|
+
compare(a, b); // -1 / 0 / 1
|
|
558
497
|
isBefore(a, b);
|
|
559
498
|
isSameDay(a, b);
|
|
560
499
|
isWeekend(date);
|
|
@@ -563,13 +502,13 @@ isWeekend(date);
|
|
|
563
502
|
- `compare(a, b)` → `-1`/`0`/`1`.
|
|
564
503
|
- `isEqual`, `isBefore`, `isAfter`.
|
|
565
504
|
- `min(values)`, `max(values)`, `clamp(value, lo, hi)`, `isBetween(value, lo, hi)`.
|
|
566
|
-
-
|
|
505
|
+
- `isToday`, `isTomorrow`, `isYesterday`, `isSameDay`, `isSameWeek`, `isSameMonth`, `isSameQuarter`, `isSameYear`, `isWeekend`, `isWeekday`.
|
|
567
506
|
|
|
568
507
|
### Rounding
|
|
569
508
|
|
|
570
|
-
- `round(value, options)
|
|
509
|
+
- `round(value, options)`.
|
|
571
510
|
- `floor(value, unit, increment?)`, `ceil(value, unit, increment?)`, `truncate(value, unit, increment?)`.
|
|
572
|
-
- `roundDuration(duration, options)` —
|
|
511
|
+
- `roundDuration(duration, options)` — throws for calendar-bound units (months, years) without a `relativeTo` reference.
|
|
573
512
|
|
|
574
513
|
### Calendar utilities
|
|
575
514
|
|
|
@@ -582,24 +521,22 @@ getQuarter(date);
|
|
|
582
521
|
```
|
|
583
522
|
|
|
584
523
|
- `daysInMonth(value)`, `daysInYear(value)`, `monthsInYear(value)`.
|
|
585
|
-
- `isLeapYear(value)`, `isLeapMonth(value)` (
|
|
524
|
+
- `isLeapYear(value)`, `isLeapMonth(value)` (always `false` in Gregorian).
|
|
586
525
|
- `dayOfYear(value)`, `weekOfYear(value)`, `weekYear(value)`.
|
|
587
526
|
- `getQuarter(value, options?)`, `getMonth(value)`, `getWeekday(value)`.
|
|
588
|
-
- `startOf(value, unit)` / `endOf(value, unit)` — `unit
|
|
527
|
+
- `startOf(value, unit)` / `endOf(value, unit)` — `unit`: `'day' | 'month' | 'year' | 'hour' | 'minute' | 'second'`.
|
|
589
528
|
|
|
590
|
-
`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:
|
|
591
530
|
|
|
592
531
|
```js
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
getQuarter({ month: 8 }); // 3 — calendar quarter (default)
|
|
596
|
-
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
|
|
597
534
|
getQuarter({ month: 8 }, { startMonth: 10 }); // 4 — fiscal year starting October (Apple's FY)
|
|
598
535
|
```
|
|
599
536
|
|
|
600
|
-
`startMonth` must be
|
|
537
|
+
`startMonth` must be 1–12; anything else throws.
|
|
601
538
|
|
|
602
|
-
**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:
|
|
603
540
|
|
|
604
541
|
```js
|
|
605
542
|
const pd = Temporal.PlainDate.from('5784-05-10[u-ca=hebrew]');
|
|
@@ -607,7 +544,7 @@ pd.daysInMonth; // 30 (Sivan)
|
|
|
607
544
|
pd.monthsInYear; // 13 (leap year)
|
|
608
545
|
```
|
|
609
546
|
|
|
610
|
-
Locale-aware
|
|
547
|
+
Locale-aware formatting tokens (`MMMM`, `MMM`, `EEEE`, `EEE`, `a`) go through `Intl.DateTimeFormat` and do respect `calendarId`:
|
|
611
548
|
|
|
612
549
|
```js
|
|
613
550
|
format(Temporal.PlainDate.from('5784-05-10[u-ca=hebrew]'), 'MMMM d, yyyy'); // "Sivan 10, 5784"
|
|
@@ -624,14 +561,14 @@ overlaps(ivA, ivB);
|
|
|
624
561
|
formatRange(iv, 'MMM d');
|
|
625
562
|
```
|
|
626
563
|
|
|
627
|
-
- `interval(start, end, bounds?)` — `bounds
|
|
564
|
+
- `interval(start, end, bounds?)` — `bounds`: `'closed'` (default) | `'open'` | `'half-open-start'` | `'half-open-end'`.
|
|
628
565
|
- `contains(iv, value)`, `overlaps(a, b)`, `intersects(a, b)`.
|
|
629
|
-
- `isBefore(a, b)` / `isAfter(a, b)` —
|
|
630
|
-
- `intersection(a, b)`, `union(a, b)` — return `null` when
|
|
631
|
-
- `difference(a, b)` / `subtract(a, b)` — imported as `intervalDifference`/`intervalSubtract` from the main entry
|
|
632
|
-
- `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.
|
|
633
570
|
- `splitInterval(iv, n)` — splits one interval into `n` equal sub-intervals.
|
|
634
|
-
- `formatRange(iv, formatStr, options?)` / `formatRangeToParts(iv, formatStr, options?)` —
|
|
571
|
+
- `formatRange(iv, formatStr, options?)` / `formatRangeToParts(iv, formatStr, options?)` — uses `Intl.DateTimeFormat.formatRange` where available.
|
|
635
572
|
|
|
636
573
|
## Recurrence
|
|
637
574
|
|
|
@@ -640,19 +577,19 @@ import { recurrence, take, between } from 'temporal-fmt';
|
|
|
640
577
|
|
|
641
578
|
const rule = { freq: 'weekly', interval: 1, count: 10 };
|
|
642
579
|
const iter = recurrence(startDate, rule);
|
|
643
|
-
take(iter, 5);
|
|
644
|
-
between(startDate, rule, rangeStart, rangeEnd);
|
|
580
|
+
take(iter, 5);
|
|
581
|
+
between(startDate, rule, rangeStart, rangeEnd);
|
|
645
582
|
```
|
|
646
583
|
|
|
647
|
-
- `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).
|
|
648
585
|
- `take(iter, n)` — collects the next `n` occurrences.
|
|
649
586
|
- `skip(iter, n)` — advances past `n` occurrences.
|
|
650
|
-
- `between(start, rule, rangeStart, rangeEnd)` — all occurrences within a range
|
|
651
|
-
- `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.
|
|
652
589
|
|
|
653
590
|
## Business calendars and holidays
|
|
654
591
|
|
|
655
|
-
|
|
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.
|
|
656
593
|
|
|
657
594
|
```js
|
|
658
595
|
import { createBusinessCalendar, isBusinessDay, addBusinessDays } from 'temporal-fmt';
|
|
@@ -662,7 +599,7 @@ isBusinessDay(cal, someDate);
|
|
|
662
599
|
addBusinessDays(cal, someDate, 5);
|
|
663
600
|
```
|
|
664
601
|
|
|
665
|
-
- `createBusinessCalendar(options?)
|
|
602
|
+
- `createBusinessCalendar(options?)`.
|
|
666
603
|
- `isBusinessDay(cal, value)`.
|
|
667
604
|
- `addBusinessDays(cal, value, n)` / `subtractBusinessDays(cal, value, n)`.
|
|
668
605
|
- `differenceInBusinessDays(cal, a, b)`.
|
|
@@ -680,12 +617,12 @@ holidays.isHoliday(someDate);
|
|
|
680
617
|
nextHoliday(holidays, someDate);
|
|
681
618
|
```
|
|
682
619
|
|
|
683
|
-
- `createHolidayCalendar(specs)` — each spec is
|
|
684
|
-
- The returned calendar has `.isHoliday(value)` and `.holidaysBetween(start, end)` as methods
|
|
685
|
-
- `nextHoliday(cal, value)` / `previousHoliday(cal, value)` —
|
|
686
|
-
- `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()`.
|
|
687
624
|
|
|
688
|
-
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.
|
|
689
626
|
|
|
690
627
|
## Time zones
|
|
691
628
|
|
|
@@ -699,10 +636,10 @@ getTransitions(rangeStart, rangeEnd);
|
|
|
699
636
|
|
|
700
637
|
- `resolveZoned(fields, timeZone, options?)` — constructs a `ZonedDateTime` with an explicit disambiguation mode for gaps/overlaps: `'compatible' | 'earlier' | 'later' | 'reject'`.
|
|
701
638
|
- `getTimeZone(value)`, `getOffset(value)`, `getOffsetNanoseconds(value)`.
|
|
702
|
-
- `isDST(value)` —
|
|
703
|
-
- `getNextTransition(value)` / `getPreviousTransition(value)
|
|
704
|
-
- `getTransitions(start, end)` — every transition within a range.
|
|
705
|
-
- `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.
|
|
706
643
|
|
|
707
644
|
## Serialization
|
|
708
645
|
|
|
@@ -715,16 +652,14 @@ fromUnixMilliseconds(1754321130000);
|
|
|
715
652
|
```
|
|
716
653
|
|
|
717
654
|
- `parseISO(input)` / `formatISO(value)` — ISO 8601.
|
|
718
|
-
- `parseRFC3339(input)` / `formatRFC3339(value)` — RFC 3339
|
|
719
|
-
- `parseRFC2822(input)` / `formatRFC2822(value)` — RFC 2822 (email/HTTP
|
|
720
|
-
- `parseHTTPDate(input)` / `formatHTTPDate(value)` —
|
|
721
|
-
- `parseSQL(input)` / `formatSQL(value)` — SQL `DATETIME`/`TIMESTAMP
|
|
722
|
-
- 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`.
|
|
723
660
|
|
|
724
661
|
## Introspection and the analyzer
|
|
725
662
|
|
|
726
|
-
Every token carries structured metadata, and there's a small analysis layer over format strings themselves.
|
|
727
|
-
|
|
728
663
|
```js
|
|
729
664
|
import { tokenInfo, listTokens, analyzeFormat, explainFormat, isValidFormat } from 'temporal-fmt';
|
|
730
665
|
|
|
@@ -745,66 +680,75 @@ analyzeFormat("MMMM d, yyyy 'at' h:mm a");
|
|
|
745
680
|
// calendarSensitive, timezoneSensitive, ambiguous, roundTripSafe, warnings }
|
|
746
681
|
```
|
|
747
682
|
|
|
748
|
-
- `analyzeFormat(formatStr)` —
|
|
749
|
-
- `explainFormat(formatStr)` —
|
|
750
|
-
- `tokenInfo(name)` — metadata for one token, or `undefined` if
|
|
751
|
-
- `listTokens()` —
|
|
752
|
-
- `isValidFormat(formatStr)` — `true`
|
|
753
|
-
- `validateFormat(formatStr)` — throws on
|
|
754
|
-
- `tokenizeFormat(formatStr)` —
|
|
755
|
-
- `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.
|
|
756
691
|
|
|
757
|
-
`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`).
|
|
758
693
|
|
|
759
|
-
**Round-trip safety
|
|
694
|
+
**Round-trip safety by token family:**
|
|
760
695
|
|
|
761
696
|
| Family | Tokens | Round-trip safe | Why |
|
|
762
|
-
|
|
697
|
+
|--------|--------|-----------------|-----|
|
|
763
698
|
| Year | `yyyy` | yes | Preserves sign for BCE. |
|
|
764
|
-
| Year | `yy` | no | Century is lost —
|
|
765
|
-
| 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. |
|
|
766
701
|
| Day | `dd`, `d` | yes | |
|
|
767
|
-
| Day | `do` | no | Format-only
|
|
768
|
-
| Weekday | `EEEE`, `EEE` | yes | Cross-checked
|
|
769
|
-
| 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. |
|
|
770
705
|
|
|
771
706
|
## Typed errors
|
|
772
707
|
|
|
773
|
-
|
|
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'`:
|
|
774
713
|
|
|
775
|
-
|
|
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.
|
|
776
720
|
|
|
777
721
|
| Class | Code | Fires when |
|
|
778
722
|
|-------|------|------------|
|
|
779
723
|
| `FormatSyntaxError` | `FORMAT_SYNTAX_ERROR` | Unterminated quote, format string over the length cap, other syntax issues. |
|
|
780
724
|
| `UnknownTokenError` | `UNKNOWN_TOKEN` | An unrecognized letter run was encountered. |
|
|
781
725
|
| `ParseMismatchError` | `PARSE_MISMATCH` | Input doesn't match the format's shape — generic catch-all. |
|
|
782
|
-
| `InvalidDateError` | `INVALID_DATE` |
|
|
726
|
+
| `InvalidDateError` | `INVALID_DATE` | Valid structure but nonexistent date (Feb 30), or a weekday/quarter that contradicts the date. |
|
|
783
727
|
| `InvalidTimeError` | `INVALID_TIME` | Time out of range (hour 25, etc). |
|
|
784
|
-
| `InvalidOffsetError` | `INVALID_OFFSET` | Offset malformed or outside
|
|
728
|
+
| `InvalidOffsetError` | `INVALID_OFFSET` | Offset malformed or outside `-12:00` to `+14:00`. |
|
|
785
729
|
| `InvalidTimeZoneError` | `INVALID_TIME_ZONE` | Not a recognized IANA name or fixed offset. |
|
|
786
730
|
| `InvalidCalendarError` | `INVALID_CALENDAR` | Unsupported calendar. |
|
|
787
|
-
| `AmbiguousInputError` | `AMBIGUOUS_INPUT` | Input has more than one valid reading
|
|
731
|
+
| `AmbiguousInputError` | `AMBIGUOUS_INPUT` | Input has more than one valid reading. |
|
|
788
732
|
| `InvalidLocaleError` | `INVALID_LOCALE` | Not a valid BCP-47 tag, or an unsupported numbering system. |
|
|
789
|
-
| `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. |
|
|
790
734
|
|
|
791
|
-
###
|
|
735
|
+
### Common error messages
|
|
792
736
|
|
|
793
|
-
**"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.
|
|
794
738
|
|
|
795
|
-
**"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.
|
|
796
740
|
|
|
797
|
-
**"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.
|
|
798
742
|
|
|
799
|
-
**"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.
|
|
800
744
|
|
|
801
|
-
**"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 }`.
|
|
802
746
|
|
|
803
|
-
**"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.
|
|
804
748
|
|
|
805
|
-
**"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`.
|
|
806
750
|
|
|
807
|
-
**"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.
|
|
808
752
|
|
|
809
753
|
## Type guards
|
|
810
754
|
|
|
@@ -812,19 +756,19 @@ All subclasses inherit structured fields from `TemporalFmtError`: `code`, `input
|
|
|
812
756
|
import { isPlainDate, assertZonedDateTime } from 'temporal-fmt';
|
|
813
757
|
|
|
814
758
|
if (isPlainDate(value)) { /* narrowed */ }
|
|
815
|
-
assertZonedDateTime(value); // throws descriptively if
|
|
759
|
+
assertZonedDateTime(value); // throws descriptively if not
|
|
816
760
|
```
|
|
817
761
|
|
|
818
|
-
- `isTemporal
|
|
819
|
-
- `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.
|
|
820
764
|
|
|
821
765
|
## Config
|
|
822
766
|
|
|
823
|
-
`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.
|
|
824
768
|
|
|
825
769
|
## Numbering systems
|
|
826
770
|
|
|
827
|
-
|
|
771
|
+
Numeric tokens output Western digits by default. Override per call:
|
|
828
772
|
|
|
829
773
|
```js
|
|
830
774
|
import { format, parse } from 'temporal-fmt';
|
|
@@ -833,96 +777,52 @@ format(date, 'yyyy-MM-dd', { numberingSystem: 'arab' }); /
|
|
|
833
777
|
parse('yyyy-MM-dd', '٢٠٢٦-٠٨-٠٤', { parseNumberingSystem: 'arab' }).toString(); // '2026-08-04'
|
|
834
778
|
```
|
|
835
779
|
|
|
836
|
-
|
|
780
|
+
`numberingSystem` and `parseNumberingSystem` are separate options on purpose — you might want localized digits in output without expecting them back on input.
|
|
837
781
|
|
|
838
|
-
|
|
782
|
+
Pass `'auto'` to use whichever numeral system the locale itself uses:
|
|
839
783
|
|
|
840
784
|
```js
|
|
841
|
-
format(date, 'yyyy-MM-dd', { numberingSystem: 'auto', locale: 'ar-EG' }); // "٢٠٢٦-٠٨-٠٤"
|
|
785
|
+
format(date, 'yyyy-MM-dd', { numberingSystem: 'auto', locale: 'ar-EG' }); // "٢٠٢٦-٠٨-٠٤"
|
|
842
786
|
format(date, 'yyyy-MM-dd', { numberingSystem: 'auto', locale: 'bn-BD' }); // "২০২৬-০৮-০৪"
|
|
843
|
-
format(date, 'yyyy-MM-dd', { numberingSystem: 'auto' }); // "2026-08-04" — en-US
|
|
844
|
-
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
|
|
845
788
|
```
|
|
846
789
|
|
|
847
|
-
|
|
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.
|
|
848
791
|
|
|
849
|
-
|
|
792
|
+
Direct conversion utilities:
|
|
850
793
|
|
|
851
794
|
```js
|
|
852
795
|
import { convertDigits, convertDigitsToAscii } from 'temporal-fmt';
|
|
853
796
|
|
|
854
|
-
convertDigits('2026', 'arab');
|
|
797
|
+
convertDigits('2026', 'arab'); // "٢٠٢٦"
|
|
855
798
|
convertDigitsToAscii('٢٠٢٦', 'arab'); // "2026"
|
|
856
799
|
```
|
|
857
800
|
|
|
858
|
-
- `convertDigits(s, system)`
|
|
859
|
-
- `
|
|
860
|
-
- `
|
|
861
|
-
- `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'`.
|
|
862
804
|
|
|
863
805
|
## Extending with custom tokens
|
|
864
806
|
|
|
865
|
-
`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.
|
|
866
808
|
|
|
867
809
|
## IDE tooling data
|
|
868
810
|
|
|
869
|
-
|
|
811
|
+
These functions feed editor tooling rather than application code:
|
|
870
812
|
|
|
871
813
|
- `getAutocompleteData()` — token autocomplete entries, grouped by family.
|
|
872
814
|
- `getHoverDocs()` — per-token hover documentation.
|
|
873
|
-
- `getInlineDiagnostics(formatStr)` — diagnostics with
|
|
874
|
-
- `previewFormat(formatStr, sample?)` —
|
|
875
|
-
- `getDocUrl(tokenName)` —
|
|
876
|
-
- `DAYJS_TO_TEMPORAL_FMT` / `DATE_FNS_TO_TEMPORAL_FMT` — token conversion
|
|
877
|
-
|
|
878
|
-
## CLI
|
|
879
|
-
|
|
880
|
-
The CLI ships in this package (`scripts/cli.mjs`) and reads/writes stdin/stdout. Run it via `npm run cli` inside a checkout of this repo, or `node scripts/cli.mjs` directly. Called with a subcommand it runs once and exits, same as any Unix tool — fine for scripts and CI:
|
|
881
|
-
|
|
882
|
-
On Node 26+ the CLI uses native `Temporal` and needs nothing extra. Below that, it looks for a globally-installed [`temporal-polyfill`](https://github.com/fullcalendar/temporal-polyfill) and exits with an install hint if it can't find one — `temporal-fmt` itself ships with zero dependencies, so this one's on you: `npm install temporal-polyfill`.
|
|
883
|
-
|
|
884
|
-
```sh
|
|
885
|
-
temporal-fmt format "2026-08-04T15:45:30" "yyyy-MM-dd HH:mm:ss"
|
|
886
|
-
temporal-fmt parse "yyyy-MM-dd" "2026-08-04"
|
|
887
|
-
temporal-fmt inspect "MMMM d, yyyy 'at' h:mm a"
|
|
888
|
-
temporal-fmt validate "yyyy-MM-dd HH:mm:ss"
|
|
889
|
-
temporal-fmt translate dayjs "YYYY-MM-DD HH:mm:ss"
|
|
890
|
-
```
|
|
891
|
-
|
|
892
|
-
| Subcommand | What it does |
|
|
893
|
-
|------------|---------------|
|
|
894
|
-
| `format <iso-input> <format-string> [--locale=LOCALE]` | Formats an ISO date/time input against the given format string. |
|
|
895
|
-
| `parse <format-string> <input> [--locale=LOCALE] [--lenient]` | Parses input against a format string, prints the resulting ISO value. |
|
|
896
|
-
| `inspect <format-string>` | Prints `explainFormat`'s report on a format string. |
|
|
897
|
-
| `validate <format-string>` | Prints `valid` or `invalid`. |
|
|
898
|
-
| `translate <source-lib> <format-string>` | Translates a Day.js or date-fns format string to `temporal-fmt` tokens. |
|
|
899
|
-
|
|
900
|
-
`translate` is implemented in-repo (`src/codemod.ts`) against the same token tables the IDE tooling data uses — no external package, no runtime dependency beyond this library itself. It throws on tokens with no `temporal-fmt` equivalent (`Do`/`P`/etc. — see the [migration table](#token-mapping)) rather than guessing.
|
|
901
|
-
|
|
902
|
-
### Interactive mode
|
|
903
|
-
|
|
904
|
-
Run `temporal-fmt` with no arguments to start a REPL:
|
|
905
|
-
|
|
906
|
-
```
|
|
907
|
-
$ temporal-fmt
|
|
908
|
-
temporal-fmt interactive mode. Type a subcommand, "help", or "exit".
|
|
909
|
-
temporal-fmt> format
|
|
910
|
-
ISO input: 2026-08-04T15:45:30
|
|
911
|
-
Format string: yyyy-MM-dd HH:mm:ss
|
|
912
|
-
2026-08-04 15:45:30
|
|
913
|
-
temporal-fmt> exit
|
|
914
|
-
```
|
|
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.
|
|
915
819
|
|
|
916
|
-
|
|
820
|
+
## Legacy mod documentation
|
|
917
821
|
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
Mod support (0.9.4+) lets you extend or patch behavior without forking. MODS.md covers installing and running mods someone else wrote — the sandbox from the user side, permissions, load order, and what happens when a mod is broken. Writing your own mod is a separate guide, split by API level: API_DOCS/LEVEL_1.md (0.9.4–0.9.5), API_DOCS/LEVEL_2.md (0.9.6-0.9.61), and API_DOCS/LEVEL_3.md (0.9.70+).
|
|
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.
|
|
921
823
|
|
|
922
824
|
## Subpath imports
|
|
923
825
|
|
|
924
|
-
Each capability area is also available as a subpath import, for anyone who wants a slice of the package rather than the whole thing:
|
|
925
|
-
|
|
926
826
|
```js
|
|
927
827
|
import { format } from 'temporal-fmt/format';
|
|
928
828
|
import { parse } from 'temporal-fmt/parse';
|
|
@@ -935,19 +835,15 @@ import { recurrence } from 'temporal-fmt/recurrence';
|
|
|
935
835
|
import { registerLocale } from 'temporal-fmt/locale';
|
|
936
836
|
```
|
|
937
837
|
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
Two Node-only script subpaths exist outside the library API proper: `scripts/loadMods.mjs` (the mod loader, with its sandbox) and `scripts/modSandbox.mjs` (subprocess lifecycle for mods that installed runtime overrides). They ship in the package but aren't in the `exports` map — the loader is ESM-only and has no honest CommonJS twin, so you import them by path rather than by bare specifier. See [MODS.md](./MODS.md); most installs will never touch either.
|
|
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.
|
|
941
839
|
|
|
942
|
-
`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.
|
|
943
841
|
|
|
944
842
|
## Migrating from Day.js or date-fns
|
|
945
843
|
|
|
946
844
|
### Token mapping
|
|
947
845
|
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
| Source (Day.js / date-fns) | temporal-fmt | Notes |
|
|
846
|
+
| Day.js / date-fns | temporal-fmt | Notes |
|
|
951
847
|
|---|---|---|
|
|
952
848
|
| `YYYY` | `yyyy` | Lowercase here |
|
|
953
849
|
| `YY` | `yy` | Same |
|
|
@@ -957,11 +853,11 @@ Most date-fns/Day.js tokens map directly. The differences:
|
|
|
957
853
|
| `ddd` | `EEE` | Short weekday |
|
|
958
854
|
| `HH`, `H`, `mm`, `m`, `ss`, `s` | same | Identical |
|
|
959
855
|
| `A`, `a` | `a` | Always lowercase here |
|
|
960
|
-
| `Z` | `XXX` |
|
|
961
|
-
| `ZZ` | `XX` |
|
|
962
|
-
| `X` | not supported |
|
|
963
|
-
| `x` | not supported |
|
|
964
|
-
| `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 |
|
|
965
861
|
|
|
966
862
|
### API mapping
|
|
967
863
|
|
|
@@ -977,13 +873,13 @@ Most date-fns/Day.js tokens map directly. The differences:
|
|
|
977
873
|
| `dayjs().isYesterday()` | `isYesterday(date)` | `isYesterday(date)` |
|
|
978
874
|
| `dayjs().isTomorrow()` | `isTomorrow(date)` | `isTomorrow(date)` |
|
|
979
875
|
|
|
980
|
-
###
|
|
876
|
+
### Things that will bite you
|
|
981
877
|
|
|
982
|
-
1. **Strict parsing
|
|
983
|
-
2. **Cross-field validation.** Weekday, quarter, and offset are cross-checked against the parsed date
|
|
984
|
-
3. **No
|
|
985
|
-
4. **Type-preserving.** `format(plainDate, 'HH:mm')` throws — `PlainDate` has no hour field. Day.js would silently use 0.
|
|
986
|
-
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:
|
|
987
883
|
|
|
988
884
|
```js
|
|
989
885
|
import { Temporal } from 'temporal-polyfill';
|
|
@@ -991,32 +887,28 @@ const pd = Temporal.PlainDate.from(jsDate.toISOString().slice(0, 10));
|
|
|
991
887
|
format(pd, 'yyyy-MM-dd');
|
|
992
888
|
```
|
|
993
889
|
|
|
994
|
-
### Common patterns
|
|
890
|
+
### Common patterns
|
|
995
891
|
|
|
996
892
|
```js
|
|
997
893
|
// CSV/log timestamp parsing
|
|
998
|
-
// Before: const d = dayjs(line, 'YYYY-MM-DD HH:mm:ss');
|
|
999
894
|
const d = parse('yyyy-MM-dd HH:mm:ss', line);
|
|
1000
895
|
|
|
1001
896
|
// Locale-aware formatting
|
|
1002
|
-
// Before: dayjs(date).locale('fr').format('MMMM D, YYYY');
|
|
1003
897
|
format(date, 'MMMM d, yyyy', { locale: 'fr-FR' }); // "août 4, 2026"
|
|
1004
898
|
|
|
1005
899
|
// Relative time
|
|
1006
|
-
// Before: dayjs(date).fromNow(); // "3 days ago"
|
|
1007
900
|
formatRelativeToNow(date); // "3 days ago"
|
|
1008
901
|
|
|
1009
902
|
// Date arithmetic
|
|
1010
|
-
// Before: dayjs(date).add(7, 'day');
|
|
1011
903
|
add(date, 7, 'days'); // or addDays(date, 7)
|
|
1012
904
|
```
|
|
1013
905
|
|
|
1014
906
|
### Things that don't migrate cleanly
|
|
1015
907
|
|
|
1016
|
-
- Day.js's `
|
|
1017
|
-
- Day.js's
|
|
1018
|
-
- date-fns's
|
|
1019
|
-
-
|
|
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.
|
|
1020
912
|
|
|
1021
913
|
### Running both during migration
|
|
1022
914
|
|
|
@@ -1030,183 +922,67 @@ function formatDate(date, formatStr, opts) {
|
|
|
1030
922
|
}
|
|
1031
923
|
```
|
|
1032
924
|
|
|
1033
|
-
Migrate file by file,
|
|
925
|
+
Migrate file by file, drop the wrapper when nothing calls the old path anymore.
|
|
1034
926
|
|
|
1035
927
|
## Related tools
|
|
1036
928
|
|
|
1037
|
-
|
|
929
|
+
Both are deprecated and archived — single-maintainer bandwidth, no further updates — but the last published versions still work:
|
|
1038
930
|
|
|
1039
|
-
- [`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`).
|
|
1040
|
-
- [`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.
|
|
1041
933
|
|
|
1042
934
|
## Testing
|
|
1043
935
|
|
|
1044
|
-
|
|
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.
|
|
1045
937
|
|
|
1046
|
-
|
|
938
|
+
## Conformance fixtures
|
|
1047
939
|
|
|
1048
|
-
`fixtures.json` is a portable
|
|
1049
|
-
token-based Temporal formatters. It's written against a different
|
|
1050
|
-
library's token vocabulary, not temporal-fmt's — the fixtures are
|
|
1051
|
-
data, not code. Each case names an `op` (`format` / `parse` /
|
|
1052
|
-
`roundtrip`), an input, a pattern, and an expected result, so any
|
|
1053
|
-
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.
|
|
1054
941
|
|
|
1055
|
-
`test/conformance.test.js` is the temporal-fmt
|
|
1056
|
-
translates fixture patterns into temporal-fmt's actual tokens, runs
|
|
1057
|
-
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.
|
|
1058
943
|
|
|
1059
|
-
|
|
944
|
+
### Why this is separate from adversarial and fuzz tests
|
|
1060
945
|
|
|
1061
|
-
|
|
1062
|
-
hostile input — clean throw or correct value, never a crash or a
|
|
1063
|
-
silently wrong one. The reference point there is the library's own
|
|
1064
|
-
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.
|
|
1065
947
|
|
|
1066
|
-
|
|
1067
|
-
shared set of tricky-but-well-defined cases — DST transitions, leap
|
|
1068
|
-
years, offset rendering, calendar limits — where "correct" comes from
|
|
1069
|
-
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.
|
|
1070
949
|
|
|
1071
|
-
|
|
950
|
+
### Pattern translation
|
|
1072
951
|
|
|
1073
952
|
Two fixture tokens don't exist in temporal-fmt:
|
|
1074
953
|
|
|
1075
954
|
| Fixture token | temporal-fmt equivalent | Why |
|
|
1076
955
|
|---|---|---|
|
|
1077
|
-
| `ZZ` (always-signed
|
|
1078
|
-
| `VV` (IANA zone
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
(`
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
One other flagged case no longer diverges:
|
|
1109
|
-
**`shape-mixing-H-and-a-rejected`**. `resolveHour()`
|
|
1110
|
-
(`src/parse.ts`) used to cross-check `H` against `a` instead of
|
|
1111
|
-
banning the combination outright, so `13:05 PM` was accepted (13:00 is
|
|
1112
|
-
consistent with PM) and only a genuine contradiction like `01:05 PM`
|
|
1113
|
-
threw. That choice has since been reverted — `H` and `a` are now
|
|
1114
|
-
refused together outright, unconditionally, matching the fixture. The
|
|
1115
|
-
fixture's own `"opinion"` text on that case still describes the old
|
|
1116
|
-
behavior; it's fixture data, not something this adapter edits, so
|
|
1117
|
-
treat the `opinionated` flag there as historical rather than current.
|
|
1118
|
-
|
|
1119
|
-
## History: divergences that have since been fixed
|
|
1120
|
-
|
|
1121
|
-
Everything below was once tracked in `KNOWN_FAILURES` at the top of
|
|
1122
|
-
`test/conformance.test.js`. That set is currently empty — every
|
|
1123
|
-
previously-found divergence has been resolved, either by fixing a
|
|
1124
|
-
real bug or by deliberately adopting the fixture's convention over a
|
|
1125
|
-
prior design choice. Kept here for context on what changed and why,
|
|
1126
|
-
in case any of it needs revisiting.
|
|
1127
|
-
|
|
1128
|
-
**Fixed — real bug: offset seconds were dropped, not rejected.**
|
|
1129
|
-
`formatOffset()` (`src/tokens.ts`) assumed every offset string was
|
|
1130
|
-
exactly 6 characters — sign, `HH`, `:`, `MM` — and never checked for a
|
|
1131
|
-
seconds component. Verified against a real `Temporal.ZonedDateTime`
|
|
1132
|
-
for a pre-1900 `America/New_York` date: the actual offset is
|
|
1133
|
-
`-04:56:02`, 9 characters, because pre-1883 New York ran on local mean
|
|
1134
|
-
time. The old code read that string's middle two digits as minutes,
|
|
1135
|
-
so `X` silently produced `-0402` (wrong) instead of refusing. Now:
|
|
1136
|
-
`X`/`XX`/`XXX`/`x`/`xx` throw when the offset has a seconds component
|
|
1137
|
-
(none of them have anywhere to put it), and `xxx` — the variant that
|
|
1138
|
-
plays `ZZ`'s "always-signed, never-Z" role — passes the full value
|
|
1139
|
-
through unchanged.
|
|
1140
|
-
- `offset-sub-minute-rejected-by-X`
|
|
1141
|
-
- `offset-sub-minute-passes-through-ZZ`
|
|
1142
|
-
|
|
1143
|
-
**Changed — offset-only `ZonedDateTime` construction, previously
|
|
1144
|
-
supported on purpose, is now refused.** `parse()` used to build a
|
|
1145
|
-
`ZonedDateTime` from an offset token alone, no `zzz` zone required.
|
|
1146
|
-
That was deliberate, not an oversight, but the fixture's position — an
|
|
1147
|
-
offset identifies a moment's distance from UTC, not a time zone, so
|
|
1148
|
-
building a `ZonedDateTime` from one alone papers over that distinction
|
|
1149
|
-
— was adopted instead. A pattern with an offset token and no `zzz` now
|
|
1150
|
-
throws; add `zzz` to the pattern (or parse into a
|
|
1151
|
-
`PlainDateTime`/`PlainDate`/`PlainTime` if a zone genuinely isn't
|
|
1152
|
-
needed).
|
|
1153
|
-
- `zone-required-for-zoneddatetime`
|
|
1154
|
-
- `zone-offset-token-rejected-on-plain-type`
|
|
1155
|
-
|
|
1156
|
-
**Added — `y` token (unpadded, variable-width year).** temporal-fmt
|
|
1157
|
-
previously had only `yyyy` (fixed 4 digits) and `yy` (2-digit,
|
|
1158
|
-
truncated). `y` formats and parses a year at any width, sign preserved
|
|
1159
|
-
for years before ISO year 0 — same semantics as `yyyy` minus the
|
|
1160
|
-
fixed width. It has no bounded fallback the way `yyyy` does when
|
|
1161
|
-
something digit-consuming follows (`yyyy` can fall back to an exact
|
|
1162
|
-
4-digit fragment in that case; `y` being unpadded is the entire point
|
|
1163
|
-
of the token, so there's no narrower shape that still means the same
|
|
1164
|
-
thing). `buildCapturingPattern()` (`src/parsePattern.ts`) refuses at
|
|
1165
|
-
build time to place `y` directly next to another digit-reading token
|
|
1166
|
-
or a digit-leading literal, rather than trying to estimate an
|
|
1167
|
-
ambiguity cost for an unbounded-width fragment — there's no finite
|
|
1168
|
-
number of "width choices" to charge for "any number of digits."
|
|
1169
|
-
- `extreme-year-max-supported`
|
|
1170
|
-
- `extreme-year-negative`
|
|
1171
|
-
- `extreme-year-past-max-rejected` — previously passed even without a
|
|
1172
|
-
real `y` token, because "no valid pattern matches" for the
|
|
1173
|
-
then-unrecognized token happened to also throw. Now genuinely tests
|
|
1174
|
-
275761 CE rejection, via the real max-year check on `y`'s parsed
|
|
1175
|
-
value.
|
|
1176
|
-
|
|
1177
|
-
**Fixed — misdiagnosed as a regex gap; the actual cause was `zzz`
|
|
1178
|
-
rejecting valid IANA zone aliases.** `offset-X-parse-accepts-four-digit`
|
|
1179
|
-
expects `X` to parse a 4-digit offset body (`+0530`) alongside a `zzz`
|
|
1180
|
-
zone. This was originally filed as "the capturing regex for `X` in
|
|
1181
|
-
`pattern.ts` doesn't offer the 4-digit shape as an alternative" — that
|
|
1182
|
-
diagnosis was wrong. The `X` regex fragment matches `+0530` correctly
|
|
1183
|
-
in isolation; the actual failure was `isValidTimeZone()`
|
|
1184
|
-
(`src/pattern.ts`) rejecting the fixture's zone name, `Asia/Kolkata`.
|
|
1185
|
-
`isValidTimeZone()` only checked `Intl.supportedValuesOf('timeZone')`,
|
|
1186
|
-
which lists canonical zone ids but not every IANA link/alias name —
|
|
1187
|
-
`Asia/Kolkata` is a legitimate, commonly-used alias for
|
|
1188
|
-
`Asia/Calcutta` that some ICU builds' `supportedValuesOf()` omits.
|
|
1189
|
-
Confirmed against `temporal-polyfill` directly:
|
|
1190
|
-
`Temporal.ZonedDateTime.from()` resolves `Asia/Kolkata` without
|
|
1191
|
-
complaint, so `parse()` was refusing input its own downstream
|
|
1192
|
-
construction step would have accepted. `isValidTimeZone()` now falls
|
|
1193
|
-
back to asking `Temporal` itself (a real `ZonedDateTime.from()` call)
|
|
1194
|
-
when the fast-path `Intl` lookup misses, rather than trusting only the
|
|
1195
|
-
`Intl` list.
|
|
1196
|
-
|
|
1197
|
-
## Adapter mapping notes
|
|
1198
|
-
|
|
1199
|
-
- `parse(formatStr, input, options)` takes `(formatStr, input)` —
|
|
1200
|
-
reversed from the fixture's `pattern`/`input` field order.
|
|
1201
|
-
- temporal-fmt does have a typed error hierarchy (`TemporalFmtError`
|
|
1202
|
-
and its subclasses in `src/errors.ts`), but the fixture's three
|
|
1203
|
-
`expect.throws` values (`"ParseError"`, `"FormatError"`,
|
|
1204
|
-
`"InvalidPatternError"`) don't map cleanly onto temporal-fmt's
|
|
1205
|
-
dozen-plus subclasses, so the adapter doesn't try — it just checks
|
|
1206
|
-
that something extending `Error` was thrown.
|
|
1207
|
-
- `target` in the fixture is informational only. temporal-fmt's
|
|
1208
|
-
`parse()` infers the result shape from which fields the pattern
|
|
1209
|
-
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.
|
|
1210
986
|
|
|
1211
987
|
## Contributing
|
|
1212
988
|
|
|
@@ -1218,12 +994,12 @@ npm run build
|
|
|
1218
994
|
npm run test:all
|
|
1219
995
|
```
|
|
1220
996
|
|
|
1221
|
-
`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).
|
|
1222
998
|
|
|
1223
|
-
|
|
999
|
+
Build notes if you're touching the toolchain:
|
|
1224
1000
|
|
|
1225
|
-
-
|
|
1226
|
-
- 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`.
|
|
1227
1003
|
|
|
1228
1004
|
## License
|
|
1229
1005
|
|