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.
Files changed (79) hide show
  1. package/README.md +304 -528
  2. package/dist/chunk-6FPOCDZV.js +129 -0
  3. package/dist/chunk-6FPOCDZV.js.map +1 -0
  4. package/dist/{chunk-CQJYB7E7.js → chunk-ACGCGXP5.js} +4 -10
  5. package/dist/chunk-ACGCGXP5.js.map +1 -0
  6. package/dist/chunk-E4DD23HH.js +241 -0
  7. package/dist/chunk-E4DD23HH.js.map +1 -0
  8. package/dist/{chunk-MXXTJ5CW.js → chunk-EEPLAE3H.js} +5 -3
  9. package/dist/{chunk-ZT3UXRAA.js → chunk-EXIJZ4JG.js} +3 -8
  10. package/dist/chunk-EXIJZ4JG.js.map +1 -0
  11. package/dist/{chunk-BQET6RUR.js → chunk-GRBQ5FRO.js} +6 -23
  12. package/dist/{chunk-BQET6RUR.js.map → chunk-GRBQ5FRO.js.map} +1 -1
  13. package/dist/{chunk-3FMUGYE2.js → chunk-KV6GMX4D.js} +4 -159
  14. package/dist/chunk-KV6GMX4D.js.map +1 -0
  15. package/dist/{chunk-3SC5AVSY.js → chunk-TDQYKYVJ.js} +5 -5
  16. package/dist/chunk-UGYSEYUQ.js +168 -0
  17. package/dist/chunk-UGYSEYUQ.js.map +1 -0
  18. package/dist/{chunk-LHR5PCP4.js → chunk-UYKKGBZ3.js} +7 -7
  19. package/dist/{chunk-64UB3WDT.js → chunk-YQGUTK4E.js} +4 -4
  20. package/dist/duration.js +3 -3
  21. package/dist/format.cjs +0 -17
  22. package/dist/format.cjs.map +1 -1
  23. package/dist/format.js +4 -4
  24. package/dist/index.cjs +3908 -4671
  25. package/dist/index.cjs.map +1 -1
  26. package/dist/index.d.cts +21 -103
  27. package/dist/index.d.ts +21 -103
  28. package/dist/index.js +2474 -893
  29. package/dist/index.js.map +1 -1
  30. package/dist/interval.cjs +106 -4773
  31. package/dist/interval.cjs.map +1 -1
  32. package/dist/interval.js +7 -12
  33. package/dist/legacy/index.d.cts +23 -0
  34. package/dist/legacy/index.d.ts +23 -0
  35. package/dist/legacy/matchesFormat.d.cts +6 -0
  36. package/dist/legacy/matchesFormat.d.ts +6 -0
  37. package/dist/legacy.cjs +2153 -0
  38. package/dist/legacy.cjs.map +1 -0
  39. package/dist/legacy.d.cts +1 -0
  40. package/dist/legacy.d.ts +1 -0
  41. package/dist/legacy.js +31 -0
  42. package/dist/legacy.js.map +1 -0
  43. package/dist/parse.cjs +0 -13
  44. package/dist/parse.cjs.map +1 -1
  45. package/dist/parse.js +4 -4
  46. package/dist/recurrence.js +2 -1
  47. package/dist/relativeTime.cjs.map +1 -1
  48. package/dist/relativeTime.js +4 -4
  49. package/dist/timezone.js +2 -2
  50. package/dist/tokens.d.cts +0 -4
  51. package/dist/tokens.d.ts +0 -4
  52. package/legacy.d.cts +1 -0
  53. package/legacy.d.ts +1 -0
  54. package/package.json +17 -21
  55. package/MODS.md +0 -148
  56. package/dist/chunk-3FMUGYE2.js.map +0 -1
  57. package/dist/chunk-CQJYB7E7.js.map +0 -1
  58. package/dist/chunk-ZT3UXRAA.js.map +0 -1
  59. package/dist/chunk-ZTIF4ZHX.js +0 -3041
  60. package/dist/chunk-ZTIF4ZHX.js.map +0 -1
  61. package/dist/modApi.d.cts +0 -147
  62. package/dist/modApi.d.ts +0 -147
  63. package/dist/runtime.d.cts +0 -206
  64. package/dist/runtime.d.ts +0 -206
  65. package/scripts/cli.mjs +0 -324
  66. package/scripts/loadMods.d.mts +0 -37
  67. package/scripts/loadMods.mjs +0 -801
  68. package/scripts/managePermissions.mjs +0 -126
  69. package/scripts/modConfig.mjs +0 -96
  70. package/scripts/modSandbox.d.mts +0 -13
  71. package/scripts/modSandbox.mjs +0 -855
  72. package/scripts/modWire.mjs +0 -241
  73. package/scripts/modWorker.mjs +0 -574
  74. package/scripts/semverRange.mjs +0 -157
  75. package/scripts/versions.json +0 -42
  76. /package/dist/{chunk-MXXTJ5CW.js.map → chunk-EEPLAE3H.js.map} +0 -0
  77. /package/dist/{chunk-3SC5AVSY.js.map → chunk-TDQYKYVJ.js.map} +0 -0
  78. /package/dist/{chunk-LHR5PCP4.js.map → chunk-UYKKGBZ3.js.map} +0 -0
  79. /package/dist/{chunk-64UB3WDT.js.map → chunk-YQGUTK4E.js.map} +0 -0
package/README.md CHANGED
@@ -2,31 +2,15 @@
2
2
 
