temporal-fmt 0.8.91 → 0.8.93

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 (2) hide show
  1. package/README.md +595 -684
  2. package/package.json +2 -2
package/README.md CHANGED
@@ -24,6 +24,39 @@ npm install temporal-fmt
24
24
 
25
25
  [View on npm](https://www.npmjs.com/package/temporal-fmt)
26
26
 
27
+ This library is genuinely large — locales, recurrence, business calendars, timezone disambiguation, an analyzer, config layers, custom token extensibility, a CLI. A substantial amount of configuration and customization is packed in here. But none of that is required reading. The reason this library exists in the first place is formatting and parsing dates with token strings, and that part stays simple: `format(temporal, formatStr)` and `parse(formatStr, input)`, the same shape as date-fns or Day.js. Read [Providing `Temporal`](#providing-temporal) and [Formatting](#formatting)/[Parsing](#parsing), and you're covered for the common case — everything past that is there for when you actually need it, not before.
28
+
29
+ ## Contents
30
+
31
+ - [Providing `Temporal`](#providing-temporal)
32
+ - [Formatting](#formatting)
33
+ - [Parsing](#parsing)
34
+ - [Locales](#locales)
35
+ - [Tokens](#tokens)
36
+ - [Duration formatting](#duration-formatting)
37
+ - [Relative time](#relative-time)
38
+ - [Natural-language date parsing](#natural-language-date-parsing)
39
+ - [Date arithmetic, comparison, and rounding](#date-arithmetic-comparison-and-rounding)
40
+ - [Intervals](#intervals)
41
+ - [Recurrence](#recurrence)
42
+ - [Business calendars and holidays](#business-calendars-and-holidays)
43
+ - [Time zones](#time-zones)
44
+ - [Serialization](#serialization)
45
+ - [Introspection and the analyzer](#introspection-and-the-analyzer)
46
+ - [Typed errors](#typed-errors)
47
+ - [Type guards](#type-guards)
48
+ - [Config](#config)
49
+ - [Extending with custom tokens](#extending-with-custom-tokens)
50
+ - [IDE tooling data](#ide-tooling-data)
51
+ - [CLI](#cli)
52
+ - [Subpath imports](#subpath-imports)
53
+ - [Migrating from Day.js or date-fns](#migrating-from-dayjs-or-date-fns)
54
+ - [Known limitations](#known-limitations)
55
+ - [Related tools](#related-tools)
56
+ - [Testing](#testing)
57
+ - [Contributing](#contributing)
58
+ - [License](#license)
59
+
27
60
  ## Providing `Temporal`
28
61
 
29
62
  ### Node 26+
@@ -32,8 +65,7 @@ Temporal is native and used automatically.
32
65
 
33
66
  ### Polyfill
34
67
 
35
- Use a polyfill like [`temporal-polyfill`](https://github.com/fullcalendar/temporal-polyfill) to implement Temporal
36
- in the global namespace.
68
+ Use a polyfill like [`temporal-polyfill`](https://github.com/fullcalendar/temporal-polyfill) to put Temporal on the global namespace.
37
69
 
38
70
  ```js
39
71
  import 'temporal-polyfill/global'
@@ -42,23 +74,22 @@ import { format, parse } from 'temporal-fmt';
42
74
  parse(...);
43
75
  ```
44
76
 
45
- ### Bring Your Own
77
+ ### Bring your own
46
78
 
47
- Set a Temporal implementation explicitly, once, before your app's first
48
- `format()`/`parse()` call:
79
+ Set a Temporal implementation explicitly, once, before your app's first `format()`/`parse()` call:
49
80
 
50
81
  ```js
51
82
  import { Temporal } from 'temporal-polyfill/full';
52
83
  import { setTemporal, format, parse } from 'temporal-fmt';
53
84
 
54
- setTemporal(Temporal); // once, before using `format` or `parse`.
85
+ setTemporal(Temporal); // once, before using format or parse
55
86
  ```
56
87
 
57
- `setTemporal()` takes precedence over native or global Temporal, and calling
58
- it again overrides whatever was set before. Useful when you don't want to
59
- pollute the global namespace, like for libraries.
88
+ `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`.
89
+
90
+ 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()`.
60
91
 
61
- ## Usage
92
+ ## Formatting
62
93
 
63
94
  ```js
64
95
  import { format } from 'temporal-fmt';
@@ -74,14 +105,17 @@ const zdt = Temporal.ZonedDateTime.from('2026-08-04T15:45:30-04:00[America/New_Y
74
105
  format(zdt, 'yyyy-MM-dd HH:mm zzz'); // "2026-08-04 15:45 America/New_York"
75
106
  ```
76
107
 
77
- Wrap literal text in single quotes, like `'at'` above. Need an actual single
78
- quote in your output? Use `''`.
108
+ Wrap literal text in single quotes, like `'at'` above. Need an actual single quote in your output? Use `''`.
109
+
110
+ 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.
79
111
 
80
- ## Parsing a string
112
+ - `format(temporal, formatStr, options?)` — formats a Temporal value against a token string. Returns a string.
113
+ - `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).
114
+ - `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.
81
115
 
82
- `parse` builds a `Temporal.PlainDate` / `PlainTime` / `PlainDateTime` /
83
- `ZonedDateTime` out of a string, picking whichever type fits the tokens
84
- present:
116
+ ## Parsing
117
+
118
+ `parse()` builds a real `Temporal.PlainDate` / `PlainTime` / `PlainDateTime` / `ZonedDateTime` out of a string, picking whichever type fits the tokens present:
85
119
 
86
120
  ```js
87
121
  import { parse } from 'temporal-fmt';
@@ -91,47 +125,62 @@ parse('yyyy-MM', '2026-08-04T15:45:30'); // throws — shape doesn't ma
91
125
  parse('yyyy-MM-dd', '2026-02-30'); // throws — not a real date
92
126
  ```
93
127
 
94
- Because the format is unknown at runtime you will need to check the result
95
- with `instanceof`, or manually assert/type guard it in Typescript, to narrow the type.
128
+ 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).
96
129
 
97
- Since `parse` constructs a real value rather than just matching shape, it
98
- catches an impossible date like February 30th, or a weekday name that
99
- doesn't match the date it's paired with:
130
+ 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:
100
131
 
101
132
  ```js
102
133
  parse('EEEE, yyyy-MM-dd', 'Tuesday, 2026-08-04'); // fine — that really is a Tuesday
103
134
  parse('EEEE, yyyy-MM-dd', 'Monday, 2026-08-04'); // throws — it isn't
104
135
  ```
105
136
 
106
- `parse` throws when `input` doesn't match `formatStr`'s shape at all
107
- or throws a descriptive error if the computed date is not valid.
108
-
109
- A few things worth knowing:
110
-
111
- - **`yy` (2-digit year)** emulates POSIX-style [strptime](https://www.man7.org/linux//man-pages/man3/strptime.3p.html): `00–68`
112
- becomes `2000–2068`, `69–99` becomes `1900–1999`.
113
- - this is an opinionated tradeoff but ensures `yy` is deterministic without an external date reference
114
- - **`hh`/`h` (12-hour) without an `a` token throws** — same if a format string mixes `HH`/`H` with `hh`/`h`,
115
- even when both agree on the same hour. `parse` won't guess which one is authoritative; pick one.
116
- - **`HH`/`H` combined with `a` is allowed, and cross-checked** — `parse('HH:mm a', '13:05 PM')` succeeds since
117
- 13:05 can only mean PM, but `parse('HH:mm a', '01:05 PM')` throws: the day period contradicts the hour.
118
- This is different from mixing `HH` with `hh`/`h` abovethere's only one hour token here, `a` just confirms it.
119
- - **`a` (AM/PM) matches case-insensitively** `pm`, `Pm`, and `PM` all parse the same way. Month and weekday
120
- names (`MMMM`, `EEEE`, etc.) stay case-sensitive; only the day-period marker is case-folded.
121
- - **`S` through `SSSSSSSSS` reach micro/nanosecond precision, not just milliseconds.** `SSS` is unchanged (3-digit
122
- ms). Wider tokens expose whatever sub-millisecond precision the `Temporal` value actually carries useful for
123
- round-tripping machine-generated timestamps (DB exports, instrumentation logs) without silently truncating to
124
- ms. Format truncates to the requested width (never rounds); parse right-pads short input, so `SSSSSSSSS` reading
125
- `.5` means 500ms-worth of nanoseconds (`500000000`), not 5 nanoseconds.
126
- - **`MMMM`/`MMM` name matching assumes a 12-month calendar** — the vocabulary
127
- it matches against is generated from 12 Gregorian reference dates, so a
128
- calendar with a leap month (e.g. Hebrew's 13-month leap years) isn't fully
129
- covered by month *names*. Numeric `yyyy-MM-dd` round-trips aren't affected.
130
-
131
- ## Locale support
132
-
133
- Pass a BCP 47 locale tag as a third argument and month names, weekday names,
134
- and AM/PM markers all localize accordingly. Defaults to `'en-US'` if you don't.
137
+ `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.
138
+
139
+ Three other entry points, same underlying logic, different failure handling:
140
+
141
+ - `safeParse(formatStr, input, options?)` — never throws. Returns `{ ok: true, value }` or `{ ok: false, error: TemporalFmtError }`.
142
+ - `tryParse(formatStr, input, options?)` never throws. Returns the value, or `undefined` on any failure.
143
+ - `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.
144
+ - `compileParser(formatStr, options?)` pre-compiles a parser for repeated use against the same format string.
145
+
146
+ A few things worth knowing about how `parse()` behaves:
147
+
148
+ - **`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.
149
+ - **`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.
150
+ - **`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.
151
+ - **`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.
152
+ - **`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.
153
+ - **`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.
154
+
155
+ ### Ambiguous input and lenient mode
156
+
157
+ 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.
158
+
159
+ ```js
160
+ parse('yyyy-Md', '2026-121') // throws — ambiguous
161
+ ```
162
+
163
+ Pass `{ lenient: true }` to opt into a documented heuristic instead:
164
+
165
+ ```js
166
+ parse('yyyy-Md', '2026-121', { lenient: true }).toString() // '2026-12-01'
167
+ ```
168
+
169
+ **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.
170
+
171
+ ### Offset tokens (`X`/`XX`/`XXX`/`x`/`xx`/`xxx`)
172
+
173
+ 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.
174
+
175
+ 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.
176
+
177
+ 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`.
178
+
179
+ Range: `-12:00` to `+14:00`, the IANA-supported range. Out-of-range values throw a descriptive error naming the bound.
180
+
181
+ ## Locales
182
+
183
+ Pass a BCP 47 locale tag via `options.locale` and month names, weekday names, and AM/PM markers all localize. Defaults to `'en-US'`.
135
184
 
136
185
  ```js
137
186
  format(date, 'MMMM d, yyyy', { locale: 'fr-FR' }); // "août 4, 2026"
@@ -139,131 +188,188 @@ format(date, 'EEEE d MMMM', { locale: 'ar-EG' }); // Arabic weekday/month nam
139
188
  format(dt, 'h:mm a', { locale: 'ja-JP' }); // "3:45 午後"
140
189
  ```
141
190
 
142
- The named fields (`MMMM`, `MMM`, `EEEE`, `EEE`, `a`) go through
143
- `Intl.DateTimeFormat` under the hood, which means non-Gregorian calendars
144
- work too, as long as the `Temporal` object is already carrying one:
191
+ 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:
145
192
 
146
193
  ```js
147
194
  const hebrewDate = date.withCalendar('hebrew');
148
195
  format(hebrewDate, 'MMMM d, yyyy'); // "Av 21, 5786"
149
196
  ```
150
197
 
151
- The above holds true for `parse` as well:
198
+ 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`.
199
+
200
+ The same locale handling applies to `parse()`:
152
201
 
153
202
  ```js
154
- parse('MMMM d, yyyy','août 4, 2026', { locale: 'fr-FR' });
203
+ parse('MMMM d, yyyy', 'août 4, 2026', { locale: 'fr-FR' });
155
204
  parse('h:mm a', '3:45 午後', { locale: 'ja-JP' });
156
- // `-u-ca-` calendar extension parses into that calendar
157
- parse('yyyy-MM-dd', '5786-11-21', { locale: 'en-u-ca-hebrew' });
205
+ parse('yyyy-MM-dd', '5786-11-21', { locale: 'en-u-ca-hebrew' }); // -u-ca- extension parses into that calendar
158
206
  ```
159
207
 
160
- **Numeric fields (`yyyy`, `MM`, `dd`, `HH`, `mm`, `ss`, `SSS`) always come out
161
- in Western (0-9) digits, no matter what locale you pass.** On purpose. Most
162
- things reading this output back in — logs, APIs, filenames — want boring,
163
- predictable ASCII digits, and locale-native numeral systems like Arabic-Indic
164
- or Devanagari don't play nicely with this library's zero-padding logic anyway.
165
- Need localized digits? Run the numeric pieces through `Intl.NumberFormat`
166
- yourself.
208
+ **Numeric fields always come out in Western (0–9) digits**, regardless of locale. On purpose — logs, APIs, and filenames reading this output back in want boring ASCII digits, and locale-native numeral systems (Arabic-Indic, Devanagari) don't play nicely with the zero-padding logic here. Need localized digits on the numeric pieces? See [Numbering systems](#numbering-systems) below, or run the output through `Intl.NumberFormat` yourself.
209
+
210
+ ### Registering custom vocabulary
211
+
212
+ `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.
213
+
214
+ **`registerLocaleVocab(locale, vocab)`** — base vocabulary: months, weekdays, day periods only.
215
+
216
+ ```js
217
+ import { registerLocaleVocab } from 'temporal-fmt';
218
+
219
+ registerLocaleVocab('en-u-ca-hebrew-leap', {
220
+ monthLong: ['Nisan', 'Iyar', 'Sivan', 'Tammuz', 'Av', 'Elul', 'Tishrei', 'Marcheshvan', 'Kislev', 'Tevet', 'Shevat', 'Adar I', 'Adar II'],
221
+ monthShort: ['Nis', 'Iyy', 'Siv', 'Tam', 'Av', 'Elu', 'Tish', 'Chesh', 'Kis', 'Tev', 'Shv', 'Ad1', 'Ad2'],
222
+ weekdayLong: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'],
223
+ weekdayShort: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
224
+ dayPeriod: ['AM', 'PM'],
225
+ });
226
+
227
+ const date = Temporal.PlainDate.from('2026-08-04').withCalendar('hebrew');
228
+ format(date, 'MMMM d, yyyy', { locale: 'en-u-ca-hebrew-leap' }); // "Av 4, 5786" (or similar)
229
+ ```
230
+
231
+ **`registerLocale(locale, vocab)`** — extended vocabulary: everything `registerLocaleVocab` covers, plus quarters, eras, ordinals, duration units, and relative-time language:
232
+
233
+ ```js
234
+ import { registerLocale } from 'temporal-fmt';
235
+
236
+ registerLocale('test-locale-1', {
237
+ // base vocab (required)
238
+ monthLong: [/* 12 entries */],
239
+ monthShort: [/* 12 entries */],
240
+ weekdayLong: [/* 7 entries */],
241
+ weekdayShort: [/* 7 entries */],
242
+ dayPeriod: ['AM', 'PM'],
243
+ // extended vocab (optional)
244
+ quartersLong: ['First', 'Second', 'Third', 'Fourth'],
245
+ quartersShort: ['Q1', 'Q2', 'Q3', 'Q4'],
246
+ erasLong: ['BCE', 'CE'],
247
+ erasShort: ['BC', 'AD'],
248
+ ordinals: ['st', 'nd', 'rd', 'th'],
249
+ durationUnits: { years: ['year', 'years'] /* , ... */ },
250
+ relativeTime: { past: 'ago', future: 'in', now: 'now' },
251
+ });
252
+ ```
253
+
254
+ `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.
255
+
256
+ 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.
167
257
 
168
258
  ## Tokens
169
259
 
170
- | Token | Meaning | Example |
171
- |-------|--------------------|---------|
172
- | yyyy | 4-digit year | 2026 |
173
- | yy | 2-digit year | 26 |
174
- | MMMM | full month name | August |
175
- | MMM | short month name | Aug |
176
- | MM | 2-digit month | 08 |
177
- | M | month | 8 |
178
- | dd | 2-digit day | 04 |
179
- | d | day | 4 |
180
- | do | ordinal day (English-only: 1st, 2nd, 3rd, 4th, ... 11th/12th/13th, ... 21st) | 4th |
181
- | EEEE | full weekday | Tuesday |
182
- | EEE | short weekday | Tue |
183
- | HH | 2-digit hour (24h) | 15 |
184
- | H | hour (24h) | 15 |
185
- | hh | 2-digit hour (12h) | 03 |
186
- | h | hour (12h) | 3 |
187
- | mm | 2-digit minute | 45 |
188
- | m | minute | 45 |
189
- | ss | 2-digit second | 30 |
190
- | s | second | 30 |
191
- | SSS | milliseconds (3 digits) | 000 |
192
- | SSSSSSSSS...S | fractional second, 1-9 digits — `S` through `SSSSSSSSS`. `SSS` is the common 3-digit millisecond case; wider widths reach micro/nanosecond precision. Format slices from the underlying nanosecond value (never rounds); parse right-pads whatever digits it captured (`.5` under `SSSSSSSSS` means 500,000,000ns, not 5ns) | `SSSSSSSSS` → 123456789 |
193
- | a | AM/PM (case-insensitive on parse) | PM |
194
- | Q | numeric quarter (1-4) | 3 |
195
- | QQQ | quarter with "Q" prefix (Q1, Q2, Q3, Q4) | Q3 |
196
- | ww | ISO 8601 week (01-53), format-only | 32 |
197
- | RRRR | ISO 8601 week-numbering year, format-only | 2026 |
198
- | zzz | IANA time zone id | America/New_York |
199
- | X | UTC offset, hours only (or `Z` for UTC); minutes appended when non-zero, no colon | `+05` / `+0530` / `Z` |
200
- | XX | UTC offset, hours+minutes, no colon (or `Z`) | `+0500` / `Z` |
201
- | XXX | UTC offset, hours+minutes with colon (or `Z`) | `+05:00` / `Z` |
202
- | x | same widths as `X` but never `Z` always numeric, even for UTC | `+05` / `+0530` / `+00` |
203
- | xx | same as `XX` but never `Z` | `+0500` / `+0000` |
204
- | xxx | same as `XXX` but never `Z` | `+05:00` / `+00:00` |
205
-
206
- `do` is format-only (parse() rejects it — the "st"/"nd"/"rd"/"th" suffix isn't structurally distinguishable from adjacent literal text in a parse context). The English-only suffix rule is on purpose — locale-aware ordinals are out of scope; `Intl.DateTimeFormat` has no part type for ordinals, and the rest of this library routes locale-specific names through it.
207
-
208
- `Q` and `QQQ` both format and parse. On parse, they cross-check against any month/date tokens present in the same format string, the same way `EEEE` cross-checks weekday against date — throw if they disagree.
209
-
210
- `ww` and `RRRR` are format-only. Parsing "ww"/"RRRR" back into a date requires resolving an ISO week + a weekday to a specific date, which is a different parsing surface than the token-based `parse()` here.
211
-
212
- `RRRR` is the **ISO week-numbering year**, not the calendar year — they can differ at year boundaries. Dec 29-31 often belong to week 1 of the *next* year; Jan 1-3 often belong to week 52/53 of the *previous* year. Examples: `format(PlainDate.from('2026-12-31'), 'ww RRRR')` → `"53 2026"`; `format(PlainDate.from('2027-01-01'), 'ww RRRR')` → `"53 2026"` (Friday in ISO year 2026's week 53); `format(PlainDate.from('2027-01-04'), 'ww RRRR')` → `"01 2027"` (Monday starting ISO week 1 of 2027).
213
-
214
- The six offset tokens (`X`/`XX`/`XXX`/`x`/`xx`/`xxx`) are the standard date-fns/Unicode-LDML UTC offset family. They only work on `ZonedDateTime`; on a `PlainDate`/`PlainTime`/`PlainDateTime` they throw the same "requires offset, which this Temporal object doesn't have" error the other field-typed tokens throw when their field is missing. Uppercase variants (`X`/`XX`/`XXX`) collapse `+00:00` to `Z` for UTC; lowercase variants (`x`/`xx`/`xxx`) always emit a numeric offset, even for UTC (`+00`, `+0000`, `+00:00`). `X` and `x` (single-letter) drop minutes when zero (`+05` rather than `+0500`) and append them with no colon when non-zero (`+0530`) — matches the LDML spec's "hours required, minutes optional when zero" rule.
215
-
216
- On parse, an offset token requires a full date and time (year, month, day, and at least one time token) to anchor the instant, same rule `zzz` already enforces. A pattern with an offset token but no `zzz` produces a `ZonedDateTime` whose `timeZoneId` is the offset string itself (e.g. `"+09:00"`). A pattern with **both** `zzz` and an offset token is a cross-check: `zzz` wins for the result's `timeZoneId` (the IANA name is the meaningful label; the offset is a derived fact about that zone at this instant), and the offset token's value must match the zone's actual offset at the parsed wall-clock instant. If they disagree — e.g. `yyyy-MM-dd HH:mm zzz XXX` against `2026-08-04 15:45 America/New_York +09:00` (August in New York is `-04:00`, not `+09:00`) — parse() throws rather than silently picking one, same contract as the EEEE-vs-date and Q-vs-month cross-checks elsewhere in this library. Range checks: `-12:00` to `+14:00` (the IANA-supported range). Out-of-range values throw a descriptive error naming the bound, not a generic "no valid pattern matches".
217
-
218
- Try to use a token your input type doesn't support `HH` on a `PlainDate`,
219
- say — and you'll get a real error telling you so, not a silent `undefined`
220
- sitting in your output waiting to confuse someone in three weeks.
260
+ | Token | Meaning | Example |
261
+ |-------|---------|---------|
262
+ | `yyyy` | Four-digit year (preserves sign for BCE) | `2026` |
263
+ | `yy` | Two-digit year (`year % 100`; throws on negative years) | `26` |
264
+ | `MMMM` | Long month name, locale-aware | `August` |
265
+ | `MMM` | Short month name, locale-aware | `Aug` |
266
+ | `MM` | Two-digit month, zero-padded | `08` |
267
+ | `M` | One- or two-digit month | `8` |
268
+ | `dd` | Two-digit day-of-month, zero-padded | `04` |
269
+ | `d` | One- or two-digit day-of-month | `4` |
270
+ | `do` | Ordinal day-of-month, English suffix. Format-only | `4th` |
271
+ | `EEEE` | Long weekday name, locale-aware. Cross-checked against the date on parse | `Tuesday` |
272
+ | `EEE` | Short weekday name, locale-aware. Cross-checked on parse | `Tue` |
273
+ | `HH` | Two-digit hour, 24-hour, zero-padded | `15` |
274
+ | `H` | One- or two-digit hour, 24-hour | `15` |
275
+ | `hh` | Two-digit hour, 12-hour, zero-padded. Needs `a` on parse | `03` |
276
+ | `h` | One- or two-digit hour, 12-hour. Needs `a` on parse | `3` |
277
+ | `mm` | Two-digit minute, zero-padded | `45` |
278
+ | `m` | One- or two-digit minute | `45` |
279
+ | `ss` | Two-digit second, zero-padded | `30` |
280
+ | `s` | One- or two-digit second | `30` |
281
+ | `S` … `SSSSSSSSS` | Fractional second, 1–9 digits (tenths through nanoseconds) | `SSS` → `000` |
282
+ | `a` | Day period, locale-aware, case-insensitive on parse | `PM` |
283
+ | `zzz` | IANA time zone id, or fixed offset. Needs full date+time on parse | `America/New_York` |
284
+ | `zzzz` | Localized long time zone name. Format-only | `Eastern Standard Time` |
285
+ | `z` | Localized short time zone name. Format-only | `EST` |
286
+ | `X` | UTC offset, short, `Z` for UTC | `+05` / `+0530` / `Z` |
287
+ | `XX` | UTC offset, no colon, `Z` for UTC | `+0500` / `Z` |
288
+ | `XXX` | UTC offset, with colon, `Z` for UTC | `+05:00` / `Z` |
289
+ | `x` | Same as `X`, never `Z` | `+05` / `+0530` / `+00` |
290
+ | `xx` | Same as `XX`, never `Z` | `+0500` / `+0000` |
291
+ | `xxx` | Same as `XXX`, never `Z` | `+05:00` / `+00:00` |
292
+ | `Q` | Quarter, digit (1–4). Cross-checked against month on parse | `3` |
293
+ | `QQQ` | Quarter with "Q" prefix. Cross-checked on parse | `Q3` |
294
+ | `ww` | ISO 8601 week number (01–53). Format-only | `32` |
295
+ | `RRRR` | ISO 8601 week-numbering year. Format-only | `2026` |
296
+ | `D` | Day of year, unpadded. Format-only | `216` |
297
+ | `DD` | Day of year, 2-digit minimum. Format-only | `216` |
298
+ | `DDD` | Day of year, 3-digit zero-padded. Format-only | `216` |
299
+ | `LLLL` | Stand-alone long month name (nominative case). Identical to `MMMM` in most locales | `August` |
300
+ | `LLL` | Stand-alone short month name. Identical to `MMM` in most locales | `Aug` |
301
+ | `cccc` | Stand-alone long weekday name. Identical to `EEEE` in most locales | `Tuesday` |
302
+ | `ccc` | Stand-alone short weekday name. Identical to `EEE` in most locales | `Tue` |
303
+ | `GGGG` | Long era name, locale-aware. Format-only | `Anno Domini` |
304
+ | `G` | Short era name, locale-aware. Format-only | `AD` |
305
+
306
+ 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).
307
+
308
+ **`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.
309
+
310
+ **`Q`/`QQQ`** both format and parse, cross-checking against any month/date tokens present in the same string — same contract `EEEE` uses for weekday.
311
+
312
+ **`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).
313
+
314
+ **`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.
221
315
 
222
316
  ## Duration formatting
223
317
 
224
- `formatDuration(duration, formatStr, options?)` formats a `Temporal.Duration` (or a plain field bag `{ years, months, weeks, days, hours, minutes, seconds, milliseconds }`) with a duration-specific token set. A duration doesn't sit on a calendar — it has no year/month/day position the way a PlainDate does — so the date/time token table above doesn't apply.
318
+ `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.
225
319
 
226
- Token grammar: each unit has three forms, in increasing verbosity.
320
+ Each unit has three forms, increasing in verbosity:
227
321
 
228
322
  | Token | Form | Example |
229
323
  |-------|------|---------|
230
- | `y` / `yy` / `yyy` | numeric / short / long (years) | `2` / `2yr` / `2 years` |
231
- | `o` / `oo` / `ooo` | numeric / short / long (months) | `2` / `2mo` / `2 months` |
232
- | `w` / `ww` / `www` | weeks | `2` / `2wk` / `2 weeks` |
233
- | `d` / `dd` / `ddd` | days | `2` / `2d` / `2 days` |
234
- | `h` / `hh` / `hhh` | hours | `2` / `2h` / `2 hours` |
235
- | `m` / `mm` / `mmm` | minutes | `2` / `2m` / `2 minutes` |
236
- | `s` / `ss` / `sss` | seconds | `2` / `2s` / `2 seconds` |
237
- | `S` / `SS` / `SSS` | milliseconds | `2` / `2ms` / `2 milliseconds` |
324
+ | `y` / `yy` / `yyy` | numeric / short / long years | `2` / `2yr` / `2 years` |
325
+ | `o` / `oo` / `ooo` | numeric / short / long months | `2` / `2mo` / `2 months` |
326
+ | `w` / `ww` / `www` | numeric / short / long — weeks | `2` / `2wk` / `2 weeks` |
327
+ | `d` / `dd` / `ddd` | numeric / short / long — days | `2` / `2d` / `2 days` |
328
+ | `h` / `hh` / `hhh` | numeric / short / long — hours | `2` / `2h` / `2 hours` |
329
+ | `m` / `mm` / `mmm` | numeric / short / long — minutes | `2` / `2m` / `2 minutes` |
330
+ | `s` / `ss` / `sss` | numeric / short / long — seconds | `2` / `2s` / `2 seconds` |
331
+ | `S` / `SS` / `SSS` | numeric / short / long — milliseconds | `2` / `2ms` / `2 milliseconds` |
238
332
 
239
- The short and long forms are plural-aware (singular for value 1, plural otherwise).
333
+ Short and long forms are plural-aware (singular at 1, plural otherwise).
240
334
 
241
335
  ```js
242
336
  import { formatDuration } from 'temporal-fmt';
243
337
 
244
- formatDuration({ years: 2, months: 3 }, 'yyy ooo') // "2 years 3 months"
338
+ formatDuration({ years: 2, months: 3 }, 'yyy ooo') // "2 years 3 months"
245
339
  formatDuration({ hours: 2, minutes: 30 }, 'hhh mmm') // "2 hours 30 minutes"
246
340
  formatDuration({ hours: 2, minutes: 30 }, 'h:mm') // "2:30"
247
341
  ```
248
342
 
249
- **Zero-value handling**: by default, zero-value units are omitted from the output. `formatDuration({ hours: 2 }, 'hhh mmm')` returns `"2 hours "` (the trailing space is the literal separator from the format string the codemod doesn't do separator cleanup; the caller is responsible for structuring the format string). Pass `{ showZeroValues: true }` to force zero-value units to render.
343
+ **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.
250
344
 
251
- **Locale-aware unit names**: pass a `locale` option to localize the short/long forms via `Intl.NumberFormat`'s `style: 'unit'` mode same approach `formatDistance` uses for `Intl.RelativeTimeFormat`. Numeric-only tokens (`y`, `o`, `w`, ...) stay ASCII digits regardless of locale, matching the rest of the library's "numbers stay Western" convention.
345
+ **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.
252
346
 
253
347
  ```js
254
348
  formatDuration({ hours: 2, minutes: 30 }, 'hhh mmm', { locale: 'fr-FR' }) // "2 heures 30 minutes"
255
349
  formatDuration({ hours: 2, minutes: 30 }, 'hhh mmm', { locale: 'es-ES' }) // "2 horas 30 minutos"
256
350
  formatDuration({ hours: 2, minutes: 30 }, 'hhh mmm', { locale: 'de-DE' }) // "2 Stunden 30 Minuten"
257
- formatDuration({ milliseconds: 5 }, 'SSS', { locale: 'fr-FR' }) // "5 millisecondes"
258
351
  ```
259
352
 
260
- Without a `locale`, the original English hardcoded singular/plural table is used byte-identical to previous versions. This is additive: existing calls with no `locale` produce the same output as before. (Passing `locale: 'en-US'` explicitly is *not* identical to no-locale — Intl's spacing differs from the hand-rolled English table, e.g. `"2 hr"` vs `"2h"`. Pick the path that matches your needs.)
353
+ 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.
354
+
355
+ ### Other duration functions
356
+
357
+ - `formatDurationToParts(duration, formatStr, options?)` — `formatDuration`, but returns parts instead of a joined string.
358
+ - `parseDuration(input, formatStr, options?)` — the inverse of `formatDuration`: parses a formatted string back into duration fields.
359
+ - `parseISODuration(input)` / `formatISODuration(duration)` — parse/format the ISO 8601 duration grammar (`P1Y2M3DT4H5M6S`).
360
+ - `balanceDuration(duration)` — normalizes fields into their natural ranges (e.g. 90 minutes → 1 hour 30 minutes).
361
+ - `totalDuration(duration, unit)` — sums a duration's absolute fields into a single number in the target unit (`'days' | 'hours' | 'minutes' | 'seconds' | 'milliseconds' | 'microseconds' | 'nanoseconds'`).
362
+ - `compareDuration(a, b)` — `-1`/`0`/`1` by total absolute length.
363
+ - `addDuration(a, b)` / `subtractDuration(a, b)` — field-by-field sum/difference.
364
+ - `roundDuration(duration, options)` — round a duration to a unit; see [Date arithmetic, comparison, and rounding](#date-arithmetic-comparison-and-rounding).
261
365
 
262
- Milliseconds *are* supported by `Intl.NumberFormat`'s unit list on every Node version this library targets — confirmed against the current Intl spec, not assumed. The original task brief flagged this as a possible gap; empirically it isn't.
366
+ ## Relative time
263
367
 
264
- ## Relative time: formatDistance
368
+ Two different things live under this heading — pick based on what you want back.
265
369
 
266
- `formatDistance(date1, date2, options?)` returns a human-readable relative-time string — "3 days ago", "in 2 hours", "now". Delegates unit names and pluralization to `Intl.RelativeTimeFormat` so the output localizes the same way the rest of the library's locale-aware tokens do.
370
+ ### `formatDistance` — "3 days ago"
371
+
372
+ `formatDistance(date1, date2, options?)` returns a human-readable relative-time string, delegating unit names and pluralization to `Intl.RelativeTimeFormat`.
267
373
 
268
374
  ```js
269
375
  import { formatDistance } from 'temporal-fmt';
@@ -271,369 +377,283 @@ import { formatDistance } from 'temporal-fmt';
271
377
  const today = Temporal.PlainDate.from('2026-08-04');
272
378
  const yesterday = Temporal.PlainDate.from('2026-08-03');
273
379
 
274
- formatDistance(today, yesterday) // "yesterday" (numeric: 'auto')
275
- formatDistance(today, yesterday, { numeric: 'always' }) // "1 day ago"
276
- formatDistance(today, today) // "now"
380
+ formatDistance(today, yesterday) // "yesterday" (numeric: 'auto')
381
+ formatDistance(today, yesterday, { numeric: 'always' }) // "1 day ago"
382
+ formatDistance(today, today) // "now"
277
383
  formatDistance(today, today.add({ days: 2 }), { locale: 'fr-FR' }) // "dans 2 jours"
278
384
  ```
279
385
 
280
- **Direction convention**: `diff = date1 - date2`. Positive diff → date1 is in the future relative to date2 → "in X". Negative diff → date1 is in the past → "X ago". Swap the args to flip the direction.
386
+ **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.
281
387
 
282
- **Unit-selection cutoffs** (defaults documented below; per-call override via the `cutoffs` option):
388
+ **Unit-selection cutoffs** (defaults below; override any subset per call via `{ cutoffs }`):
283
389
 
284
390
  | abs(diff) | Unit | Default cutoff |
285
- |-----------|------|----------------|
391
+ |-----------|------|-----------------|
286
392
  | < 60 seconds | seconds | `seconds: 60` |
287
393
  | < 60 minutes | minutes | `minutes: 60` |
288
394
  | < 24 hours | hours | `hours: 24` |
289
395
  | < 30 days | days | `days: 30` |
290
- | < 365 days | months | `months: 365` (in days — see note) |
396
+ | < 365 days | months | `months: 365` (expressed in days — see below) |
291
397
  | otherwise | years | — |
292
398
 
293
- 30 days is an approximation of a month (calendar months are 28-31 days); 365 days is an approximation of a year. These are the same cutoffs date-fns uses, trimmed to the units `Intl.RelativeTimeFormat` supports across engines.
294
-
295
- The `months` cutoff is in days, not months — "months" itself isn't a fixed number of days, so the months→years boundary is expressed as a day count, matching how the original hardcoded table expressed the same boundary (`30 * MS_PER_DAY` for days, `365 * MS_PER_DAY` for the months cap). This lets a caller say "treat anything under 90 days as months" rather than "anything under 3 months as months" (which would require picking a definition of "month").
296
-
297
- Override any subset of the boundaries per call. Unspecified boundaries fall back to the defaults above. Throws descriptively on non-monotonic boundaries (e.g. `seconds: 300, minutes: 1` — 300s > 1min, so the seconds branch would always win and the minutes branch would be unreachable) or non-positive values, rather than producing confusing output downstream.
298
-
299
- ```js
300
- formatDistance(in5d, today) // "in 5 days" (default cutoffs)
301
- formatDistance(in14d, today, { cutoffs: { days: 10 } }) // "this month" (14d > 10d)
302
- formatDistance(in200d, today, { cutoffs: { months: 100 } }) // "this year" (200d > 100d)
303
- formatDistance(in30d, today) // "next month" (exactly at default 30d boundary → next unit up)
304
- ```
305
-
306
- Accepts `Temporal.PlainDate`, `PlainDateTime`, or `ZonedDateTime`. A `PlainDate` is treated as midnight when diffing 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).
307
-
308
- ## Lenient parse mode
309
-
310
- By default, `parse()` throws when an ambiguous glued numeric run (e.g. `"121"` against `yyyy-Md`) has more than one valid split. The library refuses to guess — silently picking one would mean returning a value indistinguishable from a different, equally-valid value the same input could describe.
311
-
312
- Pass `{ lenient: true }` to opt into a documented heuristic that picks one split instead of throwing:
399
+ 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.
313
400
 
314
401
  ```js
315
- parse('yyyy-Md', '2026-121') // throws ambiguous
316
- parse('yyyy-Md', '2026-121', { lenient: true }).toString() // '2026-12-01'
402
+ formatDistance(in5d, today) // "in 5 days" (default cutoffs)
403
+ formatDistance(in14d, today, { cutoffs: { days: 10 } }) // "this month" (14d > 10d)
404
+ formatDistance(in200d, today, { cutoffs: { months: 100 } }) // "this year" (200d > 100d)
405
+ formatDistance(in30d, today) // "next month" (right at the default 30d boundary)
317
406
  ```
318
407
 
319
- **Heuristic**: when one of the tokens in the ambiguous run is `d` (day), prefer the split where the day value is 12. Rationale: when a person writes a glued run like `"121"` for an `Md` format string, they're 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 have written it as `"1/21"` or `"01/21"` with a separator or padding. This isn't a guarantee (which is exactly why lenient mode is opt-in), but it's a reasonable default when the caller has explicitly asked us to guess.
408
+ 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).
320
409
 
321
- When the heuristic doesn't narrow (e.g. both splits have day ≤ 12), falls back to the first valid split from `enumerateValidSplits()` deterministic but necessarily arbitrary. When no `d` token is in the run (e.g. `Hm`), the heuristic doesn't apply; falls back to first split.
410
+ ### `formatRelative` / `formatRelativeToNow` "yesterday", "last week"
322
411
 
323
- The default behavior (lenient unset or `false`) is unchanged this is strictly additive.
412
+ `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)`.
324
413
 
325
- ## Custom locale vocabularies
414
+ ## Natural-language date parsing
326
415
 
327
- `registerLocaleVocab(locale, vocab)` lets callers supply their own month/weekday/day-period vocabulary for a locale key `Intl` doesn't cover well. The known limitation this addresses: a 13-month Hebrew leap year silently loses a month because `Intl`'s 12-month vocabulary can't name it.
416
+ `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.
328
417
 
329
- ```js
330
- import { registerLocaleVocab, format, parse } from 'temporal-fmt';
331
-
332
- registerLocaleVocab('en-u-ca-hebrew-leap', {
333
- monthLong: ['Nisan','Iyar','Sivan','Tammuz','Av','Elul','Tishrei','Marcheshvan','Kislev','Tevet','Shevat','Adar I','Adar II'],
334
- monthShort: ['Nis','Iyy','Siv','Tam','Av','Elu','Tish','Chesh','Kis','Tev','Shv','Ad1','Ad2'],
335
- weekdayLong: ['Monday','Tuesday','Wednesday','Thursday','Friday','Saturday','Sunday'],
336
- weekdayShort: ['Mon','Tue','Wed','Thu','Fri','Sat','Sun'],
337
- dayPeriod: ['AM','PM'],
338
- });
339
-
340
- const date = Temporal.PlainDate.from('2026-08-04').withCalendar('hebrew');
341
- format(date, 'MMMM d, yyyy', { locale: 'en-u-ca-hebrew-leap' }) // "Av 4, 5786" (or similar)
342
- ```
343
-
344
- Validation is strict: throws descriptively on wrong array lengths (must be 12 months, 7 weekdays, 2 day periods), empty strings, duplicate entries, and identical AM/PM day periods (which would make `parse()` unable to tell AM from PM). All errors surface at registration time, not later during format/parse.
345
-
346
- Registered vocab takes precedence over the `Intl`-derived vocab for that locale key, for both `format()` and `parse()`.
347
-
348
- ## parseRelative: natural-language date parsing
349
-
350
- `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 locale tag with that language subtag) to route to the corresponding grammar.
351
-
352
- Supported phrases:
418
+ Supported phrase classes:
353
419
 
354
420
  - **weekday references**: "next Tuesday", "last Friday", "this Monday"
355
421
  - **relative day offsets**: "today", "tomorrow", "yesterday"
356
422
  - **relative unit offsets**: "in 3 days", "2 weeks ago", "in 1 month", "1 year ago"
357
- - **month-day without year**: "March 5th", "Aug 4" (resolved to next occurrence)
423
+ - **month-day without year**: "March 5th", "Aug 4" (resolved to the next occurrence)
358
424
 
359
- Per-language equivalents (each grammar is its own module — phrase patterns and vocabulary are NOT shared across languages, only the matching engine and the resolution helpers are):
425
+ Each language grammar is its own module — phrase patterns and vocabulary aren't shared across languages, only the matching engine and resolution helpers are.
360
426
 
361
- | Phrase class | es | fr | de |
362
- |--------------|----|----|-----|
363
- | today | `hoy` | `aujourd'hui` | `heute` |
364
- | tomorrow | `mañana` | `demain` | `morgen` |
365
- | yesterday | `ayer` | `hier` | `gestern` |
366
- | next Tuesday | `el próximo martes` / `martes próximo` | `mardi prochain` | `nächsten Dienstag` |
367
- | last Tuesday | `el martes pasado` | `mardi dernier` | `letzten Dienstag` |
368
- | this Wednesday | `este miércoles` | `ce mercredi` | `diesen Mittwoch` |
369
- | in 3 days | `en 3 días` | `dans 3 jours` | `in 3 Tagen` |
370
- | 2 weeks ago | `hace 2 semanas` | `il y a 2 semaines` | `vor 2 Wochen` |
371
- | March 5 | `5 de marzo` | `5 mars` | `5. März` |
427
+ | Phrase class | en | es | fr | de |
428
+ |--------------|----|----|----|----|
429
+ | today | `today` | `hoy` | `aujourd'hui` | `heute` |
430
+ | tomorrow | `tomorrow` | `mañana` | `demain` | `morgen` |
431
+ | yesterday | `yesterday` | `ayer` | `hier` | `gestern` |
432
+ | next Tuesday | `next Tuesday` | `el próximo martes` / `martes próximo` | `mardi prochain` | `nächsten Dienstag` |
433
+ | last Tuesday | `last Tuesday` | `el martes pasado` | `mardi dernier` | `letzten Dienstag` |
434
+ | this Wednesday | `this Wednesday` | `este miércoles` | `ce mercredi` | `diesen Mittwoch` |
435
+ | in 3 days | `in 3 days` | `en 3 días` | `dans 3 jours` | `in 3 Tagen` |
436
+ | 2 weeks ago | `2 weeks ago` | `hace 2 semanas` | `il y a 2 semaines` | `vor 2 Wochen` |
437
+ | March 5 | `March 5th` | `5 de marzo` | `5 mars` | `5. März` |
372
438
 
373
- Diacritics are stripped before matching (NFD + 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 also expanded, so `"5. Maerz"` resolves the same as `"5. März"`.
439
+ 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"`.
374
440
 
375
441
  ```js
376
442
  import { parseRelative } from 'temporal-fmt';
377
443
 
378
444
  const today = Temporal.PlainDate.from('2026-08-04'); // Tuesday
379
- parseRelative('today', today).toString() // '2026-08-04'
380
- parseRelative('tomorrow', today).toString() // '2026-08-05'
381
- parseRelative('next Tuesday', today).toString() // '2026-08-11' (7 days out, not today)
382
- parseRelative('last Friday', today).toString() // '2026-07-31'
383
- parseRelative('in 3 days', today).toString() // '2026-08-07'
384
- parseRelative('2 weeks ago', today).toString() // '2026-07-21'
385
- parseRelative('March 5th', today).toString() // '2027-03-05' (next occurrence)
386
- ```
387
-
388
- Per-language examples:
389
-
390
- ```js
391
- parseRelative('mañana', today, { locale: 'es-ES' }).toString() // '2026-08-05'
392
- parseRelative('el próximo martes', today, { locale: 'es-ES' }).toString() // '2026-08-11'
393
- parseRelative('5 de marzo', today, { locale: 'es-ES' }).toString() // '2027-03-05'
394
-
395
- parseRelative('demain', today, { locale: 'fr-FR' }).toString() // '2026-08-05'
396
- parseRelative('mardi prochain', today, { locale: 'fr-FR' }).toString() // '2026-08-11'
397
- parseRelative('5 mars', today, { locale: 'fr-FR' }).toString() // '2027-03-05'
398
-
399
- parseRelative('morgen', today, { locale: 'de-DE' }).toString() // '2026-08-05'
400
- parseRelative('nächsten Dienstag', today, { locale: 'de-DE' }).toString() // '2026-08-11'
401
- parseRelative('5. März', today, { locale: 'de-DE' }).toString() // '2027-03-05'
445
+ parseRelative('today', today).toString() // '2026-08-04'
446
+ parseRelative('tomorrow', today).toString() // '2026-08-05'
447
+ parseRelative('next Tuesday', today).toString() // '2026-08-11' (7 days out, not today)
448
+ parseRelative('last Friday', today).toString() // '2026-07-31'
449
+ parseRelative('in 3 days', today).toString() // '2026-08-07'
450
+ parseRelative('2 weeks ago', today).toString() // '2026-07-21'
451
+ parseRelative('March 5th', today).toString() // '2027-03-05' (next occurrence)
452
+
453
+ parseRelative('mañana', today, { locale: 'es-ES' }).toString() // '2026-08-05'
454
+ parseRelative('el próximo martes', today, { locale: 'es-ES' }).toString() // '2026-08-11'
455
+ parseRelative('demain', today, { locale: 'fr-FR' }).toString() // '2026-08-05'
456
+ parseRelative('mardi prochain', today, { locale: 'fr-FR' }).toString() // '2026-08-11'
457
+ parseRelative('morgen', today, { locale: 'de-DE' }).toString() // '2026-08-05'
458
+ parseRelative('nächsten Dienstag', today, { locale: 'de-DE' }).toString() // '2026-08-11'
402
459
  ```
403
460
 
404
461
  **Ambiguous-case choices** (documented, not inferred):
405
462
 
406
- - **"next Tuesday" said on a Tuesday** = 7 days out, not today. "this Tuesday" handles the same-week case, so "next Tuesday" staying strictly-future gives the two phrases distinct, non-overlapping meanings.
407
- - **"last Tuesday" said on a Tuesday** = 7 days ago (strictly-past, symmetric to "next").
408
- - **"March 5th" without a year** = next occurrence. Future-leaning: today's date returns today; a past date this year returns next year's occurrence. (The alternative — "nearest in time, past or future" — would mean "March 5th" said on March 6 returns yesterday, which is counterintuitive for the typical "next birthday"/"next deadline" use case.)
409
- - **"5 days" without "in" or "ago"** = throws. Past or future? parseRelative refuses to guess — same contract as `parse()`'s strict mode. Per-language equivalent: bare `"3 días"` / `"3 jours"` / `"3 Tage"` all throw with a localized error message pointing at the disambiguation options (`"en 3 días"`/`"hace 3 días"`, etc.).
410
-
411
- **Cross-language consistency on the same-day-of-week ambiguity**: the "next X on X = 7 days out, not today" convention holds across all four supported languages (en/es/fr/de). The natural phrasing in each language (`"next Tuesday"` / `"el próximo martes"` / `"mardi prochain"` / `"nächsten Dienstag"`) all resolve to strictly-future-next-week when said on the named weekday. This is a deliberate cross-language convention, not an accident of implementation — if a future language grammar's natural phrasing for "next X" resolves differently by convention, document it in that grammar's section and the README here.
412
-
413
- Throws a descriptive error for any phrase it doesn't recognize, naming the supported categories in the message. Accepts `PlainDate`, `PlainDateTime`, or `ZonedDateTime` as the reference (needs `dayOfWeek` to compute weekday offsets). Throws on `PlainTime`.
414
-
415
- ## Subpath imports
416
-
417
- Each capability area is also available as a subpath import, if you only need a slice and want a smaller bundle:
463
+ - **"next Tuesday" said on a Tuesday** = 7 days out, not today. "this Tuesday" handles the same-week case, so the two phrases stay distinct.
464
+ - **"last Tuesday" said on a Tuesday** = 7 days ago (symmetric with "next").
465
+ - **"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.
466
+ - **"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.
418
467
 
419
- ```js
420
- import { format } from 'temporal-fmt/format';
421
- import { parse } from 'temporal-fmt/parse';
422
- import { formatDuration } from 'temporal-fmt/duration';
423
- import { formatRelative } from 'temporal-fmt/relative';
424
- import { interval, formatRange } from 'temporal-fmt/interval';
425
- import { daysInMonth, startOf } from 'temporal-fmt/calendar';
426
- import { resolveZoned, isDST } from 'temporal-fmt/timezone';
427
- import { recurrence } from 'temporal-fmt/recurrence';
428
- import { registerLocale } from 'temporal-fmt/locale';
429
- ```
430
-
431
- ## API reference
468
+ 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.
432
469
 
433
- The core surface stays zero-runtime-dependency; everything below is exported from the main entry point (and, per the subpath list above, from its matching slice).
470
+ `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`.
434
471
 
435
- ### Formatting
472
+ **Adding a language**: `registerRelativeGrammar(grammar)` registers a new language's phrase patterns without touching the built-in four. `listRegisteredGrammars()` lists what's currently registered.
436
473
 
437
- - `format(temporal, formatStr, options?)` — format a Temporal value using a date-fns-style token string.
438
- - `formatToParts(temporal, formatStr, options?)` — same, but returns an array of `{type, value, token?}` parts.
439
- - `compileFormat(formatStr)` — pre-compile a format string for repeated use.
474
+ ## Date arithmetic, comparison, and rounding
440
475
 
441
- ### Parsing
476
+ ### Arithmetic
442
477
 
443
- - `parse(formatStr, input, options?)` — strict parse; throws on ambiguity, contradiction, or invalid date.
444
- - `safeParse(formatStr, input, options?)` — returns `{ ok: true, value }` or `{ ok: false, error: TemporalFmtError }`.
445
- - `tryParse(formatStr, input, options?)` — best-effort; returns the value or `undefined`.
446
- - `parseToParts(formatStr, input, options?)` — return matched token groups with positions.
447
- - `compileParser(formatStr, options?)` — pre-compile a parser.
478
+ ```js
479
+ import { add, subtract, difference, addDays, differenceInHours } from 'temporal-fmt';
448
480
 
449
- ### Introspection
481
+ add(date, 3, 'days');
482
+ subtract(date, 1, 'months');
483
+ difference(a, b, 'hours');
484
+ ```
450
485
 
451
- - `analyzeFormat(formatStr)` — returns `{ tokens, requiredFields, compatibleTypes, parseable, localeSensitive, calendarSensitive, timezoneSensitive, ambiguous, roundTripSafe, warnings }`.
452
- - `explainFormat(formatStr)` human-readable rendering of `analyzeFormat`.
453
- - `tokenInfo(name)` — metadata for one token, or undefined.
454
- - `listTokens()` every recognized token with metadata.
455
- - `isValidFormat(formatStr)` — true iff tokenize() accepts the string.
456
- - `validateFormat(formatStr)` — throws on invalid; returns the analysis.
486
+ - `add(value, amount, unit)` / `subtract(value, amount, unit)` `unit` is one of `'years' | 'months' | 'weeks' | 'days' | 'hours' | 'minutes' | 'seconds' | 'milliseconds'`.
487
+ - Per-unit wrappers for both directions: `addYears`, `addMonths`, `addWeeks`, `addDays`, `addHours`, `addMinutes`, `addSeconds`, `addMilliseconds`, and the matching `subtract*` set.
488
+ - `difference(a, b, unit)` — integer count of unit boundaries crossed between two values.
489
+ - Per-unit wrappers: `differenceInYears`, `differenceInMonths`, `differenceInWeeks`, `differenceInDays`, `differenceInHours`, `differenceInMinutes`, `differenceInSeconds`, `differenceInMilliseconds`.
457
490
 
458
- ### Type guards
491
+ ### Comparison
459
492
 
460
- - `isTemporal(value)`, `isInstant(value)`, `isPlainDate(value)`, `isPlainTime(value)`, `isPlainDateTime(value)`, `isZonedDateTime(value)`, `isPlainYearMonth(value)`, `isPlainMonthDay(value)`, `isDuration(value)`.
461
- - `assertX(value)` variants throw descriptively on type mismatch.
493
+ ```js
494
+ import { compare, isBefore, isSameDay, isWeekend } from 'temporal-fmt';
462
495
 
463
- ### Typed errors
496
+ compare(a, b); // -1 / 0 / 1
497
+ isBefore(a, b);
498
+ isSameDay(a, b);
499
+ isWeekend(date);
500
+ ```
464
501
 
465
- - `TemporalFmtError` — base class with `code`, `input`, `format`, `token`, `position`, `expected`, `actual`, `reason`.
466
- - Subclasses: `FormatSyntaxError`, `UnknownTokenError`, `ParseMismatchError`, `InvalidDateError`, `InvalidTimeError`, `InvalidOffsetError`, `InvalidTimeZoneError`, `InvalidCalendarError`, `AmbiguousInputError`, `InvalidLocaleError`, `InvalidDurationError`.
467
- - See [Error reference](#error-reference) below for what triggers each one.
502
+ - `compare(a, b)` `-1`/`0`/`1`.
503
+ - `isEqual`, `isBefore`, `isAfter`.
504
+ - `min(values)`, `max(values)`, `clamp(value, lo, hi)`, `isBetween(value, lo, hi)`.
505
+ - Semantic helpers: `isToday`, `isTomorrow`, `isYesterday`, `isSameDay`, `isSameWeek`, `isSameMonth`, `isSameQuarter`, `isSameYear`, `isWeekend`, `isWeekday`.
468
506
 
469
- ### Duration APIs
507
+ ### Rounding
470
508
 
471
- - `formatDuration(duration, formatStr, options?)` — duration-specific token grammar (see [Duration formatting](#duration-formatting) above).
472
- - `formatDurationToParts(duration, formatStr, options?)` same, as parts.
473
- - `parseDuration(input, formatStr, options?)` — inverse of `formatDuration`.
474
- - `parseISODuration(input)` — parse `P[n]Y[n]M[n]W[n]DT[n]H[n]M[n]S` ISO 8601 duration.
475
- - `formatISODuration(duration)` — inverse of `parseISODuration`.
476
- - `balanceDuration(duration)` — normalize fields to their natural ranges.
477
- - `roundDuration(duration, options)` — round to a unit; throws for calendar-bound units without a relativeTo.
478
- - `totalDuration(duration, unit)` — sum absolute fields into the target unit.
479
- - `compareDuration(a, b)` — `-1/0/1` by total absolute length.
480
- - `addDuration(a, b)`, `subtractDuration(a, b)` — field-by-field sum/difference.
509
+ - `round(value, options)` — round to a unit with a rounding mode.
510
+ - `floor(value, unit, increment?)`, `ceil(value, unit, increment?)`, `truncate(value, unit, increment?)`.
511
+ - `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.
481
512
 
482
- ### Relative time
513
+ ### Calendar utilities
483
514
 
484
- - `formatDistance(date1, date2, options?)` — "3 days ago", "in 2 hours" (see [Relative time](#relative-time-formatdistance) above).
485
- - `formatRelative(date1, date2, options?)` calendar-relative ("yesterday", "tomorrow", "last week").
486
- - `formatRelativeToNow(date, options?)` — `formatRelative(date, now)`.
515
+ ```js
516
+ import { daysInMonth, startOf, getQuarter } from 'temporal-fmt';
487
517
 
488
- ### Calendar utilities
518
+ daysInMonth(date);
519
+ startOf(date, 'month');
520
+ getQuarter(date);
521
+ ```
489
522
 
490
523
  - `daysInMonth(value)`, `daysInYear(value)`, `monthsInYear(value)`.
491
- - `isLeapYear(value)`, `isLeapMonth(value)` (Gregorian returns false).
524
+ - `isLeapYear(value)`, `isLeapMonth(value)` (Gregorian: `isLeapMonth` always returns `false`).
492
525
  - `dayOfYear(value)`, `weekOfYear(value)`, `weekYear(value)`.
493
526
  - `getQuarter(value)`, `getMonth(value)`, `getWeekday(value)`.
494
- - `startOf(value, unit)`, `endOf(value, unit)` — returns a new field bag with finer fields zeroed/extended.
495
- - See [Calendar guide](#calendar-guide) below for Gregorian-only caveats.
496
-
497
- ### Date arithmetic
498
-
499
- - `add(value, amount, unit)`, `subtract(value, amount, unit)`.
500
- - Per-unit wrappers: `addYears`, `addMonths`, `addWeeks`, `addDays`, `addHours`, `addMinutes`, `addSeconds`, `addMilliseconds` (and `subtract*` variants).
501
- - `difference(a, b, unit)` — integer count of unit boundaries.
502
- - Per-unit wrappers: `differenceInYears`, …, `differenceInMilliseconds`.
503
-
504
- ### Rounding
505
-
506
- - `round(value, options)` — round to a unit with a mode.
507
- - `floor(value, unit, increment?)`, `ceil(value, unit, increment?)`, `truncate(value, unit, increment?)`.
508
-
509
- ### Comparison
527
+ - `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`).
510
528
 
511
- - `compare(a, b)` `-1/0/1`.
512
- - `isEqual`, `isBefore`, `isAfter`.
513
- - `min(values)`, `max(values)`, `clamp(value, lo, hi)`, `isBetween(value, lo, hi)`.
514
- - Semantic helpers: `isToday`, `isTomorrow`, `isYesterday`, `isSameDay`, `isSameWeek`, `isSameMonth`, `isSameQuarter`, `isSameYear`, `isWeekend`, `isWeekday`.
515
-
516
- ### Intervals
517
-
518
- - `interval(start, end, bounds?)` — bounds: `'closed'` | `'open'` | `'half-open-start'` | `'half-open-end'`.
519
- - `intervalContains(iv, value)`, `overlaps(a, b)`, `intersects(a, b)`, `intervalIsBefore(a, b)`, `intervalIsAfter(a, b)`.
520
- - `intersection(a, b)`, `union(a, b)`, `intervalDifference(a, b)`, `intervalSubtract(a, b)`.
521
- - `mergeIntervals(intervals)` — combine overlapping.
522
- - `splitInterval(iv, n)` — N equal sub-intervals.
523
- - `formatRange(iv, formatStr, options?)`, `formatRangeToParts(iv, formatStr, options?)` — uses `Intl.DateTimeFormat.formatRange` when available.
529
+ **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:
524
530
 
525
- ### Timezone subsystem
526
-
527
- - `resolveZoned(fields, timeZone, options?)` — construct a `ZonedDateTime` with disambiguation mode (`compatible` | `earlier` | `later` | `reject`).
528
- - `getTimeZone(value)`, `getOffset(value)`, `getOffsetNanoseconds(value)`.
529
- - `isDST(value)` — heuristic, compares current offset to January offset.
530
- - `getNextTransition(value)`, `getPreviousTransition(value)`, `getTransitions(start, end)`.
531
- - `possibleInstantsFor(fields, timeZone)` — returns the list of possible instants (0 for gaps, 2 for overlaps, 1 otherwise).
531
+ ```js
532
+ const pd = Temporal.PlainDate.from('5784-05-10[u-ca=hebrew]');
533
+ pd.daysInMonth; // 30 (Sivan)
534
+ pd.monthsInYear; // 13 (leap year)
535
+ ```
532
536
 
533
- ### Recurrence
537
+ Locale-aware *formatting* tokens (`MMMM`, `MMM`, `EEEE`, `EEE`, `a`) don't have this limitation — they go through `Intl.DateTimeFormat`, which does respect `calendarId`:
534
538
 
535
- - `recurrence(start, rule)` — returns an iterator with `next()` and `previous()`.
536
- - `take(iter, n)` collect N occurrences.
537
- - `skip(iter, n)` — skip N occurrences.
538
- - `between(start, rule, rangeStart, rangeEnd)` — occurrences in range.
539
- - `parseRRule(str)`, `formatRRule(rule)` — RFC 5545 interop.
539
+ ```js
540
+ format(Temporal.PlainDate.from('5784-05-10[u-ca=hebrew]'), 'MMMM d, yyyy'); // "Sivan 10, 5784"
541
+ ```
540
542
 
541
- ### Business calendar
543
+ ## Intervals
542
544
 
543
- - `createBusinessCalendar(options?)` — customize weekend, holidays, working hours, half days.
544
- - `isBusinessDay(cal, value)`, `addBusinessDays(cal, value, n)`, `subtractBusinessDays(cal, value, n)`.
545
- - `differenceInBusinessDays(cal, a, b)`, `nextBusinessDay(cal, value)`, `previousBusinessDay(cal, value)`.
545
+ ```js
546
+ import { interval, contains, overlaps, formatRange } from 'temporal-fmt';
546
547
 
547
- ### Holiday framework
548
+ const iv = interval(start, end, 'closed');
549
+ contains(iv, someDate);
550
+ overlaps(ivA, ivB);
551
+ formatRange(iv, 'MMM d');
552
+ ```
548
553
 
549
- - `createHolidayCalendar(specs)` — fixed-date and computed holidays.
550
- - `isHoliday(cal, value)`, `nextHoliday(cal, value)`, `previousHoliday(cal, value)`, `holidaysBetween(cal, start, end)`.
554
+ - `interval(start, end, bounds?)` — `bounds` is one of `'closed'` (default) | `'open'` | `'half-open-start'` | `'half-open-end'`.
555
+ - `contains(iv, value)`, `overlaps(a, b)`, `intersects(a, b)`.
556
+ - `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).
557
+ - `intersection(a, b)`, `union(a, b)` — return `null` when the intervals don't overlap enough to combine.
558
+ - `difference(a, b)` / `subtract(a, b)` — imported as `intervalDifference`/`intervalSubtract` from the main entry point, same collision-avoidance reasoning.
559
+ - `mergeIntervals(intervals)` — combines a list of overlapping intervals into their union set.
560
+ - `splitInterval(iv, n)` — splits one interval into `n` equal sub-intervals.
561
+ - `formatRange(iv, formatStr, options?)` / `formatRangeToParts(iv, formatStr, options?)` — format an interval as a range string, using `Intl.DateTimeFormat.formatRange` where available.
551
562
 
552
- ### Serialization
563
+ ## Recurrence
553
564
 
554
- - `parseISO(input)`, `formatISO(value)`.
555
- - `parseRFC3339(input)`, `formatRFC3339(value)`.
556
- - `parseRFC2822(input)`, `formatRFC2822(value)`.
557
- - `parseHTTPDate(input)`, `formatHTTPDate(value)`.
558
- - `parseSQL(input)`, `formatSQL(value)`.
559
- - Epoch: `fromUnixSeconds`, `fromUnixMilliseconds`, `fromUnixMicroseconds`, `fromUnixNanoseconds`, `toUnixSeconds`, `toUnixMilliseconds`, `toUnixMicroseconds`, `toUnixNanoseconds`.
565
+ ```js
566
+ import { recurrence, take, between } from 'temporal-fmt';
560
567
 
561
- ### Locale
568
+ const rule = { freq: 'weekly', interval: 1, count: 10 };
569
+ const iter = recurrence(startDate, rule);
570
+ take(iter, 5); // first 5 occurrences
571
+ between(startDate, rule, rangeStart, rangeEnd); // occurrences within a window
572
+ ```
562
573
 
563
- - `registerLocale(locale, vocab)` — register extended vocabulary (months, weekdays, day periods, quarters, eras, ordinals, duration units, relative-time language).
564
- - `getLocale(locale)`, `hasLocale(locale)`.
565
- - `registerLocaleVocab(locale, vocab)` — base vocabulary (months/weekdays/day periods) only. See [Locale guide](#locale-guide) below.
574
+ - `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.
575
+ - `take(iter, n)` — collects the next `n` occurrences.
576
+ - `skip(iter, n)` — advances past `n` occurrences.
577
+ - `between(start, rule, rangeStart, rangeEnd)` — all occurrences within a range, without manually iterating.
578
+ - `parseRRule(input)` / `formatRRule(rule)` — parse/format the RFC 5545 RRULE text format, for interop with calendar systems that speak it.
566
579
 
567
- ### Numbering systems
580
+ ## Business calendars and holidays
568
581
 
569
- - `convertDigits(s, system)` ASCII digits to a locale's native digits.
570
- - `convertDigitsToAscii(s, system)` — inverse.
571
- - `SUPPORTED_NUMBERING_SYSTEMS` — set of supported system names.
582
+ 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.
572
583
 
573
- ### Configuration
584
+ ```js
585
+ import { createBusinessCalendar, isBusinessDay, addBusinessDays } from 'temporal-fmt';
574
586
 
575
- - `createConfig(overrides?)` frozen config with locale/calendar/timezone/numberingSystem/weekRules/rounding/disambiguation/overflow/parseLenient/durationShowZeroValues.
576
- - `mergeWithConfig(config, perCall)` — fold config defaults into per-call options.
587
+ const cal = createBusinessCalendar({ /* weekend days, holidays, working hours, half days */ });
588
+ isBusinessDay(cal, someDate);
589
+ addBusinessDays(cal, someDate, 5);
590
+ ```
577
591
 
578
- ### Extensibility
592
+ - `createBusinessCalendar(options?)` — configure weekend days, holidays, working hours, and half days.
593
+ - `isBusinessDay(cal, value)`.
594
+ - `addBusinessDays(cal, value, n)` / `subtractBusinessDays(cal, value, n)`.
595
+ - `differenceInBusinessDays(cal, a, b)`.
596
+ - `nextBusinessDay(cal, value)` / `previousBusinessDay(cal, value)`.
579
597
 
580
- - `createFormatter(options?)` — create a formatter with custom tokens (overrides built-ins of the same name).
598
+ ```js
599
+ import { createHolidayCalendar, nextHoliday, holidaysBetween } from 'temporal-fmt';
581
600
 
582
- ### Natural-language parsing
601
+ const holidays = createHolidayCalendar([
602
+ { month: 1, day: 1, name: "New Year's Day" },
603
+ { compute: (year) => ({ month: 5, day: lastMondayOf(year, 5) }), name: 'Memorial Day' },
604
+ ]);
583
605
 
584
- - `parseRelative(input, reference, options?)` — built-in EN/ES/FR/DE grammars (see [parseRelative](#parserelative-natural-language-date-parsing) above).
585
- - `registerRelativeGrammar(grammar)` — add a new language.
606
+ holidays.isHoliday(someDate);
607
+ nextHoliday(holidays, someDate);
608
+ ```
586
609
 
587
- ### IDE tooling data
610
+ - `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).
611
+ - The returned calendar has `.isHoliday(value)` and `.holidaysBetween(start, end)` as methods on the object itself — they aren't standalone exports.
612
+ - `nextHoliday(cal, value)` / `previousHoliday(cal, value)` — standalone helpers that call `.isHoliday()` under the hood, capped at a 5-year lookahead/lookbehind.
613
+ - `holidaysBetween(cal, start, end)` — standalone wrapper delegating to `cal.holidaysBetween(start, end)`, for callers who'd rather not reach into the calendar object directly.
588
614
 
589
- - `getAutocompleteData()` token autocomplete entries with family grouping.
590
- - `getHoverDocs()` — per-token hover documentation.
591
- - `getInlineDiagnostics(formatStr)` — diagnostics with position + suggested fixes.
592
- - `previewFormat(formatStr, sample?)` — live preview string.
593
- - `getDocUrl(tokenName)` — anchor link into this README's [Token reference](#token-reference) section. (Pre-consolidation, this pointed into a standalone `docs/` folder — if you're on an older version, update accordingly.)
594
- - `DAYJS_TO_TEMPORAL_FMT`, `DATE_FNS_TO_TEMPORAL_FMT` — token conversion hints.
615
+ 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.
595
616
 
596
- ### CLI
617
+ ## Time zones
597
618
 
598
- Run via `npm run cli` or `node scripts/cli.mjs`:
619
+ ```js
620
+ import { resolveZoned, isDST, getTransitions } from 'temporal-fmt';
599
621
 
600
- ```sh
601
- temporal-fmt format "2026-08-04T15:45:30" "yyyy-MM-dd HH:mm:ss"
602
- temporal-fmt parse "yyyy-MM-dd" "2026-08-04"
603
- temporal-fmt inspect "MMMM d, yyyy 'at' h:mm a"
604
- temporal-fmt validate "yyyy-MM-dd HH:mm:ss"
605
- temporal-fmt translate dayjs "YYYY-MM-DD HH:mm:ss"
622
+ resolveZoned({ year: 2026, month: 3, day: 8, hour: 2, minute: 30 }, 'America/New_York', { disambiguation: 'compatible' });
623
+ isDST(zonedDateTime);
624
+ getTransitions(rangeStart, rangeEnd);
606
625
  ```
607
626
 
608
- ## Token reference
609
-
610
- Beyond the format/parse/example columns in the [Tokens](#tokens) table above, every token carries structured metadata round-trip safety, locale sensitivity, which Temporal types it works onaccessible via `tokenInfo(name)` or `TOKEN_METADATA`.
627
+ - `resolveZoned(fields, timeZone, options?)` — constructs a `ZonedDateTime` with an explicit disambiguation mode for gaps/overlaps: `'compatible' | 'earlier' | 'later' | 'reject'`.
628
+ - `getTimeZone(value)`, `getOffset(value)`, `getOffsetNanoseconds(value)`.
629
+ - `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 therethere's no reliable hemisphere lookup built in.
630
+ - `getNextTransition(value)` / `getPreviousTransition(value)` — the nearest DST (or other offset) transition in either direction.
631
+ - `getTransitions(start, end)` — every transition within a range.
632
+ - `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.
611
633
 
612
- ### Round-trip safety by family
634
+ ## Serialization
613
635
 
614
- | Family | Tokens | Round-trip safe | Notes |
615
- |--------|--------|------------------|-------|
616
- | Year | `yyyy` | yes | Preserves sign for BCE. |
617
- | Year | `yy` | no | Century is lost — `parse` re-derives it via the `00–68`/`69–99` rule documented above, which isn't guaranteed to match the original century. |
618
- | Month | `MMMM`, `MMM`, `MM`, `M` | yes | Numeric and name forms all round-trip; only `MMMM`/`MMM` are locale-sensitive. |
619
- | Day | `dd`, `d` | yes | |
620
- | Day | `do` | no | Format-only — the "st"/"nd"/"rd"/"th" suffix isn't parseable back out. |
621
- | Weekday | `EEEE`, `EEE` | yes | Cross-checked against the parsed date, so a mismatched weekday throws rather than silently round-tripping wrong. |
622
- | ISO week | `ww`, `RRRR` | n/a | Format-only — a week number alone can't reconstruct a specific date. |
623
-
624
- ### Format-only tokens (parse rejects)
636
+ ```js
637
+ import { parseISO, formatISO, parseRFC3339, fromUnixMilliseconds } from 'temporal-fmt';
625
638
 
626
- `format()` accepts these; `parse()` throws a clear, descriptive error if you try to use them for parsing:
639
+ parseISO('2026-08-04T15:45:30Z');
640
+ formatISO(value);
641
+ fromUnixMilliseconds(1754321130000);
642
+ ```
627
643
 
628
- - `do` ordinal suffix isn't structurally distinguishable from adjacent literal text.
629
- - `ww`, `RRRR` — week alone can't reconstruct a date without a disambiguator (a weekday, or the year+month+day).
644
+ - `parseISO(input)` / `formatISO(value)` ISO 8601.
645
+ - `parseRFC3339(input)` / `formatRFC3339(value)` — RFC 3339 (ISO 8601's stricter internet-facing cousin).
646
+ - `parseRFC2822(input)` / `formatRFC2822(value)` — RFC 2822 (email/HTTP-header-style dates).
647
+ - `parseHTTPDate(input)` / `formatHTTPDate(value)` — the HTTP-date format used in `Date`/`Last-Modified` headers.
648
+ - `parseSQL(input)` / `formatSQL(value)` — SQL `DATETIME`/`TIMESTAMP` style.
649
+ - Epoch conversions, both directions, at second/millisecond/microsecond/nanosecond resolution: `fromUnixSeconds`, `fromUnixMilliseconds`, `fromUnixMicroseconds`, `fromUnixNanoseconds`, `toUnixSeconds`, `toUnixMilliseconds`, `toUnixMicroseconds`, `toUnixNanoseconds`.
630
650
 
631
- Run `analyzeFormat(formatStr).warnings` to catch these statically before you hit the runtime error. The ESLint plugin (see [Related tools](#related-tools)) surfaces the same check as a `formatOnlyToken` diagnostic.
651
+ ## Introspection and the analyzer
632
652
 
633
- ### Inspecting metadata at runtime
653
+ Every token carries structured metadata, and there's a small analysis layer over format strings themselves.
634
654
 
635
655
  ```js
636
- import { TOKEN_METADATA, tokenInfo, listTokens } from 'temporal-fmt';
656
+ import { tokenInfo, listTokens, analyzeFormat, explainFormat, isValidFormat } from 'temporal-fmt';
637
657
 
638
658
  tokenInfo('yyyy');
639
659
  // {
@@ -646,225 +666,181 @@ tokenInfo('yyyy');
646
666
  // supportedTypes: ['PlainDate', 'PlainDateTime', 'ZonedDateTime', 'PlainYearMonth'],
647
667
  // roundTripSafe: true,
648
668
  // }
649
- ```
650
-
651
- This metadata is the same source of truth the ESLint plugin and the codemod consume, so `tokenInfo()`/`listTokens()` output won't drift from what those tools actually enforce.
652
-
653
- ## Parsing: additional detail
654
-
655
- The [Parsing a string](#parsing-a-string) section above covers the core contract. A few more things that come up in practice:
656
-
657
- **Return type depends on which tokens are present:**
658
-
659
- | Tokens present | Result type |
660
- |---|---|
661
- | year + month + day only | `Temporal.PlainDate` |
662
- | time fields only (hour, minute, second) | `Temporal.PlainTime` |
663
- | full date + time, no zone | `Temporal.PlainDateTime` |
664
- | any of the above + `zzz` or an offset token | `Temporal.ZonedDateTime` |
665
-
666
- **`safeParse` and `tryParse`** — for input you don't trust and don't want to wrap in try/catch:
667
-
668
- ```js
669
- import { safeParse, tryParse } from 'temporal-fmt';
670
-
671
- const r = safeParse('yyyy-MM-dd', userInput);
672
- if (r.ok) {
673
- console.log(r.value.toString());
674
- } else {
675
- console.log(r.error.code); // 'INVALID_DATE' / 'PARSE_MISMATCH' / ...
676
- }
677
669
 
678
- const v = tryParse('yyyy-MM-dd', userInput);
679
- if (v) { /* ... */ }
670
+ analyzeFormat("MMMM d, yyyy 'at' h:mm a");
671
+ // { tokens, requiredFields, compatibleTypes, parseable, localeSensitive,
672
+ // calendarSensitive, timezoneSensitive, ambiguous, roundTripSafe, warnings }
680
673
  ```
681
674
 
682
- **`parseToParts`** returns matched token groups and positions before any Temporal value gets constructed useful for building a custom result type or doing your own cross-checks:
675
+ - `analyzeFormat(formatStr)`the full structured report shown above.
676
+ - `explainFormat(formatStr)` — a human-readable rendering of the same analysis.
677
+ - `tokenInfo(name)` — metadata for one token, or `undefined` if it's not recognized.
678
+ - `listTokens()` — every recognized token, paired with its metadata.
679
+ - `isValidFormat(formatStr)` — `true` iff the tokenizer accepts the string.
680
+ - `validateFormat(formatStr)` — throws on an invalid format string; otherwise returns the same analysis `analyzeFormat` does.
681
+ - `tokenizeFormat(formatStr)` — the raw literal/token piece list, if you want to walk it yourself.
682
+ - `fieldForToken(token)` — which `TemporalLike` field a given token reads.
683
683
 
684
- ```js
685
- parseToParts('yyyy-MM-dd HH:mm', '2026-08-04 15:45');
686
- // → [
687
- // { token: 'yyyy', raw: '2026', position: 0 },
688
- // { token: 'MM', raw: '08', position: 5 },
689
- // { token: 'dd', raw: '04', position: 8 },
690
- // { token: 'HH', raw: '15', position: 11 },
691
- // { token: 'mm', raw: '45', position: 14 },
692
- // ]
693
- ```
684
+ `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`).
694
685
 
695
- **Calendar-aware parsing** a locale with a `-u-ca-` extension parses into that calendar directly:
686
+ **Round-trip safety, by family:**
696
687
 
697
- ```js
698
- parse('yyyy-MM-dd', '5784-05-10', { locale: 'en-u-ca-hebrew' });
699
- // Temporal.PlainDate with calendarId 'hebrew'
700
- ```
701
-
702
- ## Error reference
688
+ | Family | Tokens | Round-trip safe | Why |
689
+ |--------|--------|------------------|-----|
690
+ | Year | `yyyy` | yes | Preserves sign for BCE. |
691
+ | 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. |
692
+ | Month | `MMMM`, `MMM`, `MM`, `M` | yes | Numeric and name forms both round-trip; only the name forms are locale-sensitive. |
693
+ | Day | `dd`, `d` | yes | |
694
+ | Day | `do` | no | Format-only — the ordinal suffix isn't parseable back out. |
695
+ | Weekday | `EEEE`, `EEE` | yes | Cross-checked against the parsed date, so a mismatch throws rather than round-tripping silently wrong. |
696
+ | ISO week | `ww`, `RRRR` | n/a | Format-only — a week number alone can't reconstruct a specific date. |
703
697
 
704
- Every error thrown by `temporal-fmt` is either a plain `Error` with a descriptive message (the legacy throw sites in `parse()` and `format()`) or a `TemporalFmtError` subclass (the typed-error surface exposed via `safeParse()` and `tryParse()`).
698
+ ## Typed errors
705
699
 
706
- ### Typed error classes
700
+ 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).
707
701
 
708
- All inherit from `TemporalFmtError`, which carries structured fields: `code`, `input`, `format`, `token`, `position`, `expected`, `actual`, `reason`.
702
+ All subclasses inherit structured fields from `TemporalFmtError`: `code`, `input`, `format`, `token`, `position`, `expected`, `actual`, `reason`.
709
703
 
710
- | Class | Code | When it fires |
711
- |-------|------|---------------|
712
- | `FormatSyntaxError` | `FORMAT_SYNTAX_ERROR` | Unterminated quote, format string exceeds length cap, other syntax issues. |
704
+ | Class | Code | Fires when |
705
+ |-------|------|------------|
706
+ | `FormatSyntaxError` | `FORMAT_SYNTAX_ERROR` | Unterminated quote, format string over the length cap, other syntax issues. |
713
707
  | `UnknownTokenError` | `UNKNOWN_TOKEN` | An unrecognized letter run was encountered. |
714
- | `ParseMismatchError` | `PARSE_MISMATCH` | Input doesn't match the format's shape; generic catch-all. |
715
- | `InvalidDateError` | `INVALID_DATE` | Date is structurally valid but doesn't exist (Feb 30), weekday/quarter contradicts date, etc. |
716
- | `InvalidTimeError` | `INVALID_TIME` | Time is out of range (hour 25, etc.). |
717
- | `InvalidOffsetError` | `INVALID_OFFSET` | Offset is malformed or out of IANA range (-12:00 to +14:00). |
718
- | `InvalidTimeZoneError` | `INVALID_TIME_ZONE` | Time zone isn't a recognized IANA name or fixed offset. |
719
- | `InvalidCalendarError` | `INVALID_CALENDAR` | Calendar isn't supported. |
720
- | `AmbiguousInputError` | `AMBIGUOUS_INPUT` | Input has more than one valid reading (e.g. "Md" against "121"). |
721
- | `InvalidLocaleError` | `INVALID_LOCALE` | Locale isn't a valid BCP-47 tag, or the numbering system isn't supported. |
722
- | `InvalidDurationError` | `INVALID_DURATION` | Duration string doesn't match the ISO 8601 grammar, or a field value is non-finite. |
723
-
724
- `safeParse()` classifies the underlying throw into the matching `TemporalFmtError` subclass internally — see `src/errors.ts` if you need the exact classification logic.
725
-
726
- ### Common error patterns
727
-
728
- **"no valid pattern matches the format string and input shape"** — the input doesn't match the format string at all. Usually a missing separator, wrong digit count for a fixed-width token, or a `zzz` token that captured something that isn't a real IANA zone id.
708
+ | `ParseMismatchError` | `PARSE_MISMATCH` | Input doesn't match the format's shape generic catch-all. |
709
+ | `InvalidDateError` | `INVALID_DATE` | Structurally valid but nonexistent date (Feb 30), or a weekday/quarter that contradicts the date. |
710
+ | `InvalidTimeError` | `INVALID_TIME` | Time out of range (hour 25, etc). |
711
+ | `InvalidOffsetError` | `INVALID_OFFSET` | Offset malformed or outside the IANA range (`-12:00` to `+14:00`). |
712
+ | `InvalidTimeZoneError` | `INVALID_TIME_ZONE` | Not a recognized IANA name or fixed offset. |
713
+ | `InvalidCalendarError` | `INVALID_CALENDAR` | Unsupported calendar. |
714
+ | `AmbiguousInputError` | `AMBIGUOUS_INPUT` | Input has more than one valid reading (e.g. `Md` against `"121"`). |
715
+ | `InvalidLocaleError` | `INVALID_LOCALE` | Not a valid BCP-47 tag, or an unsupported numbering system. |
716
+ | `InvalidDurationError` | `INVALID_DURATION` | Duration string doesn't match the ISO 8601 grammar, or a field is non-finite. |
729
717
 
730
- **"token X requires Y, which this Temporal object doesn't have"** — you used a token that reads a field the value doesn't carry. Common case: `format(plainDate, 'HH:mm')` — `PlainDate` has no hour field. Switch to `PlainDateTime` or use a date-only format string.
718
+ ### Reading the common ones
731
719
 
732
- **"format string mixes 'yyyy' and 'yy' year representations"** — don't mix the two year tokens in the same format string. Pick one.
720
+ **"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.
733
721
 
734
- **"format string has an incomplete date year, month, and day tokens must all be present together"** a partial date (year-only, say) isn't constructible as a Temporal value. Add the missing tokens, or use a different format.
722
+ **"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.
735
723
 
736
- **"X is ambiguous N different ways to read tokens Y are all individually valid"** — adjacent unpadded numeric tokens with no separator (e.g. `Md` against `121`) can split multiple ways. Add a separator (`M-d`), use padded forms (`MM-dd`), or opt into `{ lenient: true }`.
724
+ **"format string mixes 'yyyy' and 'yy' year representations"** — don't combine the two year tokens in one format string.
737
725
 
738
- **"offset hours X in 'Y' out of range (max 14 Kiritimati, Line Islands is +14:00)"** — the offset's hour component exceeds the IANA-supported range of -12 to +14.
726
+ **"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.
739
727
 
740
- **"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' }` to let Temporal choose an instant.
728
+ **"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 }`.
741
729
 
742
- **"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 string asks for both a zone name and an explicit offset, and the parsed offset disagrees with the zone's actual offset at that instant. Keep them consistent in the input.
730
+ **"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.
743
731
 
744
- ## Locale guide
732
+ **"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.
745
733
 
746
- The [Locale support](#locale-support) section above covers the common casepassing a `locale` option to `format`/`parse`. This section covers registering your own vocabulary.
734
+ **"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.
747
735
 
748
- `temporal-fmt` uses `Intl.DateTimeFormat` as the default source for locale-aware token output (`MMMM`, `MMM`, `EEEE`, `EEE`, `a`). For locales Intl doesn't cover well — or where you want different vocabulary — register a custom one.
749
-
750
- **`registerLocaleVocab` (base)** — months, weekdays, and day periods only:
736
+ ## Type guards
751
737
 
752
738
  ```js
753
- import { registerLocaleVocab } from 'temporal-fmt';
739
+ import { isPlainDate, assertZonedDateTime } from 'temporal-fmt';
754
740
 
755
- registerLocaleVocab('en-u-ca-hebrew-leap', {
756
- monthLong: ['Nisan', 'Iyar', 'Sivan', 'Tammuz', 'Av', 'Elul', 'Tishrei', 'Marcheshvan', 'Kislev', 'Tevet', 'Shevat', 'Adar I', 'Adar II'],
757
- monthShort: ['Nis', 'Iyy', 'Siv', 'Tam', 'Av', 'Elu', 'Tish', 'Chesh', 'Kis', 'Tev', 'Shv', 'Ad1', 'Ad2'],
758
- weekdayLong: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'],
759
- weekdayShort: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
760
- dayPeriod: ['AM', 'PM'],
761
- });
741
+ if (isPlainDate(value)) { /* narrowed */ }
742
+ assertZonedDateTime(value); // throws descriptively if it isn't one
762
743
  ```
763
744
 
764
- After registration, `format()` and `parse()` use the registered vocab for that locale key.
745
+ - `isTemporal(value)`, `isInstant(value)`, `isPlainDate(value)`, `isPlainTime(value)`, `isPlainDateTime(value)`, `isZonedDateTime(value)`, `isPlainYearMonth(value)`, `isPlainMonthDay(value)`, `isDuration(value)`.
746
+ - `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.
747
+
748
+ ## Config
749
+
750
+ `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.
765
751
 
766
- **`registerLocale` (extended)** — for vocabulary beyond months/weekdays/day-periods: quarters, eras, ordinals, duration units, relative-time language:
752
+ ## Numbering systems
767
753
 
768
754
  ```js
769
- import { registerLocale } from 'temporal-fmt';
755
+ import { convertDigits, convertDigitsToAscii } from 'temporal-fmt';
770
756
 
771
- registerLocale('test-locale-1', {
772
- // base vocab (required)
773
- monthLong: [/* 12 entries */],
774
- monthShort: [/* 12 entries */],
775
- weekdayLong: [/* 7 entries */],
776
- weekdayShort: [/* 7 entries */],
777
- dayPeriod: ['AM', 'PM'],
778
- // extended vocab (optional)
779
- quartersLong: ['First', 'Second', 'Third', 'Fourth'],
780
- quartersShort: ['Q1', 'Q2', 'Q3', 'Q4'],
781
- erasLong: ['BCE', 'CE'],
782
- erasShort: ['BC', 'AD'],
783
- ordinals: ['st', 'nd', 'rd', 'th'],
784
- durationUnits: { years: ['year', 'years'] /* , ... */ },
785
- relativeTime: { past: 'ago', future: 'in', now: 'now' },
786
- });
757
+ convertDigits('2026', 'arab'); // "٢٠٢٦"
758
+ convertDigitsToAscii('٢٠٢٦', 'arab'); // "2026"
787
759
  ```
788
760
 
789
- **Locale fallback** — `getLocale(locale)` returns the extended vocab if one's registered, otherwise the Intl-derived base vocab. Fallback is deterministic: the canonical locale key (lowercased via `Intl.Locale`) is looked up, and Intl is used if there's no entry.
790
-
791
- **Deterministic output** — all locale options are per-call. `registerLocaleVocab` and `registerLocale` are the only global mutation points, and they invalidate the cache entry for the affected locale so subsequent calls pick up the new vocab immediately. Built-in behavior stays deterministic regardless of what's registered elsewhere in your app.
761
+ - `convertDigits(s, system)` ASCII digits to a locale's native digits.
762
+ - `convertDigitsToAscii(s, system)` — the inverse.
763
+ - `applyNumbering(s, options)` / `applyParseNumbering(s, options)` the internal helpers `format()`/`parse()` call when you pass `{ numberingSystem }` / `{ parseNumberingSystem }` in `options`, rather than converting a formatted string yourself afterward.
764
+ - `SUPPORTED_NUMBERING_SYSTEMS` — the set of supported system names (`'latn' | 'arab' | 'deva' | 'beng' | 'guru' | 'gujr' | 'orya' | 'tamldec' | 'telu' | 'knda' | 'mlym' | 'fullwide' | 'hanidec'`).
792
765
 
793
- ## Calendar guide
766
+ ## Extending with custom tokens
794
767
 
795
- `temporal-fmt`'s calendar utilities operate on Temporal's calendar-aware types. By default, the helpers assume the `iso8601` (Gregorian) calendar that's what `TemporalLike` fields carry for the overwhelming majority of callers.
768
+ `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.
796
769
 
797
- **Gregorian-only helpers (documented limitation).** The following use Gregorian arithmetic and will produce wrong results on non-Gregorian calendars (Hebrew, Islamic, etc.):
770
+ ## IDE tooling data
798
771
 
799
- - `daysInMonth`, `daysInYear`, `isLeapYear`Gregorian month lengths and leap year rules.
800
- - `monthsInYear` — returns 12 unconditionally.
801
- - `isLeapMonth` — returns false unconditionally.
802
- - `dayOfYear` — Gregorian day-of-year.
803
- - `weekOfYear`, `weekYear` — ISO 8601 week numbering.
772
+ A handful of functions exist specifically to feed editor tooling autocomplete, hover docs, inline diagnostics rather than for use in application code directly:
804
773
 
805
- For non-Gregorian calendars, pass the value to `Temporal.PlainDate` directly and use its own calendar-aware methods instead:
774
+ - `getAutocompleteData()` token autocomplete entries, grouped by family.
775
+ - `getHoverDocs()` — per-token hover documentation.
776
+ - `getInlineDiagnostics(formatStr)` — diagnostics with position and suggested fixes for a given format string.
777
+ - `previewFormat(formatStr, sample?)` — a live preview string for a format, given an optional sample value.
778
+ - `getDocUrl(tokenName)` — a documentation URL for a token, currently pointing at this README's [Tokens](#tokens) section.
779
+ - `DAYJS_TO_TEMPORAL_FMT` / `DATE_FNS_TO_TEMPORAL_FMT` — token conversion hint tables, the same ones the CLI's `translate` subcommand and any migration tooling draw from.
806
780
 
807
- ```js
808
- const pd = Temporal.PlainDate.from('5784-05-10[u-ca=hebrew]');
809
- console.log(pd.daysInMonth); // 30 (Sivan)
810
- console.log(pd.monthsInYear); // 13 (leap year)
811
- ```
781
+ ## CLI
812
782
 
813
- **Locale-aware tokens ARE calendar-aware**, unlike the helpers above. `MMMM`, `MMM`, `EEEE`, `EEE`, `a` go through `Intl.DateTimeFormat`, which respects the value's `calendarId`:
783
+ The CLI ships in this package (`scripts/cli.mjs`) and reads/writes stdin/stdout. Run it via `npm run cli` inside a checkout of this repo, or `node scripts/cli.mjs` directly:
814
784
 
815
- ```js
816
- const hebrewDate = Temporal.PlainDate.from('5784-05-10[u-ca=hebrew]');
817
- format(hebrewDate, 'MMMM d, yyyy');
818
- // "Sivan 10, 5784"
785
+ ```sh
786
+ temporal-fmt format "2026-08-04T15:45:30" "yyyy-MM-dd HH:mm:ss"
787
+ temporal-fmt parse "yyyy-MM-dd" "2026-08-04"
788
+ temporal-fmt inspect "MMMM d, yyyy 'at' h:mm a"
789
+ temporal-fmt validate "yyyy-MM-dd HH:mm:ss"
790
+ temporal-fmt translate dayjs "YYYY-MM-DD HH:mm:ss"
819
791
  ```
820
792
 
821
- Numeric tokens (`yyyy`, `MM`, `dd`) read straight off the object's ISO fields — calendar-specific in the sense that the underlying Temporal value carries calendar-specific field values, but the formatting logic itself is calendar-agnostic.
822
-
823
- **Custom calendar vocabulary.** For calendars Intl doesn't cover (Hebrew leap months, for instance), register one via `registerLocaleVocab` — see [Locale guide](#locale-guide) above for the full vocab surface and validation rules.
793
+ | Subcommand | What it does |
794
+ |------------|---------------|
795
+ | `format <iso-input> <format-string> [--locale=LOCALE]` | Formats an ISO date/time input against the given format string. |
796
+ | `parse <format-string> <input> [--locale=LOCALE] [--lenient]` | Parses input against a format string, prints the resulting ISO value. |
797
+ | `inspect <format-string>` | Prints `explainFormat`'s report on a format string. |
798
+ | `validate <format-string>` | Prints `valid` or `invalid`. |
799
+ | `translate <source-lib> <format-string>` | Translates a Day.js or date-fns format string to `temporal-fmt` tokens. |
824
800
 
825
- ## Migration guide
801
+ The `translate` subcommand imports a separate `temporal-fmt-codemod` package at runtime — it isn't bundled in this repo, so `translate` will fail with a module-not-found error unless that package is installed and resolvable. Every other subcommand works standalone.
826
802
 
827
- `temporal-fmt` is designed to make migration from Day.js and date-fns straightforward. The token grammar is largely compatible, and the codemod automates the bulk of the work.
828
-
829
- ### Automated migration
803
+ ## Subpath imports
830
804
 
831
- The `temporal-fmt-codemod` package includes AST transforms for Day.js and date-fns:
805
+ Each capability area is also available as a subpath import, for anyone who wants a slice of the package rather than the whole thing:
832
806
 
833
- ```sh
834
- npx temporal-fmt-codemod --source=dayjs path/to/src
835
- npx temporal-fmt-codemod --source=date-fns path/to/src
807
+ ```js
808
+ import { format } from 'temporal-fmt/format';
809
+ import { parse } from 'temporal-fmt/parse';
810
+ import { formatDuration } from 'temporal-fmt/duration';
811
+ import { formatRelative } from 'temporal-fmt/relative';
812
+ import { interval, formatRange } from 'temporal-fmt/interval';
813
+ import { daysInMonth, startOf } from 'temporal-fmt/calendar';
814
+ import { resolveZoned, isDST } from 'temporal-fmt/timezone';
815
+ import { recurrence } from 'temporal-fmt/recurrence';
816
+ import { registerLocale } from 'temporal-fmt/locale';
836
817
  ```
837
818
 
838
- It's conservative: it only transforms call sites where the format string is a plain string literal, and only rewrites tokens with known-safe mappings. Unmappable tokens leave a `TODO(temporal-fmt-codemod)` comment.
819
+ 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.
839
820
 
840
- For a one-off translation without running the full codemod, use the CLI:
821
+ ## Migrating from Day.js or date-fns
841
822
 
842
- ```sh
843
- npx temporal-fmt translate dayjs "YYYY-MM-DD HH:mm:ss"
844
- # → "yyyy-MM-dd HH:mm:ss"
845
- ```
846
-
847
- ### Manual migration: token mapping
823
+ ### Token mapping
848
824
 
849
- Most date-fns/Day.js tokens are identical to `temporal-fmt` tokens. The differences:
825
+ Most date-fns/Day.js tokens map directly. The differences:
850
826
 
851
827
  | Source (Day.js / date-fns) | temporal-fmt | Notes |
852
828
  |---|---|---|
853
- | `YYYY` | `yyyy` | Lowercase in temporal-fmt |
829
+ | `YYYY` | `yyyy` | Lowercase here |
854
830
  | `YY` | `yy` | Same |
855
831
  | `MMMM`, `MMM`, `MM`, `M` | same | Identical |
856
- | `DD`, `D` | `dd`, `d` | Lowercase in temporal-fmt |
832
+ | `DD`, `D` | `dd`, `d` | Lowercase here |
857
833
  | `dddd` | `EEEE` | Long weekday |
858
834
  | `ddd` | `EEE` | Short weekday |
859
835
  | `HH`, `H`, `mm`, `m`, `ss`, `s` | same | Identical |
860
- | `A`, `a` | `a` | Always lowercase in temporal-fmt |
861
- | `Z` | `XXX` | Numeric UTC offset with colon, Z for UTC |
862
- | `ZZ` | `XX` | Numeric UTC offset no colon |
863
- | `X` | (not supported) | Unix timestamp — convert via `fromUnixSeconds` instead |
864
- | `x` | (not supported) | Unix ms timestamp — convert via `fromUnixMilliseconds` |
865
- | `P` | (not supported) | Localized long format — write the format string explicitly |
836
+ | `A`, `a` | `a` | Always lowercase here |
837
+ | `Z` | `XXX` | Numeric UTC offset with colon, `Z` for UTC |
838
+ | `ZZ` | `XX` | Numeric UTC offset, no colon |
839
+ | `X` | not supported | Unix timestamp — use `fromUnixSeconds` instead |
840
+ | `x` | not supported | Unix ms timestamp — use `fromUnixMilliseconds` instead |
841
+ | `P` | not supported | Localized long format — write the format string out explicitly |
866
842
 
867
- ### Manual migration: API mapping
843
+ ### API mapping
868
844
 
869
845
  | Day.js | date-fns | temporal-fmt |
870
846
  |---|---|---|
@@ -874,167 +850,102 @@ Most date-fns/Day.js tokens are identical to `temporal-fmt` tokens. The differen
874
850
  | `dayjs().diff(other, 'day')` | `differenceInDays(a, b)` | `differenceInDays(a, b)` |
875
851
  | `dayjs().isBefore(other)` | `isBefore(date, other)` | `isBefore(date, other)` |
876
852
  | `dayjs().isAfter(other)` | `isAfter(date, other)` | `isAfter(date, other)` |
877
- | `dayjs.duration(...)` | `intervalToDuration(...)` | `Temporal.Duration.from(...)` |
878
853
  | `dayjs().isToday()` | `isToday(date)` | `isToday(date)` |
879
854
  | `dayjs().isYesterday()` | `isYesterday(date)` | `isYesterday(date)` |
880
855
  | `dayjs().isTomorrow()` | `isTomorrow(date)` | `isTomorrow(date)` |
881
856
 
882
- ### Key behavioral differences
857
+ ### Behavioral differences that will bite you if you skip this
883
858
 
884
- 1. **Strict parsing.** `temporal-fmt` throws on ambiguous input by default. Day.js silently picks one reading. For ambiguous glued numeric tokens (`Md` against `121`, say), either add separators or use `{ lenient: true }`.
885
- 2. **Cross-field validation.** `temporal-fmt` cross-checks weekday, quarter, and offset against the parsed date. Day.js doesn't — a `Monday` label that disagrees with the actual date will throw here.
886
- 3. **No silent defaults.** `temporal-fmt` doesn't fall back to "now" when input is missing. Pass an explicit value.
887
- 4. **Type-preserving.** `format(plainDate, 'HH:mm')` throws — `PlainDate` has no hour field. Day.js silently uses 0. Pass a `PlainDateTime`, or use a date-only format.
888
- 5. **Temporal-native.** The library operates on Temporal types (`PlainDate`, `PlainDateTime`, `ZonedDateTime`, etc.), not on JS `Date`. Convert at the boundary:
859
+ 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 }`.
860
+ 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.
861
+ 3. **No silent "now" fallback.** Missing input doesn't fall back to the current time; pass an explicit value.
862
+ 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.
863
+ 5. **Temporal-native, not `Date`-native.** Operates on `PlainDate`/`PlainDateTime`/`ZonedDateTime`/etc, not the legacy `Date` object. Convert at the boundary:
889
864
 
890
865
  ```js
891
866
  import { Temporal } from 'temporal-polyfill';
892
867
  const pd = Temporal.PlainDate.from(jsDate.toISOString().slice(0, 10));
893
- const formatted = format(pd, 'yyyy-MM-dd');
868
+ format(pd, 'yyyy-MM-dd');
894
869
  ```
895
870
 
896
- ### Common migration patterns
897
-
898
- **CSV/log timestamp parsing:**
871
+ ### Common patterns, before and after
899
872
 
900
873
  ```js
901
- // Before (Day.js)
902
- const d = dayjs(line, 'YYYY-MM-DD HH:mm:ss');
903
- // After
874
+ // CSV/log timestamp parsing
875
+ // Before: const d = dayjs(line, 'YYYY-MM-DD HH:mm:ss');
904
876
  const d = parse('yyyy-MM-dd HH:mm:ss', line);
905
- ```
906
-
907
- **Locale-aware formatting:**
908
-
909
- ```js
910
- // Before
911
- dayjs(date).locale('fr').format('MMMM D, YYYY');
912
- // After
913
- format(date, 'MMMM d, yyyy', { locale: 'fr-FR' });
914
- // → "août 4, 2026"
915
- ```
916
877
 
917
- **Relative time:**
878
+ // Locale-aware formatting
879
+ // Before: dayjs(date).locale('fr').format('MMMM D, YYYY');
880
+ format(date, 'MMMM d, yyyy', { locale: 'fr-FR' }); // "août 4, 2026"
918
881
 
919
- ```js
920
- // Before
921
- dayjs(date).fromNow(); // "3 days ago"
922
- // After
882
+ // Relative time
883
+ // Before: dayjs(date).fromNow(); // "3 days ago"
923
884
  formatRelativeToNow(date); // "3 days ago"
924
- ```
925
-
926
- **Date arithmetic:**
927
885
 
928
- ```js
929
- // Before
930
- dayjs(date).add(7, 'day');
931
- // After
932
- add(date, 7, 'days');
933
- // or: addDays(date, 7)
886
+ // Date arithmetic
887
+ // Before: dayjs(date).add(7, 'day');
888
+ add(date, 7, 'days'); // or addDays(date, 7)
934
889
  ```
935
890
 
936
891
  ### Things that don't migrate cleanly
937
892
 
938
- - Day.js's `dayjs.extend(customParseFormat)` plugin behavior `temporal-fmt`'s parse is strict, the plugin is lenient. Audit any callers relying on lenient parsing.
939
- - Day.js's mutable locale registration (`dayjs.locale('fr')`) — `temporal-fmt` uses per-call `locale` options, no global mutation.
940
- - date-fns's `format` with a locale object parameter — `temporal-fmt` uses BCP-47 strings, not locale objects.
941
- - Timezone-aware formatting via `dayjs-timezone` — use `Temporal.ZonedDateTime` and the `zzz`/`XXX` tokens instead.
893
+ - 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.
894
+ - Day.js's mutable global locale (`dayjs.locale('fr')`) — this library uses per-call `locale` options, no global mutation.
895
+ - date-fns's `format` with a locale *object* parameter — this library takes BCP-47 strings.
896
+ - `dayjs-timezone` — use `Temporal.ZonedDateTime` and the `zzz`/offset tokens instead.
942
897
 
943
898
  ### Running both during migration
944
899
 
945
- Both libraries can coexist. Wrap migration in a feature flag:
946
-
947
900
  ```js
948
901
  import dayjs from 'dayjs';
949
902
  import { format as fmtTemporal } from 'temporal-fmt';
950
903
 
951
904
  function formatDate(date, formatStr, opts) {
952
- if (opts?.useTemporal) {
953
- return fmtTemporal(date, formatStr, opts);
954
- }
905
+ if (opts?.useTemporal) return fmtTemporal(date, formatStr, opts);
955
906
  return dayjs(date).format(formatStr);
956
907
  }
957
908
  ```
958
909
 
959
- Run the codemod per-file when ready, then drop the wrapper.
960
-
961
- ## Other guides
962
-
963
- A few narrower topics — business calendars, intervals, recurrence, durations, timezones, serialization, performance, security, and the ESLint plugin/codemod internals — don't have write-ups of their own yet beyond what's covered above and in the [API reference](#api-reference). The test files (`test/*.test.js`) are the best source for concrete usage of any of these; each one is effectively a usage guide for its corresponding module.
910
+ Migrate file by file, dropping the wrapper once nothing calls the old path anymore.
964
911
 
965
912
  ## Known limitations
966
913
 
967
- - Numeral systems are always Western digits — see [Locale support](#locale-support).
968
- - Locale-aware tokens need Node 20+, native or polyfilled. Untested below Node 20.
969
- - As mentioned above, you must [provide a Temporal implementation](#providing-temporal)
970
- if it is not natively provided (Node 26+)
971
- - On engines with native `Temporal` support (Node 26+), locale-aware tokens
972
- (`MMMM`/`MMM`/`EEEE`/`EEE`) can render the wrong month or weekday for dates
973
- before around 1582 CE. This is a known ICU limitation, not a bug in this
974
- library: ICU's default Gregorian calendar cutover is October 15, 1582, so
975
- `Intl.DateTimeFormat.formatToParts()` silently reinterprets earlier dates
976
- under the Julian calendar, even though `Temporal` itself uses a proleptic
977
- Gregorian calendar throughout — see
978
- [tc39/ecma402#1003](https://github.com/tc39/ecma402/issues/1003). Numeric
979
- tokens (`yyyy`/`MM`/`dd`) never go through `Intl` and aren't affected.
980
- - Gluing two unpadded numeric tokens with no separator between them (e.g.
981
- `Md`, `dM`, `Hm`) is ambiguous for some inputs, and `parse()` throws rather
982
- than guessing. `"121"` against `yyyy-Md` could mean month 1/day 21 or month
983
- 12/day 1 — both are valid, so there's no single correct reading to fall
984
- back to. Unambiguous inputs against the same format string still parse
985
- normally (`"85"` against `yyyy-Md` only has one valid split). If you need
986
- glued numeric fields, either zero-pad them (`MM`/`dd`) or put a separator
987
- between them; that removes the ambiguity entirely.
988
-
989
- Note: `Md` (or `dM`/`Hm`) alone, with no `yyyy`, always throws —
990
- `parse()` requires year, month, and day together to build a date, so a
991
- bare `Md` format string is incomplete regardless of ambiguity. The
992
- examples above use `yyyy-Md` for exactly this reason.
993
-
994
- - Offset tokens (`X`/`XX`/`XXX`/`x`/`xx`/`xxx`) read `ZonedDateTime.prototype.offset`, which Temporal exposes as a `+HH:MM` string for any modern date. Historical LMT (Local Mean Time) offsets with seconds — e.g. Europe/London before 1847, when it was `+00:01:15` — aren't reachable through that field, and the regex shapes the offset tokens accept don't include a seconds group either. If you need to round-trip a sub-minute historical offset, you're outside what the offset tokens can express; construct the `ZonedDateTime` directly.
995
-
996
- Range is bounded to `-12:00` through `+14:00`, the IANA-supported range (Baker Island at `-12:00`, Kiritimati at `+14:00`). `+14:01` and `-12:01` throw with a descriptive error even though each piece alone is in bounds — the overall offset exceeds the maximum any real zone uses.
997
-
998
- ## Dev notes
999
-
1000
- Building requires TypeScript 7.0.2+ but `.d.ts` generation runs as a separate
1001
- `tsc` pass, not through tsup. tsup's dts step bundles types via
1002
- `rollup-plugin-dts`, which calls into TypeScript's compiler API — the API
1003
- isn't stable yet on 7.x ([targeted for
1004
- 7.1](https://devblogs.microsoft.com/typescript/announcing-typescript-7-0/)),
1005
- so it crashes on 7.0.2. `tsup.config.ts` sets `dts: false` and `build` runs
1006
- `tsup && tsc --declaration --emitDeclarationOnly` instead. One side effect:
1007
- `dist/` now has one `.d.ts` per source file instead of a single rolled-up
1008
- `index.d.ts` — same exported API, different file layout. Revert to `dts:
1009
- true` once tsup/rollup-plugin-dts catch up.
1010
-
1011
- Tests pull from `temporal-polyfill/full`, not the slim `temporal-polyfill` —
1012
- the Hebrew-calendar test needs the full build's calendar data, and the slim
1013
- one won't cut it. Locale-aware tests pass on Node 20+ regardless of whether
1014
- `Temporal` is native or polyfilled — on native (Node 26+), formatting goes
1015
- through `Intl.DateTimeFormat` directly; on the polyfill, it falls back to
1016
- `Temporal.prototype.toLocaleString()`, which the polyfill implements itself.
1017
- `parse.test.js` configures Temporal via `setTemporal()` rather than mutating
1018
- `globalThis.Temporal` directly.
1019
-
1020
- Run `npm run test:all`, not just `npm test`. `npm test` only runs the
1021
- `node:test` suite in `test/*.test.js` — hand-picked, fuzz, adversarial, and
1022
- perf cases that exercise the public API end to end. It doesn't touch
1023
- `vitest/`, which unit-tests internals like `enumerateValidSplits()`
1024
- directly. That function resolves ambiguous glued numeric runs (does `"112"`
1025
- against `['M', 'd']` mean month 1/day 12, or month 11/day 2?), and a bug
1026
- in its edge cases — an empty token list, a range-boundary off-by-one — can
1027
- easily dodge every `parse()` example in the main suite without ever being
1028
- the specific input one of them happens to use. `test:all` also runs the
1029
- type tests (`test:types`), so it's the only single command that actually
1030
- covers everything. CI runs `test:all` for this reason; running plain `npm
1031
- test` locally will pass even with a broken `vitest/` suite.
914
+ - **Numerals are always Western digits** in numeric tokens, regardless of locale — see [Locales](#locales).
915
+ - **Locale-aware tokens need Node 20+**, native or polyfilled. Untested below that.
916
+ - **You must provide a Temporal implementation** on anything below Node 26 — see [Providing `Temporal`](#providing-temporal).
917
+ - **Pre-1582 dates and locale-aware tokens don't mix well on native Temporal (Node 26+).** `MMMM`/`MMM`/`EEEE`/`EEE` can render the wrong month or weekday for dates before roughly 1582 CE. This is an ICU limitation, not a bug here: ICU's default Gregorian calendar cutover is October 15, 1582, so `Intl.DateTimeFormat.formatToParts()` silently reinterprets earlier dates under the Julian calendar even though `Temporal` itself uses a proleptic Gregorian calendar throughout — see [tc39/ecma402#1003](https://github.com/tc39/ecma402/issues/1003). Numeric tokens never touch `Intl` and aren't affected.
918
+ - **Gluing two unpadded numeric tokens with no separator is ambiguous for some inputs**, and `parse()` throws rather than guessing (`Md`, `dM`, `Hm` against certain input). `"121"` against `yyyy-Md` could mean month 1/day 21 or month 12/day 1 — both valid, no single correct reading. Unambiguous inputs against the same format string parse fine (`"85"` against `yyyy-Md` has only one valid split). Fix it by zero-padding (`MM`/`dd`), adding a separator, or opting into `{ lenient: true }`. Note that `Md` (or `dM`/`Hm`) with no `yyyy` present always throws regardless of ambiguity — `parse()` needs year, month, and day together to build a date at all.
919
+ - **Offset tokens can't express sub-minute historical offsets.** They read `ZonedDateTime.prototype.offset`, which Temporal exposes as `+HH:MM` for any modern date. Historical LMT offsets with seconds (Europe/London before 1847 was `+00:01:15`) aren't reachable through that field, and the offset tokens' regex shapes don't include a seconds group either. Construct the `ZonedDateTime` directly if you need to round-trip one of those. Offset range is bounded to `-12:00` through `+14:00` (Baker Island to Kiritimati) — `+14:01`/`-12:01` throw even though each digit is individually plausible, since no real zone uses an offset past that range.
1032
920
 
1033
921
  ## Related tools
1034
922
 
1035
- - [`eslint-plugin-temporal-fmt`](https://www.npmjs.com/package/eslint-plugin-temporal-fmt) lints format strings for common mistakes (e.g. `hh` without `a`)
1036
- - [`temporal-fmt-codemod`](https://www.npmjs.com/package/temporal-fmt-codemod) — one-time migration tool that rewrites dayjs/date-fns calls to temporal-fmt
923
+ Neither of these ships as part of this repository separate packages, install them on their own:
924
+
925
+ - [`eslint-plugin-temporal-fmt`](https://www.npmjs.com/package/eslint-plugin-temporal-fmt) — lints format strings for common mistakes (e.g. `hh` without `a`). This is what backs 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.
926
+ - [`temporal-fmt-codemod`](https://www.npmjs.com/package/temporal-fmt-codemod) — one-time migration tool that rewrites Day.js/date-fns calls to `temporal-fmt`. The CLI's `translate` subcommand (see [CLI](#cli)) imports this package at runtime, so `translate` needs it installed to work.
927
+
928
+ ## Testing
929
+
930
+ This library is heavily tested. The `node:test` suite (`test/*.test.js`) runs 800+ 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.
931
+
932
+ ## Contributing
933
+
934
+ ```sh
935
+ git clone https://github.com/DirazCoder/temporal-fmt.git
936
+ cd temporal-fmt
937
+ npm install
938
+ npm run build
939
+ npm run test:all
940
+ ```
941
+
942
+ `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.
943
+
944
+ A couple of build-specific notes if you're touching the toolchain:
945
+
946
+ - 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.
947
+ - 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.
1037
948
 
1038
949
  ## License
1039
950
 
1040
- MIT
951
+ MIT — see [LICENSE](./LICENSE).