temporal-fmt 0.8.82 → 0.8.92
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +735 -281
- package/calendar.d.cts +1 -0
- package/calendar.d.ts +1 -0
- package/dist/analyze.d.cts +38 -0
- package/dist/analyze.d.ts +38 -0
- package/dist/arithmetic.d.cts +37 -0
- package/dist/arithmetic.d.ts +37 -0
- package/dist/businessCalendar.d.cts +20 -0
- package/dist/businessCalendar.d.ts +20 -0
- package/dist/calendarUtils.cjs +234 -0
- package/dist/calendarUtils.cjs.map +1 -0
- package/dist/calendarUtils.d.cts +28 -0
- package/dist/calendarUtils.d.ts +28 -0
- package/dist/calendarUtils.js +34 -0
- package/dist/calendarUtils.js.map +1 -0
- package/dist/chunk-5GBOYMJS.js +57 -0
- package/dist/chunk-5GBOYMJS.js.map +1 -0
- package/dist/chunk-5QZT6T4P.js +164 -0
- package/dist/chunk-5QZT6T4P.js.map +1 -0
- package/dist/chunk-7SFQUOID.js +295 -0
- package/dist/chunk-7SFQUOID.js.map +1 -0
- package/dist/chunk-7ZKINLTL.js +749 -0
- package/dist/chunk-7ZKINLTL.js.map +1 -0
- package/dist/chunk-A76C5AGZ.js +156 -0
- package/dist/chunk-A76C5AGZ.js.map +1 -0
- package/dist/chunk-C3755VAP.js +200 -0
- package/dist/chunk-C3755VAP.js.map +1 -0
- package/dist/chunk-CWHSGH2Z.js +152 -0
- package/dist/chunk-CWHSGH2Z.js.map +1 -0
- package/dist/chunk-DXDPV25F.js +176 -0
- package/dist/chunk-DXDPV25F.js.map +1 -0
- package/dist/chunk-EDUFSRWJ.js +9 -0
- package/dist/chunk-EDUFSRWJ.js.map +1 -0
- package/dist/chunk-ETS7Q56I.js +69 -0
- package/dist/chunk-ETS7Q56I.js.map +1 -0
- package/dist/chunk-GIQZ24RL.js +188 -0
- package/dist/chunk-GIQZ24RL.js.map +1 -0
- package/dist/chunk-J3YC66TB.js +150 -0
- package/dist/chunk-J3YC66TB.js.map +1 -0
- package/dist/chunk-MXXYIMFJ.js +29 -0
- package/dist/chunk-MXXYIMFJ.js.map +1 -0
- package/dist/chunk-PGWSA5QP.js +589 -0
- package/dist/chunk-PGWSA5QP.js.map +1 -0
- package/dist/chunk-QL6WPSKN.js +197 -0
- package/dist/chunk-QL6WPSKN.js.map +1 -0
- package/dist/chunk-RY7Q64ZS.js +73 -0
- package/dist/chunk-RY7Q64ZS.js.map +1 -0
- package/dist/chunk-V35OBXHF.js +206 -0
- package/dist/chunk-V35OBXHF.js.map +1 -0
- package/dist/chunk-YS52LOMJ.js +51 -0
- package/dist/chunk-YS52LOMJ.js.map +1 -0
- package/dist/comparison.d.cts +18 -0
- package/dist/comparison.d.ts +18 -0
- package/dist/config.d.cts +22 -0
- package/dist/config.d.ts +22 -0
- package/dist/duration.cjs +601 -0
- package/dist/duration.cjs.map +1 -0
- package/dist/duration.d.cts +20 -0
- package/dist/duration.d.ts +20 -0
- package/dist/duration.js +31 -0
- package/dist/duration.js.map +1 -0
- package/dist/errors.d.cts +85 -0
- package/dist/errors.d.ts +85 -0
- package/dist/extensibility.d.cts +19 -0
- package/dist/extensibility.d.ts +19 -0
- package/dist/format.cjs +594 -0
- package/dist/format.cjs.map +1 -0
- package/dist/format.d.cts +19 -0
- package/dist/format.d.ts +19 -0
- package/dist/format.js +21 -0
- package/dist/format.js.map +1 -0
- package/dist/holidays.d.cts +17 -0
- package/dist/holidays.d.ts +17 -0
- package/dist/ideData.d.cts +35 -0
- package/dist/ideData.d.ts +35 -0
- package/dist/index.cjs +5898 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +27 -3
- package/dist/index.d.ts +27 -3
- package/dist/index.js +2654 -1
- package/dist/index.js.map +1 -1
- package/dist/interval.cjs +783 -0
- package/dist/interval.cjs.map +1 -0
- package/dist/interval.d.cts +22 -0
- package/dist/interval.d.ts +22 -0
- package/dist/interval.js +42 -0
- package/dist/interval.js.map +1 -0
- package/dist/localeRegistry.cjs +282 -0
- package/dist/localeRegistry.cjs.map +1 -0
- package/dist/localeRegistry.d.cts +27 -0
- package/dist/localeRegistry.d.ts +27 -0
- package/dist/localeRegistry.js +13 -0
- package/dist/localeRegistry.js.map +1 -0
- package/dist/numbering.d.cts +12 -0
- package/dist/numbering.d.ts +12 -0
- package/dist/parse.cjs +1404 -0
- package/dist/parse.cjs.map +1 -0
- package/dist/parse.d.cts +27 -0
- package/dist/parse.d.ts +27 -0
- package/dist/parse.js +22 -0
- package/dist/parse.js.map +1 -0
- package/dist/pattern.d.cts +1 -0
- package/dist/pattern.d.ts +1 -0
- package/dist/recurrence.cjs +376 -0
- package/dist/recurrence.cjs.map +1 -0
- package/dist/recurrence.d.cts +35 -0
- package/dist/recurrence.d.ts +35 -0
- package/dist/recurrence.js +21 -0
- package/dist/recurrence.js.map +1 -0
- package/dist/relativeGrammar.d.cts +27 -0
- package/dist/relativeGrammar.d.ts +27 -0
- package/dist/relativeTime.cjs +178 -0
- package/dist/relativeTime.cjs.map +1 -0
- package/dist/relativeTime.d.cts +6 -0
- package/dist/relativeTime.d.ts +6 -0
- package/dist/relativeTime.js +15 -0
- package/dist/relativeTime.js.map +1 -0
- package/dist/rounding.d.cts +37 -0
- package/dist/rounding.d.ts +37 -0
- package/dist/serialization.d.cts +18 -0
- package/dist/serialization.d.ts +18 -0
- package/dist/temporalProvider.d.cts +19 -1
- package/dist/temporalProvider.d.ts +19 -1
- package/dist/timezone.cjs +346 -0
- package/dist/timezone.cjs.map +1 -0
- package/dist/timezone.d.cts +33 -0
- package/dist/timezone.d.ts +33 -0
- package/dist/timezone.js +27 -0
- package/dist/timezone.js.map +1 -0
- package/dist/tokenMetadata.d.cts +13 -0
- package/dist/tokenMetadata.d.ts +13 -0
- package/dist/typeGuards.d.cts +49 -0
- package/dist/typeGuards.d.ts +49 -0
- package/duration.d.cts +1 -0
- package/duration.d.ts +1 -0
- package/format.d.cts +1 -0
- package/format.d.ts +1 -0
- package/interval.d.cts +1 -0
- package/interval.d.ts +1 -0
- package/locale.d.cts +1 -0
- package/locale.d.ts +1 -0
- package/package.json +115 -5
- package/parse.d.cts +1 -0
- package/parse.d.ts +1 -0
- package/recurrence.d.cts +1 -0
- package/recurrence.d.ts +1 -0
- package/relative.d.cts +1 -0
- package/relative.d.ts +1 -0
- package/scripts/cli.mjs +182 -0
- package/timezone.d.cts +1 -0
- package/timezone.d.ts +1 -0
package/README.md
CHANGED
|
@@ -24,6 +24,36 @@ npm install temporal-fmt
|
|
|
24
24
|
|
|
25
25
|
[View on npm](https://www.npmjs.com/package/temporal-fmt)
|
|
26
26
|
|
|
27
|
+
## Contents
|
|
28
|
+
|
|
29
|
+
- [Providing `Temporal`](#providing-temporal)
|
|
30
|
+
- [Formatting](#formatting)
|
|
31
|
+
- [Parsing](#parsing)
|
|
32
|
+
- [Locales](#locales)
|
|
33
|
+
- [Tokens](#tokens)
|
|
34
|
+
- [Duration formatting](#duration-formatting)
|
|
35
|
+
- [Relative time](#relative-time)
|
|
36
|
+
- [Natural-language date parsing](#natural-language-date-parsing)
|
|
37
|
+
- [Date arithmetic, comparison, and rounding](#date-arithmetic-comparison-and-rounding)
|
|
38
|
+
- [Intervals](#intervals)
|
|
39
|
+
- [Recurrence](#recurrence)
|
|
40
|
+
- [Business calendars and holidays](#business-calendars-and-holidays)
|
|
41
|
+
- [Time zones](#time-zones)
|
|
42
|
+
- [Serialization](#serialization)
|
|
43
|
+
- [Introspection and the analyzer](#introspection-and-the-analyzer)
|
|
44
|
+
- [Typed errors](#typed-errors)
|
|
45
|
+
- [Type guards](#type-guards)
|
|
46
|
+
- [Config](#config)
|
|
47
|
+
- [Extending with custom tokens](#extending-with-custom-tokens)
|
|
48
|
+
- [IDE tooling data](#ide-tooling-data)
|
|
49
|
+
- [CLI](#cli)
|
|
50
|
+
- [Subpath imports](#subpath-imports)
|
|
51
|
+
- [Migrating from Day.js or date-fns](#migrating-from-dayjs-or-date-fns)
|
|
52
|
+
- [Known limitations](#known-limitations)
|
|
53
|
+
- [Related tools](#related-tools)
|
|
54
|
+
- [Contributing](#contributing)
|
|
55
|
+
- [License](#license)
|
|
56
|
+
|
|
27
57
|
## Providing `Temporal`
|
|
28
58
|
|
|
29
59
|
### Node 26+
|
|
@@ -32,8 +62,7 @@ Temporal is native and used automatically.
|
|
|
32
62
|
|
|
33
63
|
### Polyfill
|
|
34
64
|
|
|
35
|
-
Use a polyfill like [`temporal-polyfill`](https://github.com/fullcalendar/temporal-polyfill) to
|
|
36
|
-
in the global namespace.
|
|
65
|
+
Use a polyfill like [`temporal-polyfill`](https://github.com/fullcalendar/temporal-polyfill) to put Temporal on the global namespace.
|
|
37
66
|
|
|
38
67
|
```js
|
|
39
68
|
import 'temporal-polyfill/global'
|
|
@@ -42,23 +71,22 @@ import { format, parse } from 'temporal-fmt';
|
|
|
42
71
|
parse(...);
|
|
43
72
|
```
|
|
44
73
|
|
|
45
|
-
### Bring
|
|
74
|
+
### Bring your own
|
|
46
75
|
|
|
47
|
-
Set a Temporal implementation explicitly, once, before your app's first
|
|
48
|
-
`format()`/`parse()` call:
|
|
76
|
+
Set a Temporal implementation explicitly, once, before your app's first `format()`/`parse()` call:
|
|
49
77
|
|
|
50
78
|
```js
|
|
51
79
|
import { Temporal } from 'temporal-polyfill/full';
|
|
52
80
|
import { setTemporal, format, parse } from 'temporal-fmt';
|
|
53
81
|
|
|
54
|
-
setTemporal(Temporal); // once, before using
|
|
82
|
+
setTemporal(Temporal); // once, before using format or parse
|
|
55
83
|
```
|
|
56
84
|
|
|
57
|
-
`setTemporal()` takes precedence over native or global Temporal, and
|
|
58
|
-
|
|
59
|
-
|
|
85
|
+
`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`.
|
|
86
|
+
|
|
87
|
+
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
88
|
|
|
61
|
-
##
|
|
89
|
+
## Formatting
|
|
62
90
|
|
|
63
91
|
```js
|
|
64
92
|
import { format } from 'temporal-fmt';
|
|
@@ -74,14 +102,17 @@ const zdt = Temporal.ZonedDateTime.from('2026-08-04T15:45:30-04:00[America/New_Y
|
|
|
74
102
|
format(zdt, 'yyyy-MM-dd HH:mm zzz'); // "2026-08-04 15:45 America/New_York"
|
|
75
103
|
```
|
|
76
104
|
|
|
77
|
-
Wrap literal text in single quotes, like `'at'` above. Need an actual single
|
|
78
|
-
|
|
105
|
+
Wrap literal text in single quotes, like `'at'` above. Need an actual single quote in your output? Use `''`.
|
|
106
|
+
|
|
107
|
+
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
108
|
|
|
80
|
-
|
|
109
|
+
- `format(temporal, formatStr, options?)` — formats a Temporal value against a token string. Returns a string.
|
|
110
|
+
- `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).
|
|
111
|
+
- `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
112
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
present:
|
|
113
|
+
## Parsing
|
|
114
|
+
|
|
115
|
+
`parse()` builds a real `Temporal.PlainDate` / `PlainTime` / `PlainDateTime` / `ZonedDateTime` out of a string, picking whichever type fits the tokens present:
|
|
85
116
|
|
|
86
117
|
```js
|
|
87
118
|
import { parse } from 'temporal-fmt';
|
|
@@ -91,47 +122,62 @@ parse('yyyy-MM', '2026-08-04T15:45:30'); // throws — shape doesn't ma
|
|
|
91
122
|
parse('yyyy-MM-dd', '2026-02-30'); // throws — not a real date
|
|
92
123
|
```
|
|
93
124
|
|
|
94
|
-
Because the format
|
|
95
|
-
with `instanceof`, or manually assert/type guard it in Typescript, to narrow the type.
|
|
125
|
+
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
126
|
|
|
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:
|
|
127
|
+
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
128
|
|
|
101
129
|
```js
|
|
102
130
|
parse('EEEE, yyyy-MM-dd', 'Tuesday, 2026-08-04'); // fine — that really is a Tuesday
|
|
103
131
|
parse('EEEE, yyyy-MM-dd', 'Monday, 2026-08-04'); // throws — it isn't
|
|
104
132
|
```
|
|
105
133
|
|
|
106
|
-
`parse` throws when `input` doesn't match `formatStr`'s shape at all
|
|
107
|
-
|
|
134
|
+
`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.
|
|
135
|
+
|
|
136
|
+
Three other entry points, same underlying logic, different failure handling:
|
|
137
|
+
|
|
138
|
+
- `safeParse(formatStr, input, options?)` — never throws. Returns `{ ok: true, value }` or `{ ok: false, error: TemporalFmtError }`.
|
|
139
|
+
- `tryParse(formatStr, input, options?)` — never throws. Returns the value, or `undefined` on any failure.
|
|
140
|
+
- `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.
|
|
141
|
+
- `compileParser(formatStr, options?)` — pre-compiles a parser for repeated use against the same format string.
|
|
142
|
+
|
|
143
|
+
A few things worth knowing about how `parse()` behaves:
|
|
144
|
+
|
|
145
|
+
- **`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.
|
|
146
|
+
- **`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.
|
|
147
|
+
- **`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.
|
|
148
|
+
- **`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.
|
|
149
|
+
- **`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.
|
|
150
|
+
- **`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.
|
|
151
|
+
|
|
152
|
+
### Ambiguous input and lenient mode
|
|
153
|
+
|
|
154
|
+
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.
|
|
155
|
+
|
|
156
|
+
```js
|
|
157
|
+
parse('yyyy-Md', '2026-121') // throws — ambiguous
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Pass `{ lenient: true }` to opt into a documented heuristic instead:
|
|
161
|
+
|
|
162
|
+
```js
|
|
163
|
+
parse('yyyy-Md', '2026-121', { lenient: true }).toString() // '2026-12-01'
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
**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.
|
|
167
|
+
|
|
168
|
+
### Offset tokens (`X`/`XX`/`XXX`/`x`/`xx`/`xxx`)
|
|
108
169
|
|
|
109
|
-
|
|
170
|
+
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.
|
|
110
171
|
|
|
111
|
-
|
|
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` above — there'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.
|
|
172
|
+
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.
|
|
130
173
|
|
|
131
|
-
|
|
174
|
+
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`.
|
|
132
175
|
|
|
133
|
-
|
|
134
|
-
|
|
176
|
+
Range: `-12:00` to `+14:00`, the IANA-supported range. Out-of-range values throw a descriptive error naming the bound.
|
|
177
|
+
|
|
178
|
+
## Locales
|
|
179
|
+
|
|
180
|
+
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
181
|
|
|
136
182
|
```js
|
|
137
183
|
format(date, 'MMMM d, yyyy', { locale: 'fr-FR' }); // "août 4, 2026"
|
|
@@ -139,131 +185,188 @@ format(date, 'EEEE d MMMM', { locale: 'ar-EG' }); // Arabic weekday/month nam
|
|
|
139
185
|
format(dt, 'h:mm a', { locale: 'ja-JP' }); // "3:45 午後"
|
|
140
186
|
```
|
|
141
187
|
|
|
142
|
-
The named
|
|
143
|
-
`Intl.DateTimeFormat` under the hood, which means non-Gregorian calendars
|
|
144
|
-
work too, as long as the `Temporal` object is already carrying one:
|
|
188
|
+
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
189
|
|
|
146
190
|
```js
|
|
147
191
|
const hebrewDate = date.withCalendar('hebrew');
|
|
148
192
|
format(hebrewDate, 'MMMM d, yyyy'); // "Av 21, 5786"
|
|
149
193
|
```
|
|
150
194
|
|
|
151
|
-
|
|
195
|
+
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`.
|
|
196
|
+
|
|
197
|
+
The same locale handling applies to `parse()`:
|
|
152
198
|
|
|
153
199
|
```js
|
|
154
|
-
parse('MMMM d, yyyy','août 4, 2026', { locale: 'fr-FR' });
|
|
200
|
+
parse('MMMM d, yyyy', 'août 4, 2026', { locale: 'fr-FR' });
|
|
155
201
|
parse('h:mm a', '3:45 午後', { locale: 'ja-JP' });
|
|
156
|
-
//
|
|
157
|
-
parse('yyyy-MM-dd', '5786-11-21', { locale: 'en-u-ca-hebrew' });
|
|
202
|
+
parse('yyyy-MM-dd', '5786-11-21', { locale: 'en-u-ca-hebrew' }); // -u-ca- extension parses into that calendar
|
|
158
203
|
```
|
|
159
204
|
|
|
160
|
-
**Numeric fields (
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
205
|
+
**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.
|
|
206
|
+
|
|
207
|
+
### Registering custom vocabulary
|
|
208
|
+
|
|
209
|
+
`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.
|
|
210
|
+
|
|
211
|
+
**`registerLocaleVocab(locale, vocab)`** — base vocabulary: months, weekdays, day periods only.
|
|
212
|
+
|
|
213
|
+
```js
|
|
214
|
+
import { registerLocaleVocab } from 'temporal-fmt';
|
|
215
|
+
|
|
216
|
+
registerLocaleVocab('en-u-ca-hebrew-leap', {
|
|
217
|
+
monthLong: ['Nisan', 'Iyar', 'Sivan', 'Tammuz', 'Av', 'Elul', 'Tishrei', 'Marcheshvan', 'Kislev', 'Tevet', 'Shevat', 'Adar I', 'Adar II'],
|
|
218
|
+
monthShort: ['Nis', 'Iyy', 'Siv', 'Tam', 'Av', 'Elu', 'Tish', 'Chesh', 'Kis', 'Tev', 'Shv', 'Ad1', 'Ad2'],
|
|
219
|
+
weekdayLong: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'],
|
|
220
|
+
weekdayShort: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
|
|
221
|
+
dayPeriod: ['AM', 'PM'],
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const date = Temporal.PlainDate.from('2026-08-04').withCalendar('hebrew');
|
|
225
|
+
format(date, 'MMMM d, yyyy', { locale: 'en-u-ca-hebrew-leap' }); // "Av 4, 5786" (or similar)
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
**`registerLocale(locale, vocab)`** — extended vocabulary: everything `registerLocaleVocab` covers, plus quarters, eras, ordinals, duration units, and relative-time language:
|
|
229
|
+
|
|
230
|
+
```js
|
|
231
|
+
import { registerLocale } from 'temporal-fmt';
|
|
232
|
+
|
|
233
|
+
registerLocale('test-locale-1', {
|
|
234
|
+
// base vocab (required)
|
|
235
|
+
monthLong: [/* 12 entries */],
|
|
236
|
+
monthShort: [/* 12 entries */],
|
|
237
|
+
weekdayLong: [/* 7 entries */],
|
|
238
|
+
weekdayShort: [/* 7 entries */],
|
|
239
|
+
dayPeriod: ['AM', 'PM'],
|
|
240
|
+
// extended vocab (optional)
|
|
241
|
+
quartersLong: ['First', 'Second', 'Third', 'Fourth'],
|
|
242
|
+
quartersShort: ['Q1', 'Q2', 'Q3', 'Q4'],
|
|
243
|
+
erasLong: ['BCE', 'CE'],
|
|
244
|
+
erasShort: ['BC', 'AD'],
|
|
245
|
+
ordinals: ['st', 'nd', 'rd', 'th'],
|
|
246
|
+
durationUnits: { years: ['year', 'years'] /* , ... */ },
|
|
247
|
+
relativeTime: { past: 'ago', future: 'in', now: 'now' },
|
|
248
|
+
});
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
`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.
|
|
252
|
+
|
|
253
|
+
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
254
|
|
|
168
255
|
## Tokens
|
|
169
256
|
|
|
170
|
-
| Token | Meaning
|
|
171
|
-
|
|
172
|
-
| yyyy
|
|
173
|
-
| yy
|
|
174
|
-
| MMMM
|
|
175
|
-
| MMM
|
|
176
|
-
| MM
|
|
177
|
-
| M
|
|
178
|
-
| dd
|
|
179
|
-
| d
|
|
180
|
-
| do
|
|
181
|
-
| EEEE
|
|
182
|
-
| EEE
|
|
183
|
-
| HH
|
|
184
|
-
| H
|
|
185
|
-
| hh
|
|
186
|
-
| h
|
|
187
|
-
| mm
|
|
188
|
-
| m
|
|
189
|
-
| ss
|
|
190
|
-
| s
|
|
191
|
-
|
|
|
192
|
-
|
|
|
193
|
-
|
|
|
194
|
-
|
|
|
195
|
-
|
|
|
196
|
-
|
|
|
197
|
-
|
|
|
198
|
-
|
|
|
199
|
-
|
|
|
200
|
-
|
|
|
201
|
-
|
|
|
202
|
-
|
|
|
203
|
-
|
|
|
204
|
-
|
|
|
205
|
-
|
|
206
|
-
`
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
`
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
257
|
+
| Token | Meaning | Example |
|
|
258
|
+
|-------|---------|---------|
|
|
259
|
+
| `yyyy` | Four-digit year (preserves sign for BCE) | `2026` |
|
|
260
|
+
| `yy` | Two-digit year (`year % 100`; throws on negative years) | `26` |
|
|
261
|
+
| `MMMM` | Long month name, locale-aware | `August` |
|
|
262
|
+
| `MMM` | Short month name, locale-aware | `Aug` |
|
|
263
|
+
| `MM` | Two-digit month, zero-padded | `08` |
|
|
264
|
+
| `M` | One- or two-digit month | `8` |
|
|
265
|
+
| `dd` | Two-digit day-of-month, zero-padded | `04` |
|
|
266
|
+
| `d` | One- or two-digit day-of-month | `4` |
|
|
267
|
+
| `do` | Ordinal day-of-month, English suffix. Format-only | `4th` |
|
|
268
|
+
| `EEEE` | Long weekday name, locale-aware. Cross-checked against the date on parse | `Tuesday` |
|
|
269
|
+
| `EEE` | Short weekday name, locale-aware. Cross-checked on parse | `Tue` |
|
|
270
|
+
| `HH` | Two-digit hour, 24-hour, zero-padded | `15` |
|
|
271
|
+
| `H` | One- or two-digit hour, 24-hour | `15` |
|
|
272
|
+
| `hh` | Two-digit hour, 12-hour, zero-padded. Needs `a` on parse | `03` |
|
|
273
|
+
| `h` | One- or two-digit hour, 12-hour. Needs `a` on parse | `3` |
|
|
274
|
+
| `mm` | Two-digit minute, zero-padded | `45` |
|
|
275
|
+
| `m` | One- or two-digit minute | `45` |
|
|
276
|
+
| `ss` | Two-digit second, zero-padded | `30` |
|
|
277
|
+
| `s` | One- or two-digit second | `30` |
|
|
278
|
+
| `S` … `SSSSSSSSS` | Fractional second, 1–9 digits (tenths through nanoseconds) | `SSS` → `000` |
|
|
279
|
+
| `a` | Day period, locale-aware, case-insensitive on parse | `PM` |
|
|
280
|
+
| `zzz` | IANA time zone id, or fixed offset. Needs full date+time on parse | `America/New_York` |
|
|
281
|
+
| `zzzz` | Localized long time zone name. Format-only | `Eastern Standard Time` |
|
|
282
|
+
| `z` | Localized short time zone name. Format-only | `EST` |
|
|
283
|
+
| `X` | UTC offset, short, `Z` for UTC | `+05` / `+0530` / `Z` |
|
|
284
|
+
| `XX` | UTC offset, no colon, `Z` for UTC | `+0500` / `Z` |
|
|
285
|
+
| `XXX` | UTC offset, with colon, `Z` for UTC | `+05:00` / `Z` |
|
|
286
|
+
| `x` | Same as `X`, never `Z` | `+05` / `+0530` / `+00` |
|
|
287
|
+
| `xx` | Same as `XX`, never `Z` | `+0500` / `+0000` |
|
|
288
|
+
| `xxx` | Same as `XXX`, never `Z` | `+05:00` / `+00:00` |
|
|
289
|
+
| `Q` | Quarter, digit (1–4). Cross-checked against month on parse | `3` |
|
|
290
|
+
| `QQQ` | Quarter with "Q" prefix. Cross-checked on parse | `Q3` |
|
|
291
|
+
| `ww` | ISO 8601 week number (01–53). Format-only | `32` |
|
|
292
|
+
| `RRRR` | ISO 8601 week-numbering year. Format-only | `2026` |
|
|
293
|
+
| `D` | Day of year, unpadded. Format-only | `216` |
|
|
294
|
+
| `DD` | Day of year, 2-digit minimum. Format-only | `216` |
|
|
295
|
+
| `DDD` | Day of year, 3-digit zero-padded. Format-only | `216` |
|
|
296
|
+
| `LLLL` | Stand-alone long month name (nominative case). Identical to `MMMM` in most locales | `August` |
|
|
297
|
+
| `LLL` | Stand-alone short month name. Identical to `MMM` in most locales | `Aug` |
|
|
298
|
+
| `cccc` | Stand-alone long weekday name. Identical to `EEEE` in most locales | `Tuesday` |
|
|
299
|
+
| `ccc` | Stand-alone short weekday name. Identical to `EEE` in most locales | `Tue` |
|
|
300
|
+
| `GGGG` | Long era name, locale-aware. Format-only | `Anno Domini` |
|
|
301
|
+
| `G` | Short era name, locale-aware. Format-only | `AD` |
|
|
302
|
+
|
|
303
|
+
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).
|
|
304
|
+
|
|
305
|
+
**`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.
|
|
306
|
+
|
|
307
|
+
**`Q`/`QQQ`** both format and parse, cross-checking against any month/date tokens present in the same string — same contract `EEEE` uses for weekday.
|
|
308
|
+
|
|
309
|
+
**`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).
|
|
310
|
+
|
|
311
|
+
**`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
312
|
|
|
222
313
|
## Duration formatting
|
|
223
314
|
|
|
224
|
-
`formatDuration(duration, formatStr, options?)` formats a `Temporal.Duration` (or a plain field bag `{ years, months, weeks, days, hours, minutes, seconds, milliseconds }`) with
|
|
315
|
+
`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
316
|
|
|
226
|
-
|
|
317
|
+
Each unit has three forms, increasing in verbosity:
|
|
227
318
|
|
|
228
319
|
| Token | Form | Example |
|
|
229
320
|
|-------|------|---------|
|
|
230
|
-
| `y` / `yy` / `yyy` | numeric / short / long
|
|
231
|
-
| `o` / `oo` / `ooo` | numeric / short / long
|
|
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` |
|
|
321
|
+
| `y` / `yy` / `yyy` | numeric / short / long — years | `2` / `2yr` / `2 years` |
|
|
322
|
+
| `o` / `oo` / `ooo` | numeric / short / long — months | `2` / `2mo` / `2 months` |
|
|
323
|
+
| `w` / `ww` / `www` | numeric / short / long — weeks | `2` / `2wk` / `2 weeks` |
|
|
324
|
+
| `d` / `dd` / `ddd` | numeric / short / long — days | `2` / `2d` / `2 days` |
|
|
325
|
+
| `h` / `hh` / `hhh` | numeric / short / long — hours | `2` / `2h` / `2 hours` |
|
|
326
|
+
| `m` / `mm` / `mmm` | numeric / short / long — minutes | `2` / `2m` / `2 minutes` |
|
|
327
|
+
| `s` / `ss` / `sss` | numeric / short / long — seconds | `2` / `2s` / `2 seconds` |
|
|
328
|
+
| `S` / `SS` / `SSS` | numeric / short / long — milliseconds | `2` / `2ms` / `2 milliseconds` |
|
|
238
329
|
|
|
239
|
-
|
|
330
|
+
Short and long forms are plural-aware (singular at 1, plural otherwise).
|
|
240
331
|
|
|
241
332
|
```js
|
|
242
333
|
import { formatDuration } from 'temporal-fmt';
|
|
243
334
|
|
|
244
|
-
formatDuration({ years: 2, months: 3 }, 'yyy ooo')
|
|
335
|
+
formatDuration({ years: 2, months: 3 }, 'yyy ooo') // "2 years 3 months"
|
|
245
336
|
formatDuration({ hours: 2, minutes: 30 }, 'hhh mmm') // "2 hours 30 minutes"
|
|
246
337
|
formatDuration({ hours: 2, minutes: 30 }, 'h:mm') // "2:30"
|
|
247
338
|
```
|
|
248
339
|
|
|
249
|
-
**Zero-value handling**:
|
|
340
|
+
**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
341
|
|
|
251
|
-
**Locale-aware unit names**: pass
|
|
342
|
+
**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
343
|
|
|
253
344
|
```js
|
|
254
345
|
formatDuration({ hours: 2, minutes: 30 }, 'hhh mmm', { locale: 'fr-FR' }) // "2 heures 30 minutes"
|
|
255
346
|
formatDuration({ hours: 2, minutes: 30 }, 'hhh mmm', { locale: 'es-ES' }) // "2 horas 30 minutos"
|
|
256
347
|
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
348
|
```
|
|
259
349
|
|
|
260
|
-
Without a `locale`,
|
|
350
|
+
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.
|
|
261
351
|
|
|
262
|
-
|
|
352
|
+
### Other duration functions
|
|
263
353
|
|
|
264
|
-
|
|
354
|
+
- `formatDurationToParts(duration, formatStr, options?)` — `formatDuration`, but returns parts instead of a joined string.
|
|
355
|
+
- `parseDuration(input, formatStr, options?)` — the inverse of `formatDuration`: parses a formatted string back into duration fields.
|
|
356
|
+
- `parseISODuration(input)` / `formatISODuration(duration)` — parse/format the ISO 8601 duration grammar (`P1Y2M3DT4H5M6S`).
|
|
357
|
+
- `balanceDuration(duration)` — normalizes fields into their natural ranges (e.g. 90 minutes → 1 hour 30 minutes).
|
|
358
|
+
- `totalDuration(duration, unit)` — sums a duration's absolute fields into a single number in the target unit (`'days' | 'hours' | 'minutes' | 'seconds' | 'milliseconds' | 'microseconds' | 'nanoseconds'`).
|
|
359
|
+
- `compareDuration(a, b)` — `-1`/`0`/`1` by total absolute length.
|
|
360
|
+
- `addDuration(a, b)` / `subtractDuration(a, b)` — field-by-field sum/difference.
|
|
361
|
+
- `roundDuration(duration, options)` — round a duration to a unit; see [Date arithmetic, comparison, and rounding](#date-arithmetic-comparison-and-rounding).
|
|
265
362
|
|
|
266
|
-
|
|
363
|
+
## Relative time
|
|
364
|
+
|
|
365
|
+
Two different things live under this heading — pick based on what you want back.
|
|
366
|
+
|
|
367
|
+
### `formatDistance` — "3 days ago"
|
|
368
|
+
|
|
369
|
+
`formatDistance(date1, date2, options?)` returns a human-readable relative-time string, delegating unit names and pluralization to `Intl.RelativeTimeFormat`.
|
|
267
370
|
|
|
268
371
|
```js
|
|
269
372
|
import { formatDistance } from 'temporal-fmt';
|
|
@@ -271,220 +374,571 @@ import { formatDistance } from 'temporal-fmt';
|
|
|
271
374
|
const today = Temporal.PlainDate.from('2026-08-04');
|
|
272
375
|
const yesterday = Temporal.PlainDate.from('2026-08-03');
|
|
273
376
|
|
|
274
|
-
formatDistance(today, yesterday)
|
|
275
|
-
formatDistance(today, yesterday, { numeric: 'always' })
|
|
276
|
-
formatDistance(today, today)
|
|
377
|
+
formatDistance(today, yesterday) // "yesterday" (numeric: 'auto')
|
|
378
|
+
formatDistance(today, yesterday, { numeric: 'always' }) // "1 day ago"
|
|
379
|
+
formatDistance(today, today) // "now"
|
|
277
380
|
formatDistance(today, today.add({ days: 2 }), { locale: 'fr-FR' }) // "dans 2 jours"
|
|
278
381
|
```
|
|
279
382
|
|
|
280
|
-
**Direction
|
|
383
|
+
**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
384
|
|
|
282
|
-
**Unit-selection cutoffs** (defaults
|
|
385
|
+
**Unit-selection cutoffs** (defaults below; override any subset per call via `{ cutoffs }`):
|
|
283
386
|
|
|
284
387
|
| abs(diff) | Unit | Default cutoff |
|
|
285
|
-
|
|
388
|
+
|-----------|------|-----------------|
|
|
286
389
|
| < 60 seconds | seconds | `seconds: 60` |
|
|
287
390
|
| < 60 minutes | minutes | `minutes: 60` |
|
|
288
391
|
| < 24 hours | hours | `hours: 24` |
|
|
289
392
|
| < 30 days | days | `days: 30` |
|
|
290
|
-
| < 365 days | months | `months: 365` (in days — see
|
|
393
|
+
| < 365 days | months | `months: 365` (expressed in days — see below) |
|
|
291
394
|
| otherwise | years | — |
|
|
292
395
|
|
|
293
|
-
30 days
|
|
396
|
+
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.
|
|
294
397
|
|
|
295
|
-
|
|
398
|
+
```js
|
|
399
|
+
formatDistance(in5d, today) // "in 5 days" (default cutoffs)
|
|
400
|
+
formatDistance(in14d, today, { cutoffs: { days: 10 } }) // "this month" (14d > 10d)
|
|
401
|
+
formatDistance(in200d, today, { cutoffs: { months: 100 } }) // "this year" (200d > 100d)
|
|
402
|
+
formatDistance(in30d, today) // "next month" (right at the default 30d boundary)
|
|
403
|
+
```
|
|
296
404
|
|
|
297
|
-
|
|
405
|
+
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).
|
|
406
|
+
|
|
407
|
+
### `formatRelative` / `formatRelativeToNow` — "yesterday", "last week"
|
|
408
|
+
|
|
409
|
+
`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)`.
|
|
410
|
+
|
|
411
|
+
## Natural-language date parsing
|
|
412
|
+
|
|
413
|
+
`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.
|
|
414
|
+
|
|
415
|
+
Supported phrase classes:
|
|
416
|
+
|
|
417
|
+
- **weekday references**: "next Tuesday", "last Friday", "this Monday"
|
|
418
|
+
- **relative day offsets**: "today", "tomorrow", "yesterday"
|
|
419
|
+
- **relative unit offsets**: "in 3 days", "2 weeks ago", "in 1 month", "1 year ago"
|
|
420
|
+
- **month-day without year**: "March 5th", "Aug 4" (resolved to the next occurrence)
|
|
421
|
+
|
|
422
|
+
Each language grammar is its own module — phrase patterns and vocabulary aren't shared across languages, only the matching engine and resolution helpers are.
|
|
423
|
+
|
|
424
|
+
| Phrase class | en | es | fr | de |
|
|
425
|
+
|--------------|----|----|----|----|
|
|
426
|
+
| today | `today` | `hoy` | `aujourd'hui` | `heute` |
|
|
427
|
+
| tomorrow | `tomorrow` | `mañana` | `demain` | `morgen` |
|
|
428
|
+
| yesterday | `yesterday` | `ayer` | `hier` | `gestern` |
|
|
429
|
+
| next Tuesday | `next Tuesday` | `el próximo martes` / `martes próximo` | `mardi prochain` | `nächsten Dienstag` |
|
|
430
|
+
| last Tuesday | `last Tuesday` | `el martes pasado` | `mardi dernier` | `letzten Dienstag` |
|
|
431
|
+
| this Wednesday | `this Wednesday` | `este miércoles` | `ce mercredi` | `diesen Mittwoch` |
|
|
432
|
+
| in 3 days | `in 3 days` | `en 3 días` | `dans 3 jours` | `in 3 Tagen` |
|
|
433
|
+
| 2 weeks ago | `2 weeks ago` | `hace 2 semanas` | `il y a 2 semaines` | `vor 2 Wochen` |
|
|
434
|
+
| March 5 | `March 5th` | `5 de marzo` | `5 mars` | `5. März` |
|
|
435
|
+
|
|
436
|
+
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"`.
|
|
298
437
|
|
|
299
438
|
```js
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
439
|
+
import { parseRelative } from 'temporal-fmt';
|
|
440
|
+
|
|
441
|
+
const today = Temporal.PlainDate.from('2026-08-04'); // Tuesday
|
|
442
|
+
parseRelative('today', today).toString() // '2026-08-04'
|
|
443
|
+
parseRelative('tomorrow', today).toString() // '2026-08-05'
|
|
444
|
+
parseRelative('next Tuesday', today).toString() // '2026-08-11' (7 days out, not today)
|
|
445
|
+
parseRelative('last Friday', today).toString() // '2026-07-31'
|
|
446
|
+
parseRelative('in 3 days', today).toString() // '2026-08-07'
|
|
447
|
+
parseRelative('2 weeks ago', today).toString() // '2026-07-21'
|
|
448
|
+
parseRelative('March 5th', today).toString() // '2027-03-05' (next occurrence)
|
|
449
|
+
|
|
450
|
+
parseRelative('mañana', today, { locale: 'es-ES' }).toString() // '2026-08-05'
|
|
451
|
+
parseRelative('el próximo martes', today, { locale: 'es-ES' }).toString() // '2026-08-11'
|
|
452
|
+
parseRelative('demain', today, { locale: 'fr-FR' }).toString() // '2026-08-05'
|
|
453
|
+
parseRelative('mardi prochain', today, { locale: 'fr-FR' }).toString() // '2026-08-11'
|
|
454
|
+
parseRelative('morgen', today, { locale: 'de-DE' }).toString() // '2026-08-05'
|
|
455
|
+
parseRelative('nächsten Dienstag', today, { locale: 'de-DE' }).toString() // '2026-08-11'
|
|
304
456
|
```
|
|
305
457
|
|
|
306
|
-
|
|
458
|
+
**Ambiguous-case choices** (documented, not inferred):
|
|
459
|
+
|
|
460
|
+
- **"next Tuesday" said on a Tuesday** = 7 days out, not today. "this Tuesday" handles the same-week case, so the two phrases stay distinct.
|
|
461
|
+
- **"last Tuesday" said on a Tuesday** = 7 days ago (symmetric with "next").
|
|
462
|
+
- **"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.
|
|
463
|
+
- **"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.
|
|
464
|
+
|
|
465
|
+
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.
|
|
307
466
|
|
|
308
|
-
|
|
467
|
+
`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`.
|
|
309
468
|
|
|
310
|
-
|
|
469
|
+
**Adding a language**: `registerRelativeGrammar(grammar)` registers a new language's phrase patterns without touching the built-in four. `listRegisteredGrammars()` lists what's currently registered.
|
|
311
470
|
|
|
312
|
-
|
|
471
|
+
## Date arithmetic, comparison, and rounding
|
|
472
|
+
|
|
473
|
+
### Arithmetic
|
|
313
474
|
|
|
314
475
|
```js
|
|
315
|
-
|
|
316
|
-
|
|
476
|
+
import { add, subtract, difference, addDays, differenceInHours } from 'temporal-fmt';
|
|
477
|
+
|
|
478
|
+
add(date, 3, 'days');
|
|
479
|
+
subtract(date, 1, 'months');
|
|
480
|
+
difference(a, b, 'hours');
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
- `add(value, amount, unit)` / `subtract(value, amount, unit)` — `unit` is one of `'years' | 'months' | 'weeks' | 'days' | 'hours' | 'minutes' | 'seconds' | 'milliseconds'`.
|
|
484
|
+
- Per-unit wrappers for both directions: `addYears`, `addMonths`, `addWeeks`, `addDays`, `addHours`, `addMinutes`, `addSeconds`, `addMilliseconds`, and the matching `subtract*` set.
|
|
485
|
+
- `difference(a, b, unit)` — integer count of unit boundaries crossed between two values.
|
|
486
|
+
- Per-unit wrappers: `differenceInYears`, `differenceInMonths`, `differenceInWeeks`, `differenceInDays`, `differenceInHours`, `differenceInMinutes`, `differenceInSeconds`, `differenceInMilliseconds`.
|
|
487
|
+
|
|
488
|
+
### Comparison
|
|
489
|
+
|
|
490
|
+
```js
|
|
491
|
+
import { compare, isBefore, isSameDay, isWeekend } from 'temporal-fmt';
|
|
492
|
+
|
|
493
|
+
compare(a, b); // -1 / 0 / 1
|
|
494
|
+
isBefore(a, b);
|
|
495
|
+
isSameDay(a, b);
|
|
496
|
+
isWeekend(date);
|
|
317
497
|
```
|
|
318
498
|
|
|
319
|
-
|
|
499
|
+
- `compare(a, b)` → `-1`/`0`/`1`.
|
|
500
|
+
- `isEqual`, `isBefore`, `isAfter`.
|
|
501
|
+
- `min(values)`, `max(values)`, `clamp(value, lo, hi)`, `isBetween(value, lo, hi)`.
|
|
502
|
+
- Semantic helpers: `isToday`, `isTomorrow`, `isYesterday`, `isSameDay`, `isSameWeek`, `isSameMonth`, `isSameQuarter`, `isSameYear`, `isWeekend`, `isWeekday`.
|
|
503
|
+
|
|
504
|
+
### Rounding
|
|
320
505
|
|
|
321
|
-
|
|
506
|
+
- `round(value, options)` — round to a unit with a rounding mode.
|
|
507
|
+
- `floor(value, unit, increment?)`, `ceil(value, unit, increment?)`, `truncate(value, unit, increment?)`.
|
|
508
|
+
- `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.
|
|
322
509
|
|
|
323
|
-
|
|
510
|
+
### Calendar utilities
|
|
324
511
|
|
|
325
|
-
|
|
512
|
+
```js
|
|
513
|
+
import { daysInMonth, startOf, getQuarter } from 'temporal-fmt';
|
|
514
|
+
|
|
515
|
+
daysInMonth(date);
|
|
516
|
+
startOf(date, 'month');
|
|
517
|
+
getQuarter(date);
|
|
518
|
+
```
|
|
326
519
|
|
|
327
|
-
`
|
|
520
|
+
- `daysInMonth(value)`, `daysInYear(value)`, `monthsInYear(value)`.
|
|
521
|
+
- `isLeapYear(value)`, `isLeapMonth(value)` (Gregorian: `isLeapMonth` always returns `false`).
|
|
522
|
+
- `dayOfYear(value)`, `weekOfYear(value)`, `weekYear(value)`.
|
|
523
|
+
- `getQuarter(value)`, `getMonth(value)`, `getWeekday(value)`.
|
|
524
|
+
- `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`).
|
|
525
|
+
|
|
526
|
+
**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:
|
|
328
527
|
|
|
329
528
|
```js
|
|
330
|
-
|
|
529
|
+
const pd = Temporal.PlainDate.from('5784-05-10[u-ca=hebrew]');
|
|
530
|
+
pd.daysInMonth; // 30 (Sivan)
|
|
531
|
+
pd.monthsInYear; // 13 (leap year)
|
|
532
|
+
```
|
|
331
533
|
|
|
332
|
-
|
|
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
|
-
});
|
|
534
|
+
Locale-aware *formatting* tokens (`MMMM`, `MMM`, `EEEE`, `EEE`, `a`) don't have this limitation — they go through `Intl.DateTimeFormat`, which does respect `calendarId`:
|
|
339
535
|
|
|
340
|
-
|
|
341
|
-
format(
|
|
536
|
+
```js
|
|
537
|
+
format(Temporal.PlainDate.from('5784-05-10[u-ca=hebrew]'), 'MMMM d, yyyy'); // "Sivan 10, 5784"
|
|
342
538
|
```
|
|
343
539
|
|
|
344
|
-
|
|
540
|
+
## Intervals
|
|
345
541
|
|
|
346
|
-
|
|
542
|
+
```js
|
|
543
|
+
import { interval, contains, overlaps, formatRange } from 'temporal-fmt';
|
|
347
544
|
|
|
348
|
-
|
|
545
|
+
const iv = interval(start, end, 'closed');
|
|
546
|
+
contains(iv, someDate);
|
|
547
|
+
overlaps(ivA, ivB);
|
|
548
|
+
formatRange(iv, 'MMM d');
|
|
549
|
+
```
|
|
349
550
|
|
|
350
|
-
`
|
|
551
|
+
- `interval(start, end, bounds?)` — `bounds` is one of `'closed'` (default) | `'open'` | `'half-open-start'` | `'half-open-end'`.
|
|
552
|
+
- `contains(iv, value)`, `overlaps(a, b)`, `intersects(a, b)`.
|
|
553
|
+
- `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).
|
|
554
|
+
- `intersection(a, b)`, `union(a, b)` — return `null` when the intervals don't overlap enough to combine.
|
|
555
|
+
- `difference(a, b)` / `subtract(a, b)` — imported as `intervalDifference`/`intervalSubtract` from the main entry point, same collision-avoidance reasoning.
|
|
556
|
+
- `mergeIntervals(intervals)` — combines a list of overlapping intervals into their union set.
|
|
557
|
+
- `splitInterval(iv, n)` — splits one interval into `n` equal sub-intervals.
|
|
558
|
+
- `formatRange(iv, formatStr, options?)` / `formatRangeToParts(iv, formatStr, options?)` — format an interval as a range string, using `Intl.DateTimeFormat.formatRange` where available.
|
|
351
559
|
|
|
352
|
-
|
|
560
|
+
## Recurrence
|
|
353
561
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
562
|
+
```js
|
|
563
|
+
import { recurrence, take, between } from 'temporal-fmt';
|
|
564
|
+
|
|
565
|
+
const rule = { freq: 'weekly', interval: 1, count: 10 };
|
|
566
|
+
const iter = recurrence(startDate, rule);
|
|
567
|
+
take(iter, 5); // first 5 occurrences
|
|
568
|
+
between(startDate, rule, rangeStart, rangeEnd); // occurrences within a window
|
|
569
|
+
```
|
|
358
570
|
|
|
359
|
-
|
|
571
|
+
- `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.
|
|
572
|
+
- `take(iter, n)` — collects the next `n` occurrences.
|
|
573
|
+
- `skip(iter, n)` — advances past `n` occurrences.
|
|
574
|
+
- `between(start, rule, rangeStart, rangeEnd)` — all occurrences within a range, without manually iterating.
|
|
575
|
+
- `parseRRule(input)` / `formatRRule(rule)` — parse/format the RFC 5545 RRULE text format, for interop with calendar systems that speak it.
|
|
360
576
|
|
|
361
|
-
|
|
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` |
|
|
577
|
+
## Business calendars and holidays
|
|
372
578
|
|
|
373
|
-
|
|
579
|
+
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.
|
|
374
580
|
|
|
375
581
|
```js
|
|
376
|
-
import {
|
|
582
|
+
import { createBusinessCalendar, isBusinessDay, addBusinessDays } from 'temporal-fmt';
|
|
377
583
|
|
|
378
|
-
const
|
|
379
|
-
|
|
380
|
-
|
|
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)
|
|
584
|
+
const cal = createBusinessCalendar({ /* weekend days, holidays, working hours, half days */ });
|
|
585
|
+
isBusinessDay(cal, someDate);
|
|
586
|
+
addBusinessDays(cal, someDate, 5);
|
|
386
587
|
```
|
|
387
588
|
|
|
388
|
-
|
|
589
|
+
- `createBusinessCalendar(options?)` — configure weekend days, holidays, working hours, and half days.
|
|
590
|
+
- `isBusinessDay(cal, value)`.
|
|
591
|
+
- `addBusinessDays(cal, value, n)` / `subtractBusinessDays(cal, value, n)`.
|
|
592
|
+
- `differenceInBusinessDays(cal, a, b)`.
|
|
593
|
+
- `nextBusinessDay(cal, value)` / `previousBusinessDay(cal, value)`.
|
|
389
594
|
|
|
390
595
|
```js
|
|
391
|
-
|
|
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'
|
|
596
|
+
import { createHolidayCalendar, nextHoliday, holidaysBetween } from 'temporal-fmt';
|
|
394
597
|
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
598
|
+
const holidays = createHolidayCalendar([
|
|
599
|
+
{ month: 1, day: 1, name: "New Year's Day" },
|
|
600
|
+
{ compute: (year) => ({ month: 5, day: lastMondayOf(year, 5) }), name: 'Memorial Day' },
|
|
601
|
+
]);
|
|
398
602
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
parseRelative('5. März', today, { locale: 'de-DE' }).toString() // '2027-03-05'
|
|
603
|
+
holidays.isHoliday(someDate);
|
|
604
|
+
nextHoliday(holidays, someDate);
|
|
402
605
|
```
|
|
403
606
|
|
|
404
|
-
|
|
607
|
+
- `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).
|
|
608
|
+
- The returned calendar has `.isHoliday(value)` and `.holidaysBetween(start, end)` as methods on the object itself — they aren't standalone exports.
|
|
609
|
+
- `nextHoliday(cal, value)` / `previousHoliday(cal, value)` — standalone helpers that call `.isHoliday()` under the hood, capped at a 5-year lookahead/lookbehind.
|
|
610
|
+
- `holidaysBetween(cal, start, end)` — standalone wrapper delegating to `cal.holidaysBetween(start, end)`, for callers who'd rather not reach into the calendar object directly.
|
|
611
|
+
|
|
612
|
+
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.
|
|
613
|
+
|
|
614
|
+
## Time zones
|
|
615
|
+
|
|
616
|
+
```js
|
|
617
|
+
import { resolveZoned, isDST, getTransitions } from 'temporal-fmt';
|
|
618
|
+
|
|
619
|
+
resolveZoned({ year: 2026, month: 3, day: 8, hour: 2, minute: 30 }, 'America/New_York', { disambiguation: 'compatible' });
|
|
620
|
+
isDST(zonedDateTime);
|
|
621
|
+
getTransitions(rangeStart, rangeEnd);
|
|
622
|
+
```
|
|
623
|
+
|
|
624
|
+
- `resolveZoned(fields, timeZone, options?)` — constructs a `ZonedDateTime` with an explicit disambiguation mode for gaps/overlaps: `'compatible' | 'earlier' | 'later' | 'reject'`.
|
|
625
|
+
- `getTimeZone(value)`, `getOffset(value)`, `getOffsetNanoseconds(value)`.
|
|
626
|
+
- `isDST(value)` — a heuristic that compares the value's current offset against its January offset. Correct for the common (Northern Hemisphere) case; Southern Hemisphere zones have DST in January, so this can read backwards there — there's no reliable hemisphere lookup built in.
|
|
627
|
+
- `getNextTransition(value)` / `getPreviousTransition(value)` — the nearest DST (or other offset) transition in either direction.
|
|
628
|
+
- `getTransitions(start, end)` — every transition within a range.
|
|
629
|
+
- `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.
|
|
630
|
+
|
|
631
|
+
## Serialization
|
|
632
|
+
|
|
633
|
+
```js
|
|
634
|
+
import { parseISO, formatISO, parseRFC3339, fromUnixMilliseconds } from 'temporal-fmt';
|
|
635
|
+
|
|
636
|
+
parseISO('2026-08-04T15:45:30Z');
|
|
637
|
+
formatISO(value);
|
|
638
|
+
fromUnixMilliseconds(1754321130000);
|
|
639
|
+
```
|
|
640
|
+
|
|
641
|
+
- `parseISO(input)` / `formatISO(value)` — ISO 8601.
|
|
642
|
+
- `parseRFC3339(input)` / `formatRFC3339(value)` — RFC 3339 (ISO 8601's stricter internet-facing cousin).
|
|
643
|
+
- `parseRFC2822(input)` / `formatRFC2822(value)` — RFC 2822 (email/HTTP-header-style dates).
|
|
644
|
+
- `parseHTTPDate(input)` / `formatHTTPDate(value)` — the HTTP-date format used in `Date`/`Last-Modified` headers.
|
|
645
|
+
- `parseSQL(input)` / `formatSQL(value)` — SQL `DATETIME`/`TIMESTAMP` style.
|
|
646
|
+
- Epoch conversions, both directions, at second/millisecond/microsecond/nanosecond resolution: `fromUnixSeconds`, `fromUnixMilliseconds`, `fromUnixMicroseconds`, `fromUnixNanoseconds`, `toUnixSeconds`, `toUnixMilliseconds`, `toUnixMicroseconds`, `toUnixNanoseconds`.
|
|
647
|
+
|
|
648
|
+
## Introspection and the analyzer
|
|
649
|
+
|
|
650
|
+
Every token carries structured metadata, and there's a small analysis layer over format strings themselves.
|
|
651
|
+
|
|
652
|
+
```js
|
|
653
|
+
import { tokenInfo, listTokens, analyzeFormat, explainFormat, isValidFormat } from 'temporal-fmt';
|
|
654
|
+
|
|
655
|
+
tokenInfo('yyyy');
|
|
656
|
+
// {
|
|
657
|
+
// meaning: 'Four-digit year (preserves sign for BCE; no truncation).',
|
|
658
|
+
// formatCapable: true,
|
|
659
|
+
// parseCapable: true,
|
|
660
|
+
// localeSensitive: false,
|
|
661
|
+
// calendarSensitive: true,
|
|
662
|
+
// timezoneSensitive: false,
|
|
663
|
+
// supportedTypes: ['PlainDate', 'PlainDateTime', 'ZonedDateTime', 'PlainYearMonth'],
|
|
664
|
+
// roundTripSafe: true,
|
|
665
|
+
// }
|
|
666
|
+
|
|
667
|
+
analyzeFormat("MMMM d, yyyy 'at' h:mm a");
|
|
668
|
+
// { tokens, requiredFields, compatibleTypes, parseable, localeSensitive,
|
|
669
|
+
// calendarSensitive, timezoneSensitive, ambiguous, roundTripSafe, warnings }
|
|
670
|
+
```
|
|
671
|
+
|
|
672
|
+
- `analyzeFormat(formatStr)` — the full structured report shown above.
|
|
673
|
+
- `explainFormat(formatStr)` — a human-readable rendering of the same analysis.
|
|
674
|
+
- `tokenInfo(name)` — metadata for one token, or `undefined` if it's not recognized.
|
|
675
|
+
- `listTokens()` — every recognized token, paired with its metadata.
|
|
676
|
+
- `isValidFormat(formatStr)` — `true` iff the tokenizer accepts the string.
|
|
677
|
+
- `validateFormat(formatStr)` — throws on an invalid format string; otherwise returns the same analysis `analyzeFormat` does.
|
|
678
|
+
- `tokenizeFormat(formatStr)` — the raw literal/token piece list, if you want to walk it yourself.
|
|
679
|
+
- `fieldForToken(token)` — which `TemporalLike` field a given token reads.
|
|
680
|
+
|
|
681
|
+
`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`).
|
|
682
|
+
|
|
683
|
+
**Round-trip safety, by family:**
|
|
405
684
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
685
|
+
| Family | Tokens | Round-trip safe | Why |
|
|
686
|
+
|--------|--------|------------------|-----|
|
|
687
|
+
| Year | `yyyy` | yes | Preserves sign for BCE. |
|
|
688
|
+
| 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. |
|
|
689
|
+
| Month | `MMMM`, `MMM`, `MM`, `M` | yes | Numeric and name forms both round-trip; only the name forms are locale-sensitive. |
|
|
690
|
+
| Day | `dd`, `d` | yes | |
|
|
691
|
+
| Day | `do` | no | Format-only — the ordinal suffix isn't parseable back out. |
|
|
692
|
+
| Weekday | `EEEE`, `EEE` | yes | Cross-checked against the parsed date, so a mismatch throws rather than round-tripping silently wrong. |
|
|
693
|
+
| ISO week | `ww`, `RRRR` | n/a | Format-only — a week number alone can't reconstruct a specific date. |
|
|
410
694
|
|
|
411
|
-
|
|
695
|
+
## Typed errors
|
|
412
696
|
|
|
413
|
-
|
|
697
|
+
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).
|
|
698
|
+
|
|
699
|
+
All subclasses inherit structured fields from `TemporalFmtError`: `code`, `input`, `format`, `token`, `position`, `expected`, `actual`, `reason`.
|
|
700
|
+
|
|
701
|
+
| Class | Code | Fires when |
|
|
702
|
+
|-------|------|------------|
|
|
703
|
+
| `FormatSyntaxError` | `FORMAT_SYNTAX_ERROR` | Unterminated quote, format string over the length cap, other syntax issues. |
|
|
704
|
+
| `UnknownTokenError` | `UNKNOWN_TOKEN` | An unrecognized letter run was encountered. |
|
|
705
|
+
| `ParseMismatchError` | `PARSE_MISMATCH` | Input doesn't match the format's shape — generic catch-all. |
|
|
706
|
+
| `InvalidDateError` | `INVALID_DATE` | Structurally valid but nonexistent date (Feb 30), or a weekday/quarter that contradicts the date. |
|
|
707
|
+
| `InvalidTimeError` | `INVALID_TIME` | Time out of range (hour 25, etc). |
|
|
708
|
+
| `InvalidOffsetError` | `INVALID_OFFSET` | Offset malformed or outside the IANA range (`-12:00` to `+14:00`). |
|
|
709
|
+
| `InvalidTimeZoneError` | `INVALID_TIME_ZONE` | Not a recognized IANA name or fixed offset. |
|
|
710
|
+
| `InvalidCalendarError` | `INVALID_CALENDAR` | Unsupported calendar. |
|
|
711
|
+
| `AmbiguousInputError` | `AMBIGUOUS_INPUT` | Input has more than one valid reading (e.g. `Md` against `"121"`). |
|
|
712
|
+
| `InvalidLocaleError` | `INVALID_LOCALE` | Not a valid BCP-47 tag, or an unsupported numbering system. |
|
|
713
|
+
| `InvalidDurationError` | `INVALID_DURATION` | Duration string doesn't match the ISO 8601 grammar, or a field is non-finite. |
|
|
714
|
+
|
|
715
|
+
### Reading the common ones
|
|
716
|
+
|
|
717
|
+
**"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.
|
|
718
|
+
|
|
719
|
+
**"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.
|
|
720
|
+
|
|
721
|
+
**"format string mixes 'yyyy' and 'yy' year representations"** — don't combine the two year tokens in one format string.
|
|
722
|
+
|
|
723
|
+
**"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.
|
|
724
|
+
|
|
725
|
+
**"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 }`.
|
|
726
|
+
|
|
727
|
+
**"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.
|
|
728
|
+
|
|
729
|
+
**"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.
|
|
730
|
+
|
|
731
|
+
**"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.
|
|
732
|
+
|
|
733
|
+
## Type guards
|
|
734
|
+
|
|
735
|
+
```js
|
|
736
|
+
import { isPlainDate, assertZonedDateTime } from 'temporal-fmt';
|
|
737
|
+
|
|
738
|
+
if (isPlainDate(value)) { /* narrowed */ }
|
|
739
|
+
assertZonedDateTime(value); // throws descriptively if it isn't one
|
|
740
|
+
```
|
|
741
|
+
|
|
742
|
+
- `isTemporal(value)`, `isInstant(value)`, `isPlainDate(value)`, `isPlainTime(value)`, `isPlainDateTime(value)`, `isZonedDateTime(value)`, `isPlainYearMonth(value)`, `isPlainMonthDay(value)`, `isDuration(value)`.
|
|
743
|
+
- `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.
|
|
744
|
+
|
|
745
|
+
## Config
|
|
746
|
+
|
|
747
|
+
`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.
|
|
748
|
+
|
|
749
|
+
## Numbering systems
|
|
750
|
+
|
|
751
|
+
```js
|
|
752
|
+
import { convertDigits, convertDigitsToAscii } from 'temporal-fmt';
|
|
753
|
+
|
|
754
|
+
convertDigits('2026', 'arab'); // "٢٠٢٦"
|
|
755
|
+
convertDigitsToAscii('٢٠٢٦', 'arab'); // "2026"
|
|
756
|
+
```
|
|
757
|
+
|
|
758
|
+
- `convertDigits(s, system)` — ASCII digits to a locale's native digits.
|
|
759
|
+
- `convertDigitsToAscii(s, system)` — the inverse.
|
|
760
|
+
- `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.
|
|
761
|
+
- `SUPPORTED_NUMBERING_SYSTEMS` — the set of supported system names (`'latn' | 'arab' | 'deva' | 'beng' | 'guru' | 'gujr' | 'orya' | 'tamldec' | 'telu' | 'knda' | 'mlym' | 'fullwide' | 'hanidec'`).
|
|
762
|
+
|
|
763
|
+
## Extending with custom tokens
|
|
764
|
+
|
|
765
|
+
`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.
|
|
766
|
+
|
|
767
|
+
## IDE tooling data
|
|
768
|
+
|
|
769
|
+
A handful of functions exist specifically to feed editor tooling — autocomplete, hover docs, inline diagnostics — rather than for use in application code directly:
|
|
770
|
+
|
|
771
|
+
- `getAutocompleteData()` — token autocomplete entries, grouped by family.
|
|
772
|
+
- `getHoverDocs()` — per-token hover documentation.
|
|
773
|
+
- `getInlineDiagnostics(formatStr)` — diagnostics with position and suggested fixes for a given format string.
|
|
774
|
+
- `previewFormat(formatStr, sample?)` — a live preview string for a format, given an optional sample value.
|
|
775
|
+
- `getDocUrl(tokenName)` — a documentation URL for a token, currently pointing at this README's [Tokens](#tokens) section.
|
|
776
|
+
- `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.
|
|
777
|
+
|
|
778
|
+
## CLI
|
|
779
|
+
|
|
780
|
+
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:
|
|
781
|
+
|
|
782
|
+
```sh
|
|
783
|
+
temporal-fmt format "2026-08-04T15:45:30" "yyyy-MM-dd HH:mm:ss"
|
|
784
|
+
temporal-fmt parse "yyyy-MM-dd" "2026-08-04"
|
|
785
|
+
temporal-fmt inspect "MMMM d, yyyy 'at' h:mm a"
|
|
786
|
+
temporal-fmt validate "yyyy-MM-dd HH:mm:ss"
|
|
787
|
+
temporal-fmt translate dayjs "YYYY-MM-DD HH:mm:ss"
|
|
788
|
+
```
|
|
789
|
+
|
|
790
|
+
| Subcommand | What it does |
|
|
791
|
+
|------------|---------------|
|
|
792
|
+
| `format <iso-input> <format-string> [--locale=LOCALE]` | Formats an ISO date/time input against the given format string. |
|
|
793
|
+
| `parse <format-string> <input> [--locale=LOCALE] [--lenient]` | Parses input against a format string, prints the resulting ISO value. |
|
|
794
|
+
| `inspect <format-string>` | Prints `explainFormat`'s report on a format string. |
|
|
795
|
+
| `validate <format-string>` | Prints `valid` or `invalid`. |
|
|
796
|
+
| `translate <source-lib> <format-string>` | Translates a Day.js or date-fns format string to `temporal-fmt` tokens. |
|
|
797
|
+
|
|
798
|
+
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.
|
|
799
|
+
|
|
800
|
+
## Subpath imports
|
|
801
|
+
|
|
802
|
+
Each capability area is also available as a subpath import, for anyone who wants a slice of the package rather than the whole thing:
|
|
803
|
+
|
|
804
|
+
```js
|
|
805
|
+
import { format } from 'temporal-fmt/format';
|
|
806
|
+
import { parse } from 'temporal-fmt/parse';
|
|
807
|
+
import { formatDuration } from 'temporal-fmt/duration';
|
|
808
|
+
import { formatRelative } from 'temporal-fmt/relative';
|
|
809
|
+
import { interval, formatRange } from 'temporal-fmt/interval';
|
|
810
|
+
import { daysInMonth, startOf } from 'temporal-fmt/calendar';
|
|
811
|
+
import { resolveZoned, isDST } from 'temporal-fmt/timezone';
|
|
812
|
+
import { recurrence } from 'temporal-fmt/recurrence';
|
|
813
|
+
import { registerLocale } from 'temporal-fmt/locale';
|
|
814
|
+
```
|
|
815
|
+
|
|
816
|
+
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.
|
|
817
|
+
|
|
818
|
+
## Migrating from Day.js or date-fns
|
|
819
|
+
|
|
820
|
+
### Token mapping
|
|
821
|
+
|
|
822
|
+
Most date-fns/Day.js tokens map directly. The differences:
|
|
823
|
+
|
|
824
|
+
| Source (Day.js / date-fns) | temporal-fmt | Notes |
|
|
825
|
+
|---|---|---|
|
|
826
|
+
| `YYYY` | `yyyy` | Lowercase here |
|
|
827
|
+
| `YY` | `yy` | Same |
|
|
828
|
+
| `MMMM`, `MMM`, `MM`, `M` | same | Identical |
|
|
829
|
+
| `DD`, `D` | `dd`, `d` | Lowercase here |
|
|
830
|
+
| `dddd` | `EEEE` | Long weekday |
|
|
831
|
+
| `ddd` | `EEE` | Short weekday |
|
|
832
|
+
| `HH`, `H`, `mm`, `m`, `ss`, `s` | same | Identical |
|
|
833
|
+
| `A`, `a` | `a` | Always lowercase here |
|
|
834
|
+
| `Z` | `XXX` | Numeric UTC offset with colon, `Z` for UTC |
|
|
835
|
+
| `ZZ` | `XX` | Numeric UTC offset, no colon |
|
|
836
|
+
| `X` | not supported | Unix timestamp — use `fromUnixSeconds` instead |
|
|
837
|
+
| `x` | not supported | Unix ms timestamp — use `fromUnixMilliseconds` instead |
|
|
838
|
+
| `P` | not supported | Localized long format — write the format string out explicitly |
|
|
839
|
+
|
|
840
|
+
### API mapping
|
|
841
|
+
|
|
842
|
+
| Day.js | date-fns | temporal-fmt |
|
|
843
|
+
|---|---|---|
|
|
844
|
+
| `dayjs(str).format(fmt)` | `format(date, fmt)` | `format(temporal, fmt)` |
|
|
845
|
+
| `dayjs(str)` (parse) | `parseISO(str)` | `parseISO(str)` |
|
|
846
|
+
| `dayjs().add(n, 'day')` | `addDays(date, n)` | `add(date, n, 'days')` or `addDays(date, n)` |
|
|
847
|
+
| `dayjs().diff(other, 'day')` | `differenceInDays(a, b)` | `differenceInDays(a, b)` |
|
|
848
|
+
| `dayjs().isBefore(other)` | `isBefore(date, other)` | `isBefore(date, other)` |
|
|
849
|
+
| `dayjs().isAfter(other)` | `isAfter(date, other)` | `isAfter(date, other)` |
|
|
850
|
+
| `dayjs().isToday()` | `isToday(date)` | `isToday(date)` |
|
|
851
|
+
| `dayjs().isYesterday()` | `isYesterday(date)` | `isYesterday(date)` |
|
|
852
|
+
| `dayjs().isTomorrow()` | `isTomorrow(date)` | `isTomorrow(date)` |
|
|
853
|
+
|
|
854
|
+
### Behavioral differences that will bite you if you skip this
|
|
855
|
+
|
|
856
|
+
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 }`.
|
|
857
|
+
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.
|
|
858
|
+
3. **No silent "now" fallback.** Missing input doesn't fall back to the current time; pass an explicit value.
|
|
859
|
+
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.
|
|
860
|
+
5. **Temporal-native, not `Date`-native.** Operates on `PlainDate`/`PlainDateTime`/`ZonedDateTime`/etc, not the legacy `Date` object. Convert at the boundary:
|
|
861
|
+
|
|
862
|
+
```js
|
|
863
|
+
import { Temporal } from 'temporal-polyfill';
|
|
864
|
+
const pd = Temporal.PlainDate.from(jsDate.toISOString().slice(0, 10));
|
|
865
|
+
format(pd, 'yyyy-MM-dd');
|
|
866
|
+
```
|
|
867
|
+
|
|
868
|
+
### Common patterns, before and after
|
|
869
|
+
|
|
870
|
+
```js
|
|
871
|
+
// CSV/log timestamp parsing
|
|
872
|
+
// Before: const d = dayjs(line, 'YYYY-MM-DD HH:mm:ss');
|
|
873
|
+
const d = parse('yyyy-MM-dd HH:mm:ss', line);
|
|
874
|
+
|
|
875
|
+
// Locale-aware formatting
|
|
876
|
+
// Before: dayjs(date).locale('fr').format('MMMM D, YYYY');
|
|
877
|
+
format(date, 'MMMM d, yyyy', { locale: 'fr-FR' }); // "août 4, 2026"
|
|
878
|
+
|
|
879
|
+
// Relative time
|
|
880
|
+
// Before: dayjs(date).fromNow(); // "3 days ago"
|
|
881
|
+
formatRelativeToNow(date); // "3 days ago"
|
|
882
|
+
|
|
883
|
+
// Date arithmetic
|
|
884
|
+
// Before: dayjs(date).add(7, 'day');
|
|
885
|
+
add(date, 7, 'days'); // or addDays(date, 7)
|
|
886
|
+
```
|
|
887
|
+
|
|
888
|
+
### Things that don't migrate cleanly
|
|
889
|
+
|
|
890
|
+
- 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.
|
|
891
|
+
- Day.js's mutable global locale (`dayjs.locale('fr')`) — this library uses per-call `locale` options, no global mutation.
|
|
892
|
+
- date-fns's `format` with a locale *object* parameter — this library takes BCP-47 strings.
|
|
893
|
+
- `dayjs-timezone` — use `Temporal.ZonedDateTime` and the `zzz`/offset tokens instead.
|
|
894
|
+
|
|
895
|
+
### Running both during migration
|
|
896
|
+
|
|
897
|
+
```js
|
|
898
|
+
import dayjs from 'dayjs';
|
|
899
|
+
import { format as fmtTemporal } from 'temporal-fmt';
|
|
900
|
+
|
|
901
|
+
function formatDate(date, formatStr, opts) {
|
|
902
|
+
if (opts?.useTemporal) return fmtTemporal(date, formatStr, opts);
|
|
903
|
+
return dayjs(date).format(formatStr);
|
|
904
|
+
}
|
|
905
|
+
```
|
|
906
|
+
|
|
907
|
+
Migrate file by file, dropping the wrapper once nothing calls the old path anymore.
|
|
414
908
|
|
|
415
909
|
## Known limitations
|
|
416
910
|
|
|
417
|
-
-
|
|
418
|
-
- Locale-aware tokens need Node 20
|
|
419
|
-
-
|
|
420
|
-
|
|
421
|
-
-
|
|
422
|
-
|
|
423
|
-
before around 1582 CE. This is a known ICU limitation, not a bug in this
|
|
424
|
-
library: ICU's default Gregorian calendar cutover is October 15, 1582, so
|
|
425
|
-
`Intl.DateTimeFormat.formatToParts()` silently reinterprets earlier dates
|
|
426
|
-
under the Julian calendar, even though `Temporal` itself uses a proleptic
|
|
427
|
-
Gregorian calendar throughout — see
|
|
428
|
-
[tc39/ecma402#1003](https://github.com/tc39/ecma402/issues/1003). Numeric
|
|
429
|
-
tokens (`yyyy`/`MM`/`dd`) never go through `Intl` and aren't affected.
|
|
430
|
-
- Gluing two unpadded numeric tokens with no separator between them (e.g.
|
|
431
|
-
`Md`, `dM`, `Hm`) is ambiguous for some inputs, and `parse()` throws rather
|
|
432
|
-
than guessing. `"121"` against `yyyy-Md` could mean month 1/day 21 or month
|
|
433
|
-
12/day 1 — both are valid, so there's no single correct reading to fall
|
|
434
|
-
back to. Unambiguous inputs against the same format string still parse
|
|
435
|
-
normally (`"85"` against `yyyy-Md` only has one valid split). If you need
|
|
436
|
-
glued numeric fields, either zero-pad them (`MM`/`dd`) or put a separator
|
|
437
|
-
between them; that removes the ambiguity entirely.
|
|
438
|
-
|
|
439
|
-
Note: `Md` (or `dM`/`Hm`) alone, with no `yyyy`, always throws —
|
|
440
|
-
`parse()` requires year, month, and day together to build a date, so a
|
|
441
|
-
bare `Md` format string is incomplete regardless of ambiguity. The
|
|
442
|
-
examples above use `yyyy-Md` for exactly this reason.
|
|
443
|
-
|
|
444
|
-
- 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.
|
|
445
|
-
|
|
446
|
-
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.
|
|
447
|
-
|
|
448
|
-
## Dev notes
|
|
449
|
-
|
|
450
|
-
Building requires TypeScript 7.0.2+ but `.d.ts` generation runs as a separate
|
|
451
|
-
`tsc` pass, not through tsup. tsup's dts step bundles types via
|
|
452
|
-
`rollup-plugin-dts`, which calls into TypeScript's compiler API — the API
|
|
453
|
-
isn't stable yet on 7.x ([targeted for
|
|
454
|
-
7.1](https://devblogs.microsoft.com/typescript/announcing-typescript-7-0/)),
|
|
455
|
-
so it crashes on 7.0.2. `tsup.config.ts` sets `dts: false` and `build` runs
|
|
456
|
-
`tsup && tsc --declaration --emitDeclarationOnly` instead. One side effect:
|
|
457
|
-
`dist/` now has one `.d.ts` per source file instead of a single rolled-up
|
|
458
|
-
`index.d.ts` — same exported API, different file layout. Revert to `dts:
|
|
459
|
-
true` once tsup/rollup-plugin-dts catch up.
|
|
460
|
-
|
|
461
|
-
Tests pull from `temporal-polyfill/full`, not the slim `temporal-polyfill` —
|
|
462
|
-
the Hebrew-calendar test needs the full build's calendar data, and the slim
|
|
463
|
-
one won't cut it. Locale-aware tests pass on Node 20+ regardless of whether
|
|
464
|
-
`Temporal` is native or polyfilled — on native (Node 26+), formatting goes
|
|
465
|
-
through `Intl.DateTimeFormat` directly; on the polyfill, it falls back to
|
|
466
|
-
`Temporal.prototype.toLocaleString()`, which the polyfill implements itself.
|
|
467
|
-
`parse.test.js` configures Temporal via `setTemporal()` rather than mutating
|
|
468
|
-
`globalThis.Temporal` directly.
|
|
469
|
-
|
|
470
|
-
Run `npm run test:all`, not just `npm test`. `npm test` only runs the
|
|
471
|
-
`node:test` suite in `test/*.test.js` — hand-picked, fuzz, adversarial, and
|
|
472
|
-
perf cases that exercise the public API end to end. It doesn't touch
|
|
473
|
-
`vitest/`, which unit-tests internals like `enumerateValidSplits()`
|
|
474
|
-
directly. That function resolves ambiguous glued numeric runs (does `"112"`
|
|
475
|
-
against `['M', 'd']` mean month 1/day 12, or month 11/day 2?), and a bug
|
|
476
|
-
in its edge cases — an empty token list, a range-boundary off-by-one — can
|
|
477
|
-
easily dodge every `parse()` example in the main suite without ever being
|
|
478
|
-
the specific input one of them happens to use. `test:all` also runs the
|
|
479
|
-
type tests (`test:types`), so it's the only single command that actually
|
|
480
|
-
covers everything. CI runs `test:all` for this reason; running plain `npm
|
|
481
|
-
test` locally will pass even with a broken `vitest/` suite.
|
|
911
|
+
- **Numerals are always Western digits** in numeric tokens, regardless of locale — see [Locales](#locales).
|
|
912
|
+
- **Locale-aware tokens need Node 20+**, native or polyfilled. Untested below that.
|
|
913
|
+
- **You must provide a Temporal implementation** on anything below Node 26 — see [Providing `Temporal`](#providing-temporal).
|
|
914
|
+
- **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.
|
|
915
|
+
- **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.
|
|
916
|
+
- **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.
|
|
482
917
|
|
|
483
918
|
## Related tools
|
|
484
919
|
|
|
485
|
-
|
|
486
|
-
|
|
920
|
+
Neither of these ships as part of this repository — separate packages, install them on their own:
|
|
921
|
+
|
|
922
|
+
- [`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.
|
|
923
|
+
- [`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.
|
|
924
|
+
|
|
925
|
+
## Contributing
|
|
926
|
+
|
|
927
|
+
```sh
|
|
928
|
+
git clone https://github.com/DirazCoder/temporal-fmt.git
|
|
929
|
+
cd temporal-fmt
|
|
930
|
+
npm install
|
|
931
|
+
npm run build
|
|
932
|
+
npm run test:all
|
|
933
|
+
```
|
|
934
|
+
|
|
935
|
+
`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.
|
|
936
|
+
|
|
937
|
+
A couple of build-specific notes if you're touching the toolchain:
|
|
938
|
+
|
|
939
|
+
- 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.
|
|
940
|
+
- 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.
|
|
487
941
|
|
|
488
942
|
## License
|
|
489
943
|
|
|
490
|
-
MIT
|
|
944
|
+
MIT — see [LICENSE](./LICENSE).
|