3
3
  ![coverage](https://img.shields.io/badge/coverage-100%25%20(c8)-brightgreen?style=flat-square)
4
4
 
5
- Format and parse `Temporal` values (`PlainDate`, `PlainTime`, `PlainDateTime`,
6
- `ZonedDateTime`) using date-fns-style tokens, with real validation — bad input
7
- throws instead of silently returning garbage. Locale-aware, no deps.
8
-
9
- Node 26 shipped native `Temporal` and then pointedly left out a custom-string
10
- formatter. TC39's take: use `Intl.DateTimeFormat` and leave string-token syntax
11
- to userland. Fair enough, but if you've spent years typing `'yyyy-MM-dd'` out of
12
- muscle memory from date-fns, moment, or dayjs, that's a rough adjustment. This
13
- library exists so you don't have to make it.
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'); // "2026-08-04"
28
+ format(date, 'yyyy-MM-dd'); // "2026-08-04"
45
29
 
46
- parse('yyyy-MM-dd HH:mm', '2026-08-04 15:45'); // Temporal.PlainDateTime
30
+ parse('yyyy-MM-dd HH:mm', '2026-08-04 15:45'); // Temporal.PlainDateTime
47
31
  ```
48
32
 
49
- That's the whole library for most use cases — `format(temporal, formatStr)` in, `parse(formatStr, input)` out, same shape as date-fns or Day.js. Import from the subpaths (`temporal-fmt/format`, `temporal-fmt/parse`) shown above, not the bare `temporal-fmt` package — a bundler only ships what you actually call that way. Measured with esbuild: ~27KB for `format` alone via the subpath, versus ~68KB for the same function pulled from the bare import.
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 yet, so you'll need a polyfill first — see [Providing `Temporal`](#providing-temporal). On Node 26+ the snippet above just works.
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 looks large on npm — locales, recurrence, business calendars, timezone disambiguation, an analyzer, config layers, a CLI — but none of that is required reading or required bundle weight. It's there behind its own subpaths for when you need it; see [Subpath imports](#subpath-imports) for the full list and [Formatting](#formatting)/[Parsing](#parsing) for the details on the two functions above.
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
- - [CLI](#cli)
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
- Set a Temporal implementation explicitly, once, before your app's first `format()`/`parse()` call:
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); // once, before using format or parse
94
+ setTemporal(Temporal);
116
95
  ```
117
96
 
118
- `setTemporal()` takes precedence over native or global `Temporal`, and calling it again overrides whatever was set before. Useful when you don't want to pollute the global namespace — libraries, mainly. Call it with no argument to clear the override and fall back to `globalThis.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
- Anything that constructs a `Temporal` value from scratch needs this — `parse()`, `parseISO()`, `resolveZoned()`, and a handful of others. Functions that only read fields off a value you already built (`format()`, `compare()`, `add()`) don't touch the namespace at all, so they work with any Temporal-shaped object regardless of whether you've called `setTemporal()`.
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'); // "2026-08-04"
129
- format(date, 'MMMM d, yyyy'); // "August 4, 2026"
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, like `'at'` above. Need an actual single quote in your output? Use `''`.
117
+ Wrap literal text in single quotes. For a literal single quote in output, use `''`.
139
118
 
140
- Try a token your input type doesn't support — `HH` on a `PlainDate`, say — and you get a real error telling you so, not a silent `undefined` sitting in your output waiting to confuse someone in three weeks.
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?)` — formats a Temporal value against a token string. Returns a string.
143
- - `formatToParts(temporal, formatStr, options?)` — same, but returns an array of `{ type: 'literal' | 'token', value, token? }` parts instead of a joined string. Mirrors the shape of `Intl.DateTimeFormat.formatToParts`, useful if you want to style each piece separately (e.g. one `<span>` per token in a DOM output).
144
- - `compileFormat(formatStr)` — pre-tokenizes a format string once and returns an object with `.format()`/`.formatToParts()` methods, plus `.pieces` and `.formatStr` for inspection. Format strings are already tokenize-cached internally (LRU, 500 entries), so this mainly buys you up-front validation — a bad format string throws at compile time instead of on first use — and a place to hold onto the compiled form explicitly.
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 real `Temporal.PlainDate` / `PlainTime` / `PlainDateTime` / `ZonedDateTime` out of a string, picking whichever type fits the tokens present:
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'); // Temporal.PlainDateTime
154
- parse('yyyy-MM', '2026-08-04T15:45:30'); // throws — shape doesn't match
155
- parse('yyyy-MM-dd', '2026-02-30'); // throws — not a real date
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 in the format string, and that's unknown at compile time, you'll need `instanceof` or your own type guard to narrow it in TypeScript — see [Type guards](#type-guards).
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
- Since `parse()` constructs a real value rather than just matching shape, it also catches contradictions a shape-only regex would miss — an impossible date like February 30th, or a weekday label that disagrees with the date it's paired with:
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 — that really is a Tuesday
164
- parse('EEEE, yyyy-MM-dd', 'Monday, 2026-08-04'); // throws — it isn't
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
- `parse()` throws when `input` doesn't match `formatStr`'s shape at all, and throws a more specific error when the shape matches but the resulting date/time is invalid.
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, or `undefined` on any failure.
173
- - `parseToParts(formatStr, input, options?)` — returns the matched token groups with their positions in the input string, instead of constructing a value. Useful for highlighting what matched where.
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
- A few things worth knowing about how `parse()` behaves:
153
+ ### Parsing notes
177
154
 
178
- - **`yy` (2-digit year)** follows the POSIX [strptime](https://www.man7.org/linux//man-pages/man3/strptime.3p.html) convention: `00–68` becomes `2000–2068`, `69–99` becomes `1900–1999`. Opinionated, but it's what makes `yy` deterministic without an external reference date.
179
- - **`hh`/`h` (12-hour) without an `a` token throws.** Same if a format string mixes `HH`/`H` with `hh`/`h`, even when both agree on the hour — `parse()` won't guess which one is authoritative, so pick one.
180
- - **`HH`/`H` combined with `a` is allowed, and cross-checked.** `parse('HH:mm a', '13:05 PM')` succeeds since 13:05 can only mean PM, but `parse('HH:mm a', '01:05 PM')` throws — the day period contradicts the hour. Different case from the point above: there's only one hour token here, `a` is just confirming it.
181
- - **`a` (AM/PM) is case-insensitive** — `pm`, `Pm`, `PM` all parse the same. Month and weekday names (`MMMM`, `EEEE`, etc.) stay case-sensitive; only the day-period marker is folded.
182
- - **`S` through `SSSSSSSSS` reach micro/nanosecond precision**, not just milliseconds. `SSS` is the familiar 3-digit ms case; wider tokens expose whatever sub-millisecond precision the `Temporal` value actually carries, so you can round-trip machine-generated timestamps (DB exports, instrumentation logs) without silently truncating. Format truncates to the requested width (never rounds); parse right-pads short input — `SSSSSSSSS` reading `.5` means 500ms-worth of nanoseconds (`500000000`), not 5 nanoseconds.
183
- - **`MMMM`/`MMM` assume a 12-month calendar.** The name vocabulary is generated from 12 Gregorian reference dates, so a calendar with a leap month (Hebrew's 13-month leap years, for instance) isn't fully covered by month *names*. Numeric `yyyy-MM-dd` round-trips are unaffected.
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 (e.g. `"121"` against `yyyy-Md`) has more than one valid split. It refuses to guess — silently picking one would return a value that's indistinguishable from a different, equally valid reading of the same input.
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
- **Heuristic**: if one of the tokens in the ambiguous run is `d` (day), prefer the split where the day value is ≤ 12. The reasoning: someone who glues a run like `"121"` into an `Md` format is more likely to mean Dec 1 (M=12, d=1) than Jan 21 (M=1, d=21) — if they meant Jan 21, they'd more often write it with a separator or padding (`"1/21"`, `"01/21"`). It's not a guarantee, which is exactly why it's opt-in. When the heuristic doesn't narrow it down (both splits have day ≤ 12), or when there's no `d` token in the run at all (e.g. `Hm`), it falls back to the first valid split — deterministic, but arbitrary. Default behavior (lenient unset or `false`) is unchanged either way.
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
- The six offset tokens only work on `ZonedDateTime`. On a `PlainDate`/`PlainTime`/`PlainDateTime` they throw the same "requires offset, which this Temporal object doesn't have" error every other field-typed token throws when its field is missing.
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 to anchor the instant — same rule `zzz` enforces. With an offset token and no `zzz`, the resulting `ZonedDateTime`'s `timeZoneId` is the offset string itself (e.g. `"+09:00"`). With **both** `zzz` and an offset token, it's a cross-check: `zzz` wins for the result's `timeZoneId` (the IANA name is the meaningful label), and the offset token's value must match that zone's actual offset at the parsed instant. Disagreement throws rather than silently picking one — `parse('yyyy-MM-dd HH:mm zzz XXX', '2026-08-04 15:45 America/New_York +09:00')` throws, because August in New York is `-04:00`, not `+09:00`.
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 historical offsets: the tokens read `ZonedDateTime`'s `offset` field, which is `±HH:MM` for any modern date but carries a seconds component for pre-railway local-mean-time zones (Europe/London before 1847 surfaces `-00:01:15`). `xxx` is the one variant with a slot for that — it passes a sub-minute offset through verbatim — while the other five throw a descriptive error recommending `xxx` rather than silently truncating the seconds away. Parse-side offset shapes have no seconds group, so a sub-minute offset can't be parsed back through a token; construct the `ZonedDateTime` directly if you need to round-trip one of those.
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 locale tag via `options.locale` and month names, weekday names, and AM/PM markers all localize. Defaults to `'en-US'`.
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
- The named-vocabulary tokens (`MMMM`, `MMM`, `EEEE`, `EEE`, `a`) go through `Intl.DateTimeFormat` under the hood, so non-Gregorian calendars work as long as the `Temporal` object already carries one:
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 (`yyyy`, `MM`, `dd`) read straight off the object's already-calendar-specific fields, so the formatting logic itself is calendar-agnostic — they never touch `Intl`.
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 fields come out in Western (0–9) digits by default**, regardless of locale — passing `{ locale: 'ar-EG' }` alone doesn't switch `yyyy`/`MM`/`dd` to Arabic-Indic digits, only the named-vocabulary tokens above. That's on purpose: logs, APIs, and filenames reading this output back in generally want boring ASCII digits, so locale doesn't silently drag the numeric tokens along with it. If you specifically want localized digits on the numeric pieces, that's a separate, explicit opt-in — see [Numbering systems](#numbering-systems) below.
200
+ **Numeric output is always Western digits by default**, regardless of locale. `{ locale: 'ar-EG' }` alone doesn't switch `yyyy`/`MM`/`dd` to Arabic-Indic digits — see [Numbering systems](#numbering-systems) for the opt-in.
243
201
 
244
202
  ### Registering custom vocabulary
245
203
 
246
- `Intl` doesn't cover every locale well — the standing example here is a 13-month Hebrew leap year, where `Intl`'s 12-month vocabulary can't name the extra month. Two registration functions cover this, at different levels of detail.
204
+ `Intl` doesn't cover every case well — a 13-month Hebrew leap year is the standing example.
247
205
 
248
- **`registerLocaleVocab(locale, vocab)`** — base vocabulary: months, weekdays, day periods only.
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)`** — extended vocabulary: everything `registerLocaleVocab` covers, plus quarters, eras, ordinals, duration units, and relative-time language:
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 (optional)
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 extended vocab if there is one, otherwise the `Intl`-derived base vocab — deterministic, keyed off the canonical locale tag (lowercased via `Intl.Locale`). `hasLocale(locale)` checks whether an entry is registered without pulling the fallback.
242
+ `getLocale(locale)` returns the registered vocab or falls back to `Intl`. `hasLocale(locale)` checks without pulling the fallback.
289
243
 
