temporal-fmt 0.9.80 → 0.9.82

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