290
- Validation on both functions is strict: wrong array lengths (12 months, 7 weekdays, 2 day periods), empty strings, duplicate entries, and identical AM/PM day periods (which would leave `parse()` unable to distinguish them) all throw at registration time, not later during format/parse. Registering invalidates the cache entry for that locale key immediately, so the next call picks up the new vocab — `registerLocaleVocab`/`registerLocale` are the only global mutation points in the library; every other locale option is per-call.
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 against the date on parse | `Tuesday` |
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` … `SSSSSSSSS` | Fractional second, 1–9 digits (tenths through nanoseconds) | `SSS` → `000` |
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 id, or fixed offset. Needs full date+time on parse | `America/New_York` |
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, digit (1–4). Cross-checked against month on parse | `3` |
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 — round-trip safety, locale/calendar/timezone sensitivity, which Temporal types it works on — is available at runtime via `tokenInfo(name)` or the full `TOKEN_METADATA` table; see [Introspection and the analyzer](#introspection-and-the-analyzer).
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
- **`do`** is format-only — `parse()` rejects it, since the "st"/"nd"/"rd"/"th" suffix isn't structurally distinguishable from adjacent literal text once you're matching against arbitrary input. The English-only suffix rule is deliberate too: locale-aware ordinals are out of scope, since `Intl.DateTimeFormat` has no part type for ordinals and the rest of this library routes locale-specific names through it.
298
+ **`Q`/`QQQ`** format and parse, cross-checking against any month/date tokens in the same string.
343
299
 
344
- **`Q`/`QQQ`** both format and parse, cross-checking against any month/date tokens present in the same string — same contract `EEEE` uses for weekday.
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
- **`ww`/`RRRR`** are format-only: parsing a week number back into a specific date needs a weekday or a full date to disambiguate, which is a different parsing surface than what `parse()` does here. `RRRR` is the ISO week-numbering year, not the calendar year — they diverge at year boundaries. `format(PlainDate.from('2026-12-31'), 'ww RRRR')` → `"53 2026"`; `format(PlainDate.from('2027-01-01'), 'ww RRRR')` → `"53 2026"` (that Friday belongs to ISO year 2026's week 53); `format(PlainDate.from('2027-01-04'), 'ww RRRR')` → `"01 2027"` (Monday starting ISO week 1 of 2027).
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 the stand-alone forms (nominative case in Slavic locales, where the regular month/weekday forms decline by grammatical case). They render identically to `MMMM`/`MMM`/`EEEE`/`EEE` in most locales — the distinction only shows up in languages with case-marked calendar vocabulary.
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` (or a plain field bag `{ years, months, weeks, days, hours, minutes, seconds, milliseconds }`) with its own duration-specific token set. A duration doesn't sit on a calendar — no year/month/day position the way a `PlainDate` has — so the date/time token table above doesn't apply here.
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, increasing in verbosity:
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 (singular at 1, plural otherwise).
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') // "2 years 3 months"
373
- formatDuration({ hours: 2, minutes: 30 }, 'hhh mmm') // "2 hours 30 minutes"
374
- formatDuration({ hours: 2, minutes: 30 }, 'h:mm') // "2:30"
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
- **Zero-value handling**: zero-value units are omitted by default. `formatDuration({ hours: 2 }, 'hhh mmm')` returns `"2 hours "` — the trailing space is the literal separator from the format string, and cleaning that up is on the caller, not the library. Pass `{ showZeroValues: true }` to render zero-value units anyway.
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
- **Locale-aware unit names**: pass `{ locale }` to localize short/long forms via `Intl.NumberFormat`'s `style: 'unit'` mode, the same approach `formatDistance` uses for `Intl.RelativeTimeFormat`. Numeric-only tokens (`y`, `o`, `w`, ...) always stay ASCII digits, matching the rest of the library's convention.
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?)` — `formatDuration`, but returns parts instead of a joined string.
392
- - `parseDuration(input, formatStr, options?)` — the inverse of `formatDuration`: parses a formatted string back into duration fields.
393
- - `parseISODuration(input)` / `formatISODuration(duration)` — parse/format the ISO 8601 duration grammar (`P1Y2M3DT4H5M6S`).
394
- - `balanceDuration(duration)` — normalizes fields into their natural ranges (e.g. 90 minutes → 1 hour 30 minutes).
395
- - `totalDuration(duration, unit)` — sums a duration's absolute fields into a single number in the target unit (`'days' | 'hours' | 'minutes' | 'seconds' | 'milliseconds' | 'microseconds' | 'nanoseconds'`).
396
- - `compareDuration(a, b)` — `-1`/`0`/`1` by total absolute length.
397
- - `addDuration(a, b)` / `subtractDuration(a, b)` — field-by-field sum/difference.
398
- - `roundDuration(duration, options)` — round a duration to a unit; see [Date arithmetic, comparison, and rounding](#date-arithmetic-comparison-and-rounding).
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, delegating unit names and pluralization to `Intl.RelativeTimeFormat`.
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) // "yesterday" (numeric: 'auto')
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
- **Direction**: `diff = date1 - date2`. Positive → date1 is in the future relative to date2 → "in X". Negative → date1 is in the past → "X ago". Swap the arguments to flip it.
375
+ Direction: `diff = date1 - date2`. Positive → "in X". Negative → "X ago".
421
376
 
422
- **Unit-selection cutoffs** (defaults below; override any subset per call via `{ cutoffs }`):
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` (expressed in days — see below) |
385
+ | < 365 days | months | `months: 365` (in days) |
431
386
  | otherwise | years | — |
432
387
 
433
- 30 days approximates a month, 365 approximates a year — the same cutoffs date-fns uses, trimmed to the units `Intl.RelativeTimeFormat` supports across engines. The `months` cutoff is expressed in days rather than a month count because "a month" isn't a fixed number of days; that lets a caller say "treat anything under 90 days as months" without having to pick a definition of month first. Unspecified boundaries fall back to the defaults. Non-monotonic boundaries (e.g. `seconds: 300, minutes: 1`, which would make the minutes branch unreachable) and non-positive values throw descriptively rather than producing confusing downstream output.
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
- ```js
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
- Accepts `PlainDate`, `PlainDateTime`, or `ZonedDateTime`. A `PlainDate` is treated as midnight when diffed against a `PlainDateTime`. Throws on `PlainTime` (no anchor date to diff against) and on partial-date shapes (e.g. `{ year: 2026 }` with no month/day).
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" (numeric: 'auto' reads "tomorrow")
398
+ formatDistanceToNow(tomorrow) // "in 1 day"
451
399
  ```
452
400
 
453
- It reads full wall-clock time (hour through millisecond) off the system clock, not just the calendar date — `formatRelativeToNow` below only needs day resolution, but `formatDistance`'s unit selection is millisecond-resolution, so a date-only reference here would misclassify anything under 24 hours old. The reference is captured fresh on every call, never cached, so back-to-back calls reflect whatever "now" actually is at each call site.
401
+ Reference is captured fresh on every call, never cached.
454
402
 
455
- ### `formatRelative` / `formatRelativeToNow` — "yesterday", "last week"
403
+ ### `formatRelative` / `formatRelativeToNow`
456
404
 
457
- `formatRelative(date1, date2, options?)` returns a calendar-relative label ("yesterday", "tomorrow", "last week") rather than `formatDistance`'s numeric-distance phrasing. `formatRelativeToNow(date, options?)` is `formatRelative(date, now)`.
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 common relative-date phrases against a reference date, returning a `Temporal.PlainDate`. English by default; pass `{ locale: 'es' }` / `'fr'` / `'de'` (or any tag with that language subtag) to route to the matching grammar.
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
- - **weekday references**: "next Tuesday", "last Friday", "this Monday"
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
- Each language grammar is its own module — phrase patterns and vocabulary aren't shared across languages, only the matching engine and resolution helpers are.
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 class | en | es | fr | de |
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` / `martes próximo` | `mardi prochain` | `nächsten Dienstag` |
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 (NFD normalization + combining-mark removal), so `"miercoles"` matches the same as `"miércoles"`, `"aout"` as `"août"`, `"naechsten"` as `"nächsten"`. German umlaut transliterations (`ä`→`ae`, `ö`→`oe`, `ü`→`ue`, `ß`→`ss`) are expanded too, so `"5. Maerz"` resolves the same as `"5. März"`.
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() // '2026-08-04'
491
- parseRelative('tomorrow', today).toString() // '2026-08-05'
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('2 weeks ago', today).toString() // '2026-07-21'
496
- parseRelative('March 5th', today).toString() // '2027-03-05' (next occurrence)
497
-
498
- parseRelative('mañana', today, { locale: 'es-ES' }).toString() // '2026-08-05'
499
- parseRelative('el próximo martes', today, { locale: 'es-ES' }).toString() // '2026-08-11'
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
- **Ambiguous-case choices** (documented, not inferred):
447
+ **Documented ambiguous-case choices:**
507
448
 
508
- - **"next Tuesday" said on a Tuesday** = 7 days out, not today. "this Tuesday" handles the same-week case, so the two phrases stay distinct.
509
- - **"last Tuesday" said on a Tuesday** = 7 days ago (symmetric with "next").
510
- - **"March 5th" without a year** = next occurrence. Today's date returns today; a past date this year rolls to next year. The alternative — nearest in time, past or future — would make "March 5th" said on March 6 return yesterday, which is the wrong call for the typical "next birthday"/"next deadline" use.
511
- - **"5 days" without "in" or "ago"** = throws, same strict-refusal contract as `parse()`. The equivalent bare phrase in any supported language (`"3 días"`, `"3 jours"`, `"3 Tage"`) throws with a localized message pointing at the disambiguated forms.
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
- The "next X on X = 7 days out, not today" convention holds across all four supported languages by design, not by accident — if a future grammar's natural phrasing resolves differently, that's meant to be called out explicitly in that grammar's own documentation.
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 any phrase it doesn't recognize, naming the supported categories. Accepts `PlainDate`, `PlainDateTime`, or `ZonedDateTime` as the reference (needs `dayOfWeek` to compute weekday offsets); throws on `PlainTime`.
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 — fuzzy is opt-in
463
+ parseRelative('tommorow', today); // throws
525
464
  ```
526
465
 
527
- It works by tokenizing the input on whitespace and, for any word that isn't already an exact match against the English vocabulary (weekday names, month names, and marker words like "next"/"ago"/"days"), finding the closest vocabulary word within edit distance 2 and substituting it before re-running the exact matcher. Distance 2 covers the common cases — a dropped letter ("tommorow") is distance 1, a transposed pair ("tuesady") is distance 2 under plain Levenshtein — without opening the door to correcting a word into something only vaguely similar.
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 touched by fuzzy correction, on purpose: `"5 dyas"` fuzzy-corrects to `"5 days"` and then still throws the usual past-or-future ambiguity error, since "in"/"ago" disambiguation is a separate, stricter contract this option doesn't relax. Digit typos are also a much easier way to silently produce a wrong date than a weekday-name typo is, so they stay out of scope here.
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 for now. The other three grammars have enough positional and multi-word-marker variation — French's "il y a", Spanish's pre/post weekday-modifier forms — that one word-substitution corrector doesn't fit all of them without per-language tuning this doesn't attempt yet. Combining `{ fuzzy: true }` with a non-English `locale` throws a scope-limited error rather than silently skipping the correction pass, so a caller relying on it for, say, French input gets a clear signal instead of a confusing "doesn't recognize" error with no indication fuzzy matching never ran.
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
- **Adding a language**: `registerRelativeGrammar(grammar)` registers a new language's phrase patterns without touching the built-in four. `listRegisteredGrammars()` lists what's currently registered.
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` is one of `'years' | 'months' | 'weeks' | 'days' | 'hours' | 'minutes' | 'seconds' | 'milliseconds'`.
548
- - Per-unit wrappers for both directions: `addYears`, `addMonths`, `addWeeks`, `addDays`, `addHours`, `addMinutes`, `addSeconds`, `addMilliseconds`, and the matching `subtract*` set.
549
- - `difference(a, b, unit)` — integer count of unit boundaries crossed between two values.
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); // -1 / 0 / 1
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
- - Semantic helpers: `isToday`, `isTomorrow`, `isYesterday`, `isSameDay`, `isSameWeek`, `isSameMonth`, `isSameQuarter`, `isSameYear`, `isWeekend`, `isWeekday`.
505
+ - `isToday`, `isTomorrow`, `isYesterday`, `isSameDay`, `isSameWeek`, `isSameMonth`, `isSameQuarter`, `isSameYear`, `isWeekend`, `isWeekday`.
567
506
 
568
507
  ### Rounding
569
508
 
570
- - `round(value, options)` — round to a unit with a rounding mode.
509
+ - `round(value, options)`.
571
510
  - `floor(value, unit, increment?)`, `ceil(value, unit, increment?)`, `truncate(value, unit, increment?)`.
572
- - `roundDuration(duration, options)` — the duration equivalent; throws for calendar-bound units (months, years) without a `relativeTo` reference, since those units aren't a fixed length on their own.
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)` (Gregorian: `isLeapMonth` always returns `false`).
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` is `'day' | 'month' | 'year' | 'hour' | 'minute' | 'second'`. Returns a field bag with finer fields zeroed (`startOf`) or extended to their max (`endOf`).
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, same as the `Q`/`QQQ` format tokens), but a fiscal year rarely starts in January. Pass `{ startMonth }` to shift which month counts as fiscal month 1:
529
+ `getQuarter` defaults to calendar quarters (Jan–Mar = Q1). Pass `{ startMonth }` to shift the fiscal year start:
591
530
 
592
531
  ```js
593
- import { getQuarter } from 'temporal-fmt';
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 an integer 1–12; anything else throws rather than silently defaulting. This is a separate function from the `Q`/`QQQ` tokens, not a shared implementation — those tokens compute quarter inline from month and have no fiscal-offset option, so a fiscal quarter number isn't currently something `format()`/`parse()` can render or round-trip through a token string. Use `getQuarter` directly for fiscal reporting and reach for the tokens only when you actually want calendar quarters in formatted output.
537
+ `startMonth` must be 1–12; anything else throws.
601
538
 
602
- **Gregorian-only, documented limitation**: `daysInMonth`, `daysInYear`, `isLeapYear`, `monthsInYear` (always 12), `isLeapMonth` (always `false`), `dayOfYear`, `weekOfYear`, and `weekYear` all use Gregorian rules and will give wrong answers on non-Gregorian calendars (Hebrew, Islamic, etc.). For those, use the `Temporal` value's own calendar-aware properties directly instead:
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 *formatting* tokens (`MMMM`, `MMM`, `EEEE`, `EEE`, `a`) don't have this limitation — they go through `Intl.DateTimeFormat`, which does respect `calendarId`:
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` is one of `'closed'` (default) | `'open'` | `'half-open-start'` | `'half-open-end'`.
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)` — interval-to-interval ordering (imported as `intervalIsBefore`/`intervalIsAfter` when pulled from the main entry point, to avoid colliding with the date `isBefore`/`isAfter` above).
630
- - `intersection(a, b)`, `union(a, b)` — return `null` when the intervals don't overlap enough to combine.
631
- - `difference(a, b)` / `subtract(a, b)` — imported as `intervalDifference`/`intervalSubtract` from the main entry point, same collision-avoidance reasoning.
632
- - `mergeIntervals(intervals)` — combines a list of overlapping intervals into their union set.
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?)` — format an interval as a range string, using `Intl.DateTimeFormat.formatRange` where available.
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); // first 5 occurrences
644
- between(startDate, rule, rangeStart, rangeEnd); // occurrences within a window
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` is one of `'secondly' | 'minutely' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly'`; the rule shape also supports `interval`, `count`, `until`, weekday/month-day/positional constraints, and exclusions/inclusions, mirroring RFC 5545's RRULE model.
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, without manually iterating.
651
- - `parseRRule(input)` / `formatRRule(rule)` — parse/format the RFC 5545 RRULE text format, for interop with calendar systems that speak it.
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
- Two related but separate pieces: a business-day calendar (weekends, working hours, half days) and a holiday calendar (which specific dates are excluded). Compose them by handing a holiday calendar's dates to a business calendar's options.
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?)` — configure weekend days, holidays, working hours, and half days.
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 either a fixed `{ month, day }` or a `compute(year) => { month, day }` function for floating holidays ("last Monday of May"-style rules).
684
- - The returned calendar has `.isHoliday(value)` and `.holidaysBetween(start, end)` as methods on the object itself — they aren't standalone exports.
685
- - `nextHoliday(cal, value)` / `previousHoliday(cal, value)` — standalone helpers that call `.isHoliday()` under the hood, capped at a 5-year lookahead/lookbehind.
686
- - `holidaysBetween(cal, start, end)` — standalone wrapper delegating to `cal.holidaysBetween(start, end)`, for callers who'd rather not reach into the calendar object directly.
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 intentionally out of scope for this package — `createHolidayCalendar` is the abstraction; populating it with, say, US federal holidays is left to the caller or a separate package.
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)` — a heuristic that compares the value's current offset against its January offset. Correct for the common (Northern Hemisphere) case; Southern Hemisphere zones have DST in January, so this can read backwards there — there's no reliable hemisphere lookup built in.
703
- - `getNextTransition(value)` / `getPreviousTransition(value)` — the nearest DST (or other offset) transition in either direction.
704
- - `getTransitions(start, end)` — every transition within a range.
705
- - `possibleInstantsFor(fields, timeZone)` — the list of possible instants for a given wall-clock time in a zone: empty for a DST gap (the time never happened), two for an overlap (the time happened twice), one otherwise.
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 (ISO 8601's stricter internet-facing cousin).
719
- - `parseRFC2822(input)` / `formatRFC2822(value)` — RFC 2822 (email/HTTP-header-style dates).
720
- - `parseHTTPDate(input)` / `formatHTTPDate(value)` — the HTTP-date format used in `Date`/`Last-Modified` headers.
721
- - `parseSQL(input)` / `formatSQL(value)` — SQL `DATETIME`/`TIMESTAMP` style.
722
- - Epoch conversions, both directions, at second/millisecond/microsecond/nanosecond resolution: `fromUnixSeconds`, `fromUnixMilliseconds`, `fromUnixMicroseconds`, `fromUnixNanoseconds`, `toUnixSeconds`, `toUnixMilliseconds`, `toUnixMicroseconds`, `toUnixNanoseconds`.
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)` — the full structured report shown above.
749
- - `explainFormat(formatStr)` — a human-readable rendering of the same analysis.
750
- - `tokenInfo(name)` — metadata for one token, or `undefined` if it's not recognized.
751
- - `listTokens()` — every recognized token, paired with its metadata.
752
- - `isValidFormat(formatStr)` — `true` iff the tokenizer accepts the string.
753
- - `validateFormat(formatStr)` — throws on an invalid format string; otherwise returns the same analysis `analyzeFormat` does.
754
- - `tokenizeFormat(formatStr)` — the raw literal/token piece list, if you want to walk it yourself.
755
- - `fieldForToken(token)` — which `TemporalLike` field a given token reads.
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` (the full table `tokenInfo`/`listTokens` read from) and `ALL_TOKEN_NAMES` are also exported directly, for callers — [`eslint-plugin-temporal-fmt`](#related-tools), specifically — that want the raw table rather than going through the wrapper functions. `FORMAT_ONLY_TOKENS` lists the tokens `parse()` rejects (`do`, `ww`, `RRRR`, `D`/`DD`/`DDD`, `LLLL`/`LLL`, `cccc`/`ccc`, `GGGG`/`G`, `zzzz`/`z`).
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, by family:**
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 — `parse` re-derives it via the `00–68`/`69–99` rule, which isn't guaranteed to match the original century. |
765
- | Month | `MMMM`, `MMM`, `MM`, `M` | yes | Numeric and name forms both round-trip; only the name forms are locale-sensitive. |
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 — the ordinal suffix isn't parseable back out. |
768
- | Weekday | `EEEE`, `EEE` | yes | Cross-checked against the parsed date, so a mismatch throws rather than round-tripping silently wrong. |
769
- | ISO week | `ww`, `RRRR` | n/a | Format-only — a week number alone can't reconstruct a specific date. |
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
- Two shapes of error come out of this library: plain `Error` objects with a descriptive message (most throw sites in `format()`/`parse()`), and `TemporalFmtError` subclasses (the typed surface `safeParse()`/`tryParse()` classify into).
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
- All subclasses inherit structured fields from `TemporalFmtError`: `code`, `input`, `format`, `token`, `position`, `expected`, `actual`, `reason`.
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` | Structurally valid but nonexistent date (Feb 30), or a weekday/quarter that contradicts the 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 the IANA range (`-12:00` to `+14:00`). |
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 (e.g. `Md` against `"121"`). |
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 the ISO 8601 grammar, or a field is non-finite. |
733
+ | `InvalidDurationError` | `INVALID_DURATION` | Duration string doesn't match ISO 8601 grammar, or a field is non-finite. |
790
734
 
791
- ### Reading the common ones
735
+ ### Common error messages
792
736
 
793
- **"no valid pattern matches the format string and input shape"** — input doesn't match the format string at all. Usually a missing separator, wrong digit count for a fixed-width token, or a `zzz` capture that isn't a real IANA zone id.
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"** — a token reading a field the value doesn't carry. Classic case: `format(plainDate, 'HH:mm')` — `PlainDate` has no hour field. Use `PlainDateTime`, or a date-only format string.
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 the two year tokens in one format string.
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 (year-only) can't become a Temporal value. Add the missing tokens, or use a different format.
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"** — adjacent unpadded numeric tokens with no separator (`Md` against `"121"`) split more than one way. Add a separator (`M-d`), zero-pad (`MM-dd`), or opt into `{ lenient: true }`.
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 in 'Y' out of range (max 14 — Kiritimati, Line Islands is +14:00)"** — the offset's hour component is past the IANA-supported range.
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 input describes a wall-clock time that doesn't exist (a spring-forward gap). Pick a different time, or pass `{ disambiguation: 'compatible' }` and let `resolveZoned` choose an instant.
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"** — the format asks for a zone name and an explicit offset, and they disagree at the parsed instant. Fix the input so they agree.
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 it isn't one
759
+ assertZonedDateTime(value); // throws descriptively if not
816
760
  ```
817
761
 
818
- - `isTemporal(value)`, `isInstant(value)`, `isPlainDate(value)`, `isPlainTime(value)`, `isPlainDateTime(value)`, `isZonedDateTime(value)`, `isPlainYearMonth(value)`, `isPlainMonthDay(value)`, `isDuration(value)`.
819
- - `assertTemporal`, `assertInstant`, `assertPlainDate`, `assertPlainTime`, `assertPlainDateTime`, `assertZonedDateTime`, `assertPlainYearMonth`, `assertPlainMonthDay`, `assertDuration` — same checks, throwing descriptively on mismatch instead of returning a boolean. Useful for narrowing the `unknown` that `parse()` hands back.
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` defaults — a way to set defaults once instead of passing the same options to every call. `mergeWithConfig(config, perCall)` folds a config's defaults into a specific call's options, with the per-call options taking precedence.
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
- The [Locales](#locales) section above notes that numeric tokens always render Western digits by default — that's still the default, but it's opt-out rather than fixed, via `{ numberingSystem }` on `format()` and `{ parseNumberingSystem }` on `parse()`:
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
- The two option names are deliberately different (`numberingSystem` vs. `parseNumberingSystem`), not a naming inconsistency — the two directions aren't always symmetric. You might want Arabic-Indic digits in your UI output without expecting Arabic-Indic digits back on input, or the reverse, so a caller mixing `format()` and `parse()` options in one config object can set each independently. `formatToParts()` applies numbering per-part rather than once at the end, so a caller styling individual token parts (one `<span>` per token, say) still gets correctly-transliterated digits in each part instead of plain ASCII. An unsupported system name throws immediately rather than silently falling back to `'latn'`.
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
- Rather than hardcoding a system code, pass `'auto'` to use whichever numeral system the call's locale itself uses, resolved through `Intl.NumberFormat(locale).resolvedOptions().numberingSystem`:
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' }); // "٢٠٢٦-٠٨-٠٤" — ar-EG's native digits
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 defaults to latn
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
- `'auto'` works the same on both sides and through `createConfig()` (a config's `numberingSystem: 'auto'` resolves per call, against whichever locale that call uses). When the locale's native system isn't one this library can transliterate (Thai digits, Lao, Myanmar, ...), `'auto'` falls back to `'latn'` rather than throwing — only a malformed locale tag throws. Unset still means `'latn'` everywhere; `'auto'` is an opt-in, not a default change.
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
- If you'd rather convert digits yourself instead of going through `format()`/`parse()`'s options — say, transliterating a string that came from somewhere else entirely — the underlying conversion is available directly:
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)` — ASCII digits to a locale's native digits.
859
- - `convertDigitsToAscii(s, system)` — the inverse.
860
- - `applyNumbering(s, options)` / `applyParseNumbering(s, options)` — the same helpers `format()`/`parse()` call internally for `{ numberingSystem }` / `{ parseNumberingSystem }`, exposed directly for anyone building their own formatting layer on top rather than going through `format()`/`parse()`.
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 object with its own custom token(s), which can override a built-in token of the same name if you need different behavior for it. Useful for house-style formats a plain token string can't express, without forking the library.
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
- A handful of functions exist specifically to feed editor tooling — autocomplete, hover docs, inline diagnostics — rather than for use in application code directly:
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 position and suggested fixes for a given format string.
874
- - `previewFormat(formatStr, sample?)` — a live preview string for a format, given an optional sample value.
875
- - `getDocUrl(tokenName)` — a documentation URL for a token, currently pointing at this README's [Tokens](#tokens) section.
876
- - `DAYJS_TO_TEMPORAL_FMT` / `DATE_FNS_TO_TEMPORAL_FMT` — token conversion hint tables, the same ones the CLI's `translate` subcommand and any migration tooling draw from.
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
- Type a subcommand with all its arguments inline (`validate yyyy-MM-dd`) or just the subcommand name — the REPL prompts for whatever's missing, one field at a time. Errors print and the session keeps going; `exit`, `quit`, or Ctrl+D ends it. This is the same subcommand logic as one-shot mode, just wrapped in a loop that asks instead of exiting on a missing argument — one-shot stays there for scripting, and doesn't touch the REPL machinery.
820
+ ## Legacy mod documentation
917
821
 
918
- ## Mods (advanced, optional)
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
- The rest of the API (arithmetic, comparison, rounding, intervals-adjacent helpers not listed above, business calendars, holidays, serialization, config, type guards, typed errors, the analyzer, and IDE tooling data) is only available from the main `temporal-fmt` entry point — there's no dedicated subpath for those yet.
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`, so a bundler with tree-shaking enabled genuinely drops what you don't import. Measured with esbuild: `import { format } from 'temporal-fmt/format'` bundles to ~27KB, versus ~68KB for the same single function pulled from the bare `temporal-fmt` entry — the main entry point re-exports everything, so anything imported from it drags the whole graph along regardless of what you actually call. If bundle size matters for your use case, import from the subpath, not the package root.
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
- Most date-fns/Day.js tokens map directly. The differences:
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` | Numeric UTC offset with colon, `Z` for UTC |
961
- | `ZZ` | `XX` | Numeric UTC offset, no colon |
962
- | `X` | not supported | Unix timestamp — use `fromUnixSeconds` instead |
963
- | `x` | not supported | Unix ms timestamp — use `fromUnixMilliseconds` instead |
964
- | `P` | not supported | Localized long format — write the format string out explicitly |
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
- ### Behavioral differences that will bite you if you skip this
876
+ ### Things that will bite you
981
877
 
982
- 1. **Strict parsing by default.** Day.js silently picks one reading of ambiguous input; this library throws. For glued numeric ambiguity (`Md` against `"121"`), add separators or opt into `{ lenient: true }`.
983
- 2. **Cross-field validation.** Weekday, quarter, and offset are cross-checked against the parsed date — a `Monday` label that disagrees with the actual date throws here, where Day.js would let it slide.
984
- 3. **No silent "now" fallback.** Missing input doesn't fall back to the current time; pass an explicit value.
985
- 4. **Type-preserving.** `format(plainDate, 'HH:mm')` throws — `PlainDate` has no hour field. Day.js would silently use 0. Use `PlainDateTime`, or a date-only format string.
986
- 5. **Temporal-native, not `Date`-native.** Operates on `PlainDate`/`PlainDateTime`/`ZonedDateTime`/etc, not the legacy `Date` object. Convert at the boundary:
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, before and after
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 `dayjs.extend(customParseFormat)` — that plugin parses leniently; this library's `parse()` is strict by default. Audit callers relying on the lenient behavior.
1017
- - Day.js's mutable global locale (`dayjs.locale('fr')`) — this library uses per-call `locale` options, no global mutation.
1018
- - date-fns's `format` with a locale *object* parameter — this library takes BCP-47 strings.
1019
- - `dayjs-timezone` — use `Temporal.ZonedDateTime` and the `zzz`/offset tokens instead.
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, dropping the wrapper once nothing calls the old path anymore.
925
+ Migrate file by file, drop the wrapper when nothing calls the old path anymore.
1034
926
 
1035
927
  ## Related tools
1036
928
 
1037
- Neither of these ships as part of this repository — separate packages. **Both are now deprecated and archived** — single-maintainer bandwidth, no further updates — but the last published versions still work if you want them:
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`). This is what backed the `analyzeFormat(formatStr).warnings` check mentioned in [Introspection and the analyzer](#introspection-and-the-analyzer) — same underlying metadata, surfaced as a lint diagnostic instead of a runtime call.
1040
- - [`temporal-fmt-codemod`](https://github.com/DirazCoder/temporal-fmt-codemod) *(deprecated, archived)* — a jscodeshift AST codemod that rewrote `dayjs(x).format(...)`/date-fns `format(...)` *call sites* across a codebase, not just format-string literals. A different job from the CLI's `translate` subcommand (see [CLI](#cli)), which only translates a format string you hand it and doesn't touch call sites; this was the option for migrating an entire codebase and rewriting the calls themselves.
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
- This library is heavily tested. The `node:test` suite (`test/*.test.js`) runs 1400+ cases covering hand-picked scenarios, fuzzing, and adversarial input, alongside a separate `vitest/` suite unit-testing internals directly. On top of that there's a dedicated conformance suite, smoke tests that check the package actually resolves correctly under CJS/ESM/bundler/nodenext, and type tests. If it's mentioned in this README, it's backed by a test — not just a docstring.
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
- # Conformance fixtures
938
+ ## Conformance fixtures
1047
939
 
1048
- `fixtures.json` is a portable, library-agnostic test-vector set for
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-specific adapter. It
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
- ## Why this is separate from `test/adversarial.test.js` and `test/fuzz.test.js`
944
+ ### Why this is separate from adversarial and fuzz tests
1060
945
 
1061
- Those two check that temporal-fmt is internally consistent under
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
- This folder is different: it checks temporal-fmt against an external,
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
- ## Pattern translation
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 offset, never `Z`) | `xxx` | Only the **uppercase** `X`/`XX`/`XXX` family collapses `+00:00` to `Z` (see `formatOffset()` in `src/tokens.ts`). Lowercase never does, which is exactly `ZZ`'s semantics. Mapping `ZZ` to `XXX` was tried first and is wrong — it fails `offset-ZZ-format-utc-not-Z` and `zone-utc-roundtrip`, both of which expect `+00:00`, not `Z`. |
1078
- | `VV` (IANA zone id) | `zzz` | temporal-fmt's only zone-identity token. |
1079
-
1080
- Translation happens in `translatePattern()` and skips anything inside
1081
- a quoted literal span. Everything else in the fixture set — `yyyy`,
1082
- `y`, `MM`, `dd`, `HH`, `mm`, `ss`, `S`..`SSSSSSSSS`, `h`, `a`,
1083
- `X`/`XX`/`XXX` — already matches temporal-fmt's vocabulary directly.
1084
-
1085
- ## `opinionated` cases
1086
-
1087
- Some cases are flagged `"opinionated": true` right in the fixture.
1088
- These encode a design choice of the fixture's source library, not a
1089
- fact about dates, and temporal-fmt is allowed to disagree with them.
1090
- The adapter still runs them — if temporal-fmt's behavior differs, it
1091
- logs a divergence note (printed at the end of the run) instead of
1092
- failing the suite.
1093
-
1094
- Two cases currently diverge, both `yy`-pivot ones —
1095
- `extreme-year-two-digit-pivot-low` and
1096
- `extreme-year-two-digit-pivot-high`. temporal-fmt refuses `yy` in any
1097
- format string that isn't a complete date (`yyyy`/`yy` + month + day),
1098
- so `parse("yy-MM", ...)` throws an incomplete-date error before the
1099
- question of *which* century a 2-digit year should resolve to ever
1100
- comes up. The fixture's position — that bare `yy-MM` should resolve
1101
- via the 00-68/69-99 ECMAScript pivot — is a convention, not a fact
1102
- about dates; a library is free to pick a different pivot, or, as here,
1103
- decline to guess a century from `yy` alone at all. Both are documented
1104
- design choices with their own passing tests
1105
- (`test/parse.test.js`, `yy pivot: ...`), not something in scope to
1106
- "fix" by adopting the fixture's convention.
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 runs the `node:test` suite under `test/*.test.js` — hand-picked, fuzz, adversarial, and perf cases exercising the public API end to end. It doesn't touch `vitest/`, which unit-tests internals directly (`enumerateValidSplits()`, the function that resolves ambiguous glued numeric runs, being the one most worth having covered — a bug in one of its edge cases can dodge every example in the main suite without being the specific input any of them happens to use). Run `npm run test:all` instead, which also builds first and runs the type tests (`test:types`) — it's the only single command that covers everything CI runs. Plain `npm test` will pass locally even with a broken `vitest/` suite.
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
- A couple of build-specific notes if you're touching the toolchain:
999
+ Build notes if you're touching the toolchain:
1224
1000
 
1225
- - Building requires TypeScript 7.0.2+, but `.d.ts` generation runs as a separate `tsc` pass rather than through `tsup` — `tsup`'s dts step bundles types via `rollup-plugin-dts`, which calls into TypeScript's compiler API, and that API isn't stable yet on 7.x. `tsup.config.ts` sets `dts: false` and `npm run build` runs `tsup && tsc --declaration --emitDeclarationOnly` instead. One side effect: `dist/` has one `.d.ts` per source file rather than a single rolled-up `index.d.ts` — same exported API, different file layout.
1226
- - Tests pull from `temporal-polyfill/full`, not the slim `temporal-polyfill` — the Hebrew-calendar tests need the full build's calendar data. Locale-aware tests pass on Node 20+ regardless of native vs. polyfilled `Temporal`: native goes through `Intl.DateTimeFormat` directly, the polyfill falls back to its own `toLocaleString()`. `parse.test.js` configures `Temporal` via `setTemporal()` rather than mutating `globalThis.Temporal` directly.
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