temporal-fmt 0.9.2 → 0.9.4

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 (103) hide show
  1. package/README.md +480 -9
  2. package/dist/calendarUtils.cjs +259 -4
  3. package/dist/calendarUtils.cjs.map +1 -1
  4. package/dist/calendarUtils.js +33 -1
  5. package/dist/chunk-4N3WCGEY.js +183 -0
  6. package/dist/{chunk-4JWGUR4O.js.map → chunk-4N3WCGEY.js.map} +1 -1
  7. package/dist/chunk-5UTV5VP6.js +984 -0
  8. package/dist/chunk-5UTV5VP6.js.map +1 -0
  9. package/dist/chunk-ACKKWH5Z.js +578 -0
  10. package/dist/{chunk-2JHEV7ZF.js.map → chunk-ACKKWH5Z.js.map} +1 -1
  11. package/dist/chunk-EGUUO6LE.js +439 -0
  12. package/dist/chunk-EGUUO6LE.js.map +1 -0
  13. package/dist/chunk-FBPHNRVW.js +2939 -0
  14. package/dist/chunk-FBPHNRVW.js.map +1 -0
  15. package/dist/chunk-GPB4JHGI.js +65 -0
  16. package/dist/chunk-GPB4JHGI.js.map +1 -0
  17. package/dist/chunk-H7YQX25T.js +170 -0
  18. package/dist/{chunk-J2AQNXF2.js.map → chunk-H7YQX25T.js.map} +1 -1
  19. package/dist/chunk-HXY2GUUM.js +182 -0
  20. package/dist/{chunk-EHLRZULM.js.map → chunk-HXY2GUUM.js.map} +1 -1
  21. package/dist/chunk-L24NPTAJ.js +328 -0
  22. package/dist/chunk-L24NPTAJ.js.map +1 -0
  23. package/dist/chunk-M3GDTHMT.js +169 -0
  24. package/dist/chunk-M3GDTHMT.js.map +1 -0
  25. package/dist/chunk-MKPDLQKD.js +51 -0
  26. package/dist/chunk-MKPDLQKD.js.map +1 -0
  27. package/dist/chunk-TSLPIURC.js +242 -0
  28. package/dist/{chunk-VQXUMFB2.js.map → chunk-TSLPIURC.js.map} +1 -1
  29. package/dist/chunk-UOPAV3WX.js +75 -0
  30. package/dist/chunk-UOPAV3WX.js.map +1 -0
  31. package/dist/chunk-VNIUIQA4.js +398 -0
  32. package/dist/chunk-VNIUIQA4.js.map +1 -0
  33. package/dist/chunk-YEUASGDK.js +9 -0
  34. package/dist/chunk-YEUASGDK.js.map +1 -0
  35. package/dist/chunk-ZQZF5EXW.js +29 -0
  36. package/dist/chunk-ZQZF5EXW.js.map +1 -0
  37. package/dist/config.d.cts +4 -2
  38. package/dist/config.d.ts +4 -2
  39. package/dist/duration.cjs +638 -26
  40. package/dist/duration.cjs.map +1 -1
  41. package/dist/duration.js +28 -1
  42. package/dist/format.cjs +833 -35
  43. package/dist/format.cjs.map +1 -1
  44. package/dist/format.js +20 -1
  45. package/dist/index.cjs +7698 -163
  46. package/dist/index.cjs.map +1 -1
  47. package/dist/index.d.cts +108 -24
  48. package/dist/index.d.ts +108 -24
  49. package/dist/index.js +1536 -58
  50. package/dist/index.js.map +1 -1
  51. package/dist/interval.cjs +5480 -35
  52. package/dist/interval.cjs.map +1 -1
  53. package/dist/interval.js +46 -1
  54. package/dist/localeRegistry.cjs +375 -32
  55. package/dist/localeRegistry.cjs.map +1 -1
  56. package/dist/localeRegistry.js +11 -1
  57. package/dist/modApi.d.cts +131 -0
  58. package/dist/modApi.d.ts +131 -0
  59. package/dist/parse.cjs +1797 -70
  60. package/dist/parse.cjs.map +1 -1
  61. package/dist/parse.js +20 -1
  62. package/dist/pattern.d.cts +1 -0
  63. package/dist/pattern.d.ts +1 -0
  64. package/dist/recurrence.cjs +452 -20
  65. package/dist/recurrence.cjs.map +1 -1
  66. package/dist/recurrence.js +19 -1
  67. package/dist/relativeTime.cjs +268 -21
  68. package/dist/relativeTime.cjs.map +1 -1
  69. package/dist/relativeTime.js +14 -1
  70. package/dist/runtime.d.cts +201 -0
  71. package/dist/runtime.d.ts +201 -0
  72. package/dist/timezone.cjs +387 -20
  73. package/dist/timezone.cjs.map +1 -1
  74. package/dist/timezone.js +26 -1
  75. package/package.json +4 -6
  76. package/scripts/cli.mjs +27 -5
  77. package/dist/chunk-2JHEV7ZF.js +0 -7
  78. package/dist/chunk-3MZLTVP3.js +0 -2
  79. package/dist/chunk-3MZLTVP3.js.map +0 -1
  80. package/dist/chunk-4JWGUR4O.js +0 -11
  81. package/dist/chunk-5U5WJ465.js +0 -36
  82. package/dist/chunk-5U5WJ465.js.map +0 -1
  83. package/dist/chunk-C5YESWFT.js +0 -2
  84. package/dist/chunk-C5YESWFT.js.map +0 -1
  85. package/dist/chunk-C6ZZ62ET.js +0 -2
  86. package/dist/chunk-C6ZZ62ET.js.map +0 -1
  87. package/dist/chunk-EHLRZULM.js +0 -5
  88. package/dist/chunk-F4RGUDA3.js +0 -2
  89. package/dist/chunk-F4RGUDA3.js.map +0 -1
  90. package/dist/chunk-J2AQNXF2.js +0 -2
  91. package/dist/chunk-NBXF7V5B.js +0 -2
  92. package/dist/chunk-NBXF7V5B.js.map +0 -1
  93. package/dist/chunk-R52YOKI3.js +0 -7
  94. package/dist/chunk-R52YOKI3.js.map +0 -1
  95. package/dist/chunk-UJR6DHXY.js +0 -2
  96. package/dist/chunk-UJR6DHXY.js.map +0 -1
  97. package/dist/chunk-VQXUMFB2.js +0 -21
  98. package/dist/chunk-XH46JNPK.js +0 -2
  99. package/dist/chunk-XH46JNPK.js.map +0 -1
  100. package/dist/chunk-YB6YAG7E.js +0 -35
  101. package/dist/chunk-YB6YAG7E.js.map +0 -1
  102. package/dist/chunk-ZGMTY52O.js +0 -2
  103. package/dist/chunk-ZGMTY52O.js.map +0 -1
package/README.md CHANGED
@@ -2,8 +2,9 @@
2
2
 
3
3
  ![coverage](https://img.shields.io/badge/coverage-100%25%20(c8)-brightgreen?style=flat-square)
4
4
 
5
- Format `Temporal.PlainDate` / `PlainTime` / `PlainDateTime` / `ZonedDateTime` objects
6
- using date-fns-style token strings.
5
+ Format and parse `Temporal` values (`PlainDate`, `PlainTime`, `PlainDateTime`,
6
+ `ZonedDateTime`) using date-fns-style tokens, with real validation — bad input
7
+ throws instead of silently returning garbage. Locale-aware, no deps.
7
8
 
8
9
  Node 26 shipped native `Temporal` and then pointedly left out a custom-string
9
10
  formatter. TC39's take: use `Intl.DateTimeFormat` and leave string-token syntax
@@ -12,7 +13,8 @@ muscle memory from date-fns, moment, or dayjs, that's a rough adjustment. This
12
13
  library exists so you don't have to make it.
13
14
 
14
15
  Zero dependencies. Native on Node 26+, or bring your own via a polyfill or
15
- `setTemporal()`.
16
+ `setTemporal()`. Import from a subpath (`temporal-fmt/format`, `temporal-fmt/parse`,
17
+ etc.) to pull in only what you use — see [Subpath imports](#subpath-imports).
16
18
 
17
19
  Locale-aware tokens need Node 20+ regardless of which path you use — native
18
20
  on 26+, or falling back to the Temporal implementation's own
@@ -26,10 +28,27 @@ npm install temporal-fmt
26
28
 
27
29
  [View on npm](https://www.npmjs.com/package/temporal-fmt)
28
30
 
29
- 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.
31
+ ## Get started
32
+
33
+ ```js
34
+ import { format } from 'temporal-fmt/format';
35
+ import { parse } from 'temporal-fmt/parse';
36
+
37
+ const date = Temporal.PlainDate.from('2026-08-04');
38
+ format(date, 'yyyy-MM-dd'); // "2026-08-04"
39
+
40
+ parse('yyyy-MM-dd HH:mm', '2026-08-04 15:45'); // Temporal.PlainDateTime
41
+ ```
42
+
43
+ That's the whole library for most use cases — `format(temporal, formatStr)` in, `parse(formatStr, input)` out, same shape as date-fns or Day.js. Import from the subpaths (`temporal-fmt/format`, `temporal-fmt/parse`) shown above, not the bare `temporal-fmt` package — a bundler only ships what you actually call that way. Measured with esbuild: ~27KB for `format` alone via the subpath, versus ~68KB for the same function pulled from the bare import.
44
+
45
+ Below Node 26, `Temporal` isn't global yet, so you'll need a polyfill first — see [Providing `Temporal`](#providing-temporal). On Node 26+ the snippet above just works.
46
+
47
+ The package looks large on npm — locales, recurrence, business calendars, timezone disambiguation, an analyzer, config layers, a CLI — but none of that is required reading or required bundle weight. It's there behind its own subpaths for when you need it; see [Subpath imports](#subpath-imports) for the full list and [Formatting](#formatting)/[Parsing](#parsing) for the details on the two functions above.
30
48
 
31
49
  ## Contents
32
50
 
51
+ - [Get started](#get-started)
33
52
  - [Providing `Temporal`](#providing-temporal)
34
53
  - [Formatting](#formatting)
35
54
  - [Parsing](#parsing)
@@ -51,6 +70,7 @@ This library is genuinely large — locales, recurrence, business calendars, tim
51
70
  - [Extending with custom tokens](#extending-with-custom-tokens)
52
71
  - [IDE tooling data](#ide-tooling-data)
53
72
  - [CLI](#cli)
73
+ - [Mods](#mods)
54
74
  - [Subpath imports](#subpath-imports)
55
75
  - [Migrating from Day.js or date-fns](#migrating-from-dayjs-or-date-fns)
56
76
  - [Known limitations](#known-limitations)
@@ -71,7 +91,8 @@ Use a polyfill like [`temporal-polyfill`](https://github.com/fullcalendar/tempor
71
91
 
72
92
  ```js
73
93
  import 'temporal-polyfill/global'
74
- import { format, parse } from 'temporal-fmt';
94
+ import { format } from 'temporal-fmt/format';
95
+ import { parse } from 'temporal-fmt/parse';
75
96
 
76
97
  parse(...);
77
98
  ```
@@ -82,7 +103,9 @@ Set a Temporal implementation explicitly, once, before your app's first `format(
82
103
 
83
104
  ```js
84
105
  import { Temporal } from 'temporal-polyfill/full';
85
- import { setTemporal, format, parse } from 'temporal-fmt';
106
+ import { setTemporal } from 'temporal-fmt';
107
+ import { format } from 'temporal-fmt/format';
108
+ import { parse } from 'temporal-fmt/parse';
86
109
 
87
110
  setTemporal(Temporal); // once, before using format or parse
88
111
  ```
@@ -94,7 +117,7 @@ Anything that constructs a `Temporal` value from scratch needs this — `parse()
94
117
  ## Formatting
95
118
 
96
119
  ```js
97
- import { format } from 'temporal-fmt';
120
+ import { format } from 'temporal-fmt/format';
98
121
 
99
122
  const date = Temporal.PlainDate.from('2026-08-04');
100
123
  format(date, 'yyyy-MM-dd'); // "2026-08-04"
@@ -120,7 +143,7 @@ Try a token your input type doesn't support — `HH` on a `PlainDate`, say — a
120
143
  `parse()` builds a real `Temporal.PlainDate` / `PlainTime` / `PlainDateTime` / `ZonedDateTime` out of a string, picking whichever type fits the tokens present:
121
144
 
122
145
  ```js
123
- import { parse } from 'temporal-fmt';
146
+ import { parse } from 'temporal-fmt/parse';
124
147
 
125
148
  parse('yyyy-MM-dd HH:mm', '2026-08-04 15:45'); // Temporal.PlainDateTime
126
149
  parse('yyyy-MM', '2026-08-04T15:45:30'); // throws — shape doesn't match
@@ -836,6 +859,8 @@ A handful of functions exist specifically to feed editor tooling — autocomplet
836
859
 
837
860
  The CLI ships in this package (`scripts/cli.mjs`) and reads/writes stdin/stdout. Run it via `npm run cli` inside a checkout of this repo, or `node scripts/cli.mjs` directly. Called with a subcommand it runs once and exits, same as any Unix tool — fine for scripts and CI:
838
861
 
862
+ On Node 26+ the CLI uses native `Temporal` and needs nothing extra. Below that, it looks for a globally-installed [`temporal-polyfill`](https://github.com/fullcalendar/temporal-polyfill) and exits with an install hint if it can't find one — `temporal-fmt` itself ships with zero dependencies, so this one's on you: `npm install temporal-polyfill`.
863
+
839
864
  ```sh
840
865
  temporal-fmt format "2026-08-04T15:45:30" "yyyy-MM-dd HH:mm:ss"
841
866
  temporal-fmt parse "yyyy-MM-dd" "2026-08-04"
@@ -870,6 +895,285 @@ temporal-fmt> exit
870
895
 
871
896
  Type a subcommand with all its arguments inline (`validate yyyy-MM-dd`) or just the subcommand name — the REPL prompts for whatever's missing, one field at a time. Errors print and the session keeps going; `exit`, `quit`, or Ctrl+D ends it. This is the same subcommand logic as one-shot mode, just wrapped in a loop that asks instead of exiting on a missing argument — one-shot stays there for scripting, and doesn't touch the REPL machinery.
872
897
 
898
+ ## Mods
899
+
900
+ `registerLocale`, `createHolidayCalendar`, and `createFormatter` are already how you extend this library without forking it — [Locales](#locales), [Business calendars and holidays](#business-calendars-and-holidays), and [Extending with custom tokens](#extending-with-custom-tokens) all cover them. Mods are just a delivery mechanism on top of those same functions: drop a file in a `mods/` folder, the CLI picks it up on startup and runs it. No publishing to npm, no build step in this repo, no manifest to register anywhere. If you've used a Minecraft mods folder, it's the same idea — a file the host looks for and loads, not a package the host depends on.
901
+
902
+ This exists so bugfixes and locale corrections don't have to wait on a PR merging and a release going out. If en-GB's holiday list is wrong for your team, or a locale you need isn't covered yet, write a mod and drop it in. Whether it ever gets upstreamed into this repo is a separate question from whether it works today.
903
+
904
+ ### Writing a mod
905
+
906
+ A mod is a `.mjs` file that default-exports an object with a `name` and a `register(ctx, config)` function. `ctx` is the same registration API `index.ts` exports for everyone else — `registerLocale`, `registerLocaleVocab`, `registerRelativeGrammar`, `createFormatter`, `createHolidayCalendar` — nothing beyond that. A mod that needs more than those five functions expose is asking for something this library doesn't support yet, not something to route around by reaching into internals that could shift under it without warning. `config` is `{}` for a loose `.mjs` mod — there's no manifest to declare settings in, so there's nothing to resolve; see [Mod settings and `config/`](#mod-settings-and-config) for mods that need user-adjustable settings, which means packaging as `.tfmod`.
907
+
908
+ ```js
909
+ // mods/en-gb-bank-holidays.mjs
910
+ export default {
911
+ name: 'en-gb-bank-holidays',
912
+ version: '1.0.0',
913
+ register(ctx) {
914
+ ctx.createHolidayCalendar([
915
+ { month: 1, day: 1, name: "New Year's Day" },
916
+ { month: 12, day: 25, name: 'Christmas Day' },
917
+ { month: 12, day: 26, name: 'Boxing Day' },
918
+ ]);
919
+ },
920
+ };
921
+ ```
922
+
923
+ Put that in `mods/` at your project root (the folder the CLI is run from, not inside this package's own checkout) and run any CLI command — the loader reports what it found on stderr:
924
+
925
+ ```
926
+ $ temporal-fmt validate "yyyy-MM-dd"
927
+ temporal-fmt mods:
928
+ loaded en-gb-bank-holidays@1.0.0 (en-gb-bank-holidays.mjs)
929
+ valid
930
+ ```
931
+
932
+ `version` is optional and only shows up in that report — it's for your own tracking, not something the loader checks. That's a different field from `temporalFmtVersion`, which *is* checked against the installed `temporal-fmt` version, but only exists on `.tfmod` manifests (see [Pinning a mod to a `temporal-fmt` version](#pinning-a-mod-to-a-temporal-fmt-version)) — a loose `.mjs` mod has no manifest to declare it in.
933
+
934
+ ### Packaging a mod as `.tfmod`
935
+
936
+ A loose `.mjs` file covers the common case, but it's one file — no bundled data, and the loader has to `import()` it just to find out its `name` before deciding load order. For anything bigger than that, package the mod as a `.tfmod` archive instead: a gzipped tar (same format as `.tgz`, renamed for identity) containing a manifest the loader can read without running any code, plus the mod's actual implementation:
937
+
938
+ ```
939
+ en-gb-bank-holidays.tfmod
940
+ ├── mod.json — name, version, main, requires, priority, temporalFmtVersion, config
941
+ ├── main.mjs — the mod's entry point (same shape as a loose .mjs mod's default export, minus `name`/`version`/`requires`/`priority` — mod.json owns those)
942
+ └── data/ — optional: JSON files, locale tables, anything main.mjs wants to read at register() time
943
+ ```
944
+
945
+ ```json
946
+ // mod.json
947
+ {
948
+ "name": "en-gb-bank-holidays",
949
+ "version": "1.0.0",
950
+ "main": "main.mjs",
951
+ "requires": ["some-other-mod"],
952
+ "priority": 0,
953
+ "temporalFmtVersion": "^0.9.0"
954
+ }
955
+ ```
956
+
957
+ ```js
958
+ // main.mjs
959
+ export default {
960
+ register(ctx) {
961
+ ctx.createHolidayCalendar([
962
+ { month: 1, day: 1, name: "New Year's Day" },
963
+ { month: 12, day: 25, name: 'Christmas Day' },
964
+ ]);
965
+ },
966
+ };
967
+ ```
968
+
969
+ Build the archive with plain `tar` — no special tooling:
970
+
971
+ ```sh
972
+ tar -czf en-gb-bank-holidays.tfmod mod.json main.mjs data/
973
+ ```
974
+
975
+ Drop that in `mods/` alongside any loose `.mjs` mods you have; the loader treats both formats as one pool for load-order and conflict purposes. The report shows `mod.json`'s `name`, not anything from `main.mjs` itself:
976
+
977
+ ```
978
+ $ temporal-fmt validate "yyyy-MM-dd"
979
+ temporal-fmt mods:
980
+ loaded en-gb-bank-holidays@1.0.0 (en-gb-bank-holidays.tfmod)
981
+ valid
982
+ ```
983
+
984
+ Why bother with an archive format at all instead of just supporting multi-file `.mjs` mods directly: `mod.json` is metadata the loader can read with zero code execution, which is what makes cross-mod dependency resolution work honestly — with a loose `.mjs` mod, the loader has no choice but to `import()` the file to learn its `name`/`requires`, before it even knows whether that mod should run. A `.tfmod`'s manifest is checked, and the whole dependency graph is resolved, before `main.mjs` is ever imported. At the current few-mods-loaded-once-at-CLI-startup scale that distinction mostly doesn't matter — but it's the honest reason the format exists rather than "loose files but with a folder," and it's what a "list what's installed without running any of it" feature would build on if that ever comes up.
985
+
986
+ Failure modes are per-archive, same as loose mods — one bad `.tfmod` doesn't block anything else in `mods/`:
987
+
988
+ - `mod.json` missing or malformed (no `name`, no `main`, or `requires`/`priority`/`temporalFmtVersion`/`config` the wrong type) — reported with what was expected, `main.mjs` is never imported.
989
+ - `mod.json` names a `main` file that isn't actually in the archive — reported with the missing filename.
990
+ - The archive isn't a valid gzip/tar (corrupted, wrong format, a `.tfmod` extension slapped on some other file) — reported with the extraction error.
991
+ - `main.mjs`'s default export doesn't have a `register` function — reported, same as a loose mod's malformed export.
992
+ - `temporalFmtVersion` doesn't match the installed `temporal-fmt` version — reported with the range and the actual version, `main.mjs` is never imported. See [Pinning a mod to a `temporal-fmt` version](#pinning-a-mod-to-a-temporal-fmt-version).
993
+
994
+ Extraction happens to a temporary directory that's cleaned up after the load pass — nothing from a `.tfmod` sticks around on disk after the CLI command finishes. Extraction shells out to the system `tar` binary rather than adding a tar/gzip-parsing dependency, consistent with this package staying dependency-free (see [Providing `Temporal`](#providing-temporal) for the same call made about the polyfill) — if `tar` isn't on the system `PATH`, the archive fails to load with that reason rather than crashing the CLI.
995
+
996
+ ### Pinning a mod to a `temporal-fmt` version
997
+
998
+ `mod.json` can declare `temporalFmtVersion`, either an exact version (`"0.9.32"`) or a caret range (`"^0.9.0"`, meaning ">=0.9.0, <0.10.0" — same meaning npm gives `^` in `package.json`). If the installed `temporal-fmt` doesn't satisfy it, the mod fails to load with the range and the actual version, before `main.mjs` is ever imported:
999
+
1000
+ ```
1001
+ failed holidays.tfmod: "en-gb-bank-holidays" needs temporal-fmt ^2.0.0 (>=2.0.0 <3.0.0), host is 0.9.32
1002
+ ```
1003
+
1004
+ This exists because nothing else catches the alternative: a mod built against one version's override surface (which functions are zero-fanout and therefore overridable — see [Overriding functions](#overriding-functions)) has no way to know if a future release moved a function it depends on, and would otherwise fail with whatever confusing error `register()` happens to throw, or — worse — silently do nothing if the call it expected to matter just no longer has any effect. A declared range turns that into one clear, pre-`register()` failure instead.
1005
+
1006
+ Omitting `temporalFmtVersion` is allowed — the mod loads against whatever version is installed, same as before this field existed. Loose `.mjs` mods have no manifest to put this in at all, so they can't declare a version requirement; that's one real reason to prefer `.tfmod` for anything you plan to distribute rather than just run yourself.
1007
+
1008
+ There's no dependency-resolution logic here, unlike `requires`/`priority` — this is a single boolean check (does the host version satisfy the range), not something that affects load order.
1009
+
1010
+ ### Mod settings and `config/`
1011
+
1012
+ A mod can declare user-adjustable settings in `mod.json`'s `config` array, and `register()` receives the resolved values as its second argument:
1013
+
1014
+ ```json
1015
+ // mod.json
1016
+ {
1017
+ "name": "en-gb-bank-holidays",
1018
+ "main": "main.mjs",
1019
+ "config": [
1020
+ { "key": "includeScottish", "type": "boolean", "default": false },
1021
+ { "key": "observedRule", "type": "enum", "default": "nearest-weekday", "choices": ["nearest-weekday", "strict-date"] },
1022
+ { "key": "yearsAhead", "type": "number", "default": 5, "min": 1, "max": 20 }
1023
+ ]
1024
+ }
1025
+ ```
1026
+
1027
+ ```js
1028
+ // main.mjs
1029
+ export default {
1030
+ register(ctx, config) {
1031
+ const years = config.yearsAhead; // 5, unless overridden below
1032
+ ctx.createHolidayCalendar(buildHolidays({ scottish: config.includeScottish, years }));
1033
+ },
1034
+ };
1035
+ ```
1036
+
1037
+ Four setting types are supported: `string`, `number` (with optional `min`/`max`), `boolean`, and `enum` (a string constrained to `choices`). Every entry needs a `key` and a `default` — the default is what `register()` gets if the user hasn't overridden that setting, which also means a mod with no `config/<name>.json` file on disk at all still runs normally, just entirely on defaults.
1038
+
1039
+ To override a setting, drop a JSON file at `config/<mod-name>.json` — **next to `mods/`, not inside it** (so re-downloading or updating the `.tfmod` never touches a user's settings, the same reason Forge keeps `config/` and `mods/` as siblings rather than bundling settings into the jar):
1040
+
1041
+ ```
1042
+ your-project/
1043
+ ├── mods/
1044
+ │ └── en-gb-bank-holidays.tfmod
1045
+ └── config/
1046
+ └── en-gb-bank-holidays.json — { "includeScottish": true, "yearsAhead": 10 }
1047
+ ```
1048
+
1049
+ Only keys the schema actually declares can be set — anything else is a mistake worth surfacing, not a silent no-op:
1050
+
1051
+ ```
1052
+ temporal-fmt mods:
1053
+ loaded en-gb-bank-holidays@1.0.0 (en-gb-bank-holidays.tfmod)
1054
+ failed config/en-gb-bank-holidays.json: en-gb-bank-holidays: config key "yearsAhead" must be <= 20, got 50 (using default)
1055
+ failed config/en-gb-bank-holidays.json: en-gb-bank-holidays: unknown config key "includeWelsh" (not declared in this mod's schema)
1056
+ ```
1057
+
1058
+ An invalid value for a declared key falls back to that key's default rather than failing the whole mod — one typo'd number in a config file shouldn't take down a working mod, but it's reported so the mistake doesn't go unnoticed either. This is deliberately not JSON Schema: no nesting, no `$ref`, no conditional rules — just the handful of primitive shapes an actual setting realistically is, kept dependency-free the same way `temporalFmtVersion` checking and `.tfmod` extraction are.
1059
+
1060
+ Loose `.mjs` mods have no manifest to declare a schema in, so `register()`'s second argument is always `{}` for them — same as a `.tfmod` mod that didn't declare a `config` field at all.
1061
+
1062
+ ### Load order, dependencies, and conflicts
1063
+
1064
+ By default mods load in filename order — alphabetical, deterministic, but not something you'd want to rely on once two mods actually need to run in a specific order relative to each other. Two fields on the mod object control that directly:
1065
+
1066
+ - `requires: string[]` — other mods' `name` fields that must load (and finish `register()`) before this one. The loader resolves this as a dependency graph, not just "sort requires first" — if A requires B and B requires nothing, B always loads first regardless of filename.
1067
+ - `priority: number` — tiebreak for mods with no dependency relationship to each other. Higher loads later. Defaults to `0`.
1068
+
1069
+ ```js
1070
+ export default {
1071
+ name: 'extended-en-gb-holidays',
1072
+ requires: ['en-gb-bank-holidays'],
1073
+ priority: 10,
1074
+ register(ctx) {
1075
+ // runs after en-gb-bank-holidays, and after anything else at a lower priority
1076
+ },
1077
+ };
1078
+ ```
1079
+
1080
+ Two failure modes come out of this, both reported per-mod without blocking the rest:
1081
+
1082
+ - **Missing dependency** — `requires` names a mod that isn't in `mods/`. That mod fails to load; whatever it would've registered doesn't happen, and other mods that don't depend on it load normally.
1083
+ - **Circular dependency** — A requires B requires A (or a longer cycle). Every mod in the cycle fails, each reported with what it's still waiting on.
1084
+
1085
+ Registration itself is still last-write-wins, same as calling `registerLocale` twice for the same tag outside of mods — that's existing, intentional behavior (see [Locales](#locales)), not something mods change. What mods add is *visibility* into it: if two mods register the same locale tag, the same relative-time-grammar language, or the same custom token name, the load report calls it out as a conflict and says which one won:
1086
+
1087
+ ```
1088
+ temporal-fmt mods:
1089
+ loaded holiday-pack-a (conflict-1.mjs)
1090
+ loaded holiday-pack-b (conflict-2.mjs)
1091
+ conflict on locale "cv-CV": holiday-pack-a, holiday-pack-b — "holiday-pack-b" wins (loaded last)
1092
+ ```
1093
+
1094
+ This is informational, not a failure — both mods still loaded, the last one to register just took the key, and now you know it happened instead of silently getting whichever mod's filename sorted last. If that's not what you want, `priority` is the knob: raise the one that should win, or add a `requires` so the loser explicitly runs first and the winner's intent is unambiguous in the mod itself, not just in a startup log line.
1095
+
1096
+ Mod names have to be unique across `mods/` — two files claiming the same `name` is ambiguous the moment either one shows up in another mod's `requires`, so the second one to load fails with which file already claimed that name.
1097
+
1098
+ ### Overriding functions
1099
+
1100
+ The five registration functions above are additive — they add a locale, a holiday set, a token, alongside whatever's already there. `ctx.overrideFormat` and `ctx.overrideParse` work differently: they let a mod replace the actual `format()`/`parse()` implementation everywhere in the library, which is what makes a real bugfix or performance mod possible rather than just new data being registered alongside an unfixed bug.
1101
+
1102
+ ```js
1103
+ export default {
1104
+ name: 'fast-format',
1105
+ register(ctx) {
1106
+ ctx.overrideFormat((original, value, formatStr, options) => {
1107
+ // Handle the one hot-path format string yourself; fall back to the
1108
+ // real implementation for everything else.
1109
+ if (formatStr === 'yyyy-MM-dd') {
1110
+ return `${value.year}-${String(value.month).padStart(2, '0')}-${String(value.day).padStart(2, '0')}`;
1111
+ }
1112
+ return original(value, formatStr, options);
1113
+ });
1114
+ },
1115
+ };
1116
+ ```
1117
+
1118
+ `impl` always receives the real built-in as its first argument (`original`), regardless of what else is loaded — call it to keep existing behavior for cases you're not trying to change, or ignore it to replace the behavior outright. The override applies consistently everywhere in the library, not just to whoever imports the function from the package root — `formatRange()`'s internal use of `format()`, for instance, sees it too. Remove the mod and restart, and it's back to the unmodified built-in; nothing about this touches the source file on disk.
1119
+
1120
+ **Only one mod may hold each override point.** A second override call for the same function — from any mod, even one that `requires` the first — fails immediately with which mod already owns it:
1121
+
1122
+ ```
1123
+ temporal-fmt mods:
1124
+ loaded override-1 (a-override1.mjs)
1125
+ failed b-override2.mjs: temporal-fmt: "format" is already overridden by mod "override-1" — mod "override-2" can't also override it. [...]
1126
+ ```
1127
+
1128
+ This is a hard failure, not last-write-wins like the registration functions — two mods silently fighting over the same function's behavior is a correctness bug in whatever depends on this library, not a cosmetic surprise. There's no mechanism for two separate mod files to layer through the same override point in sequence; if two mods both need to change a function's behavior, one has to incorporate the other's fix directly rather than composing through the override twice.
1129
+
1130
+ **Which functions are overridable.** `format`, `formatToParts`, and `parse` always were. Beyond those, any function in this library that nothing *else* in the library calls internally is also overridable — if a function has zero internal call sites, there's no risk of some other module holding a stale direct reference that a mod's fix would silently fail to reach, so it gets the same `overrideXxx()` treatment. As of this version, that's:
1131
+
1132
+ `compileFormat`, `compileParser`, `parseRelative`, `explainFormat`, `tokenizeFormat`, `listTokens`, `tokenInfo`, `isValidFormat`, `validateFormat`, `fieldForToken`, `monthsInYear`, `isLeapYear`, `isLeapMonth`, `weekOfYear`, `weekYear`, `getMonth`, `getWeekday`, `isEqual`, `isBefore`, `isAfter`, `clamp`, `isBetween`, `isToday`, `isTomorrow`, `isYesterday`, `isSameDay`, `isSameWeek`, `isSameMonth`, `isSameQuarter`, `isSameYear`, `isWeekday`, `floor`, `ceil`, `truncate`, `parseRFC3339`, `formatRFC3339`, `parseRFC2822`, `parseHTTPDate`, `fromUnixMicroseconds`, `fromUnixNanoseconds`, `toUnixSeconds`, `toUnixMilliseconds`, `toUnixMicroseconds`, `toUnixNanoseconds`, `parseSQL`, `formatSQL`, `formatDurationToParts`, `parseDuration`, `parseISODuration`, `formatISODuration`, `balanceDuration`, `compareDuration`, `subtractDuration`, `getLocale`, `hasLocale`, `createConfig`, `mergeWithConfig`, `listRegisteredGrammars`, `interval`, `overlaps`, `intersection`, `union`, `mergeIntervals`, `formatRangeToParts`, `between`, `parseRRule`, `formatRRule`, `createBusinessCalendar`, `subtractBusinessDays`, `nextHoliday`, `previousHoliday`, `resolveZoned`, `getNextTransition`, `getPreviousTransition`, `possibleInstantsFor`, `getAutocompleteData`, `getHoverDocs`, `getInlineDiagnostics`, `previewFormat`, `getDocUrl`, `translateDateFnsFormatString`.
1133
+
1134
+ Each follows the `ctx.overrideXxx((original, ...args) => ...)` shape shown above for `overrideFormat`. Functions *not* in this list — `round`, `subtract`, `difference`, `formatDistance`, and others that other parts of this library call directly — aren't overridable this way: something else in the codebase holds its own direct reference to them, so a mod's override would silently miss those internal callers, which is worse than not offering the override at all. A function moves onto this list only when an audit confirms nothing internal still calls it directly. If you need to change one of those, that's a real feature request for making it internally indirect first, not something `overrideFormat`-style code can paper over.
1135
+
1136
+ ### If you're writing the mod in TypeScript
1137
+
1138
+ Compile it and rename the output before it goes in `mods/` — the loader only accepts `.mjs`. It won't run a TS file for you, and it won't skip one quietly either: a `.ts` file sitting in `mods/` shows up in the load report as a failure with the exact compile command to run, because a mod that silently never loads is worse than one that fails loudly.
1139
+
1140
+ ```sh
1141
+ tsc en-gb-bank-holidays.ts --module esnext --target esnext --outDir mods
1142
+ mv mods/en-gb-bank-holidays.js mods/en-gb-bank-holidays.mjs
1143
+ ```
1144
+
1145
+ If you're importing `ModContext` or `Mod` for the types while you write it, both are exported from `temporal-fmt` itself:
1146
+
1147
+ ```ts
1148
+ import type { Mod, ModContext } from 'temporal-fmt';
1149
+
1150
+ const mod: Mod = {
1151
+ name: 'en-gb-bank-holidays',
1152
+ register(ctx: ModContext) {
1153
+ ctx.createHolidayCalendar([{ month: 1, day: 1, name: "New Year's Day" }]);
1154
+ },
1155
+ };
1156
+
1157
+ export default mod;
1158
+ ```
1159
+
1160
+ ### What happens when a mod is broken
1161
+
1162
+ Each mod loads independently — one throwing doesn't stop the rest from loading, and it doesn't stop the CLI command you actually ran. Every failure mode ends up as one line in the report:
1163
+
1164
+ - Wrong file extension (`.ts`, `.js`, anything but `.mjs`) — reported with the compile-and-rename instructions above.
1165
+ - Default export isn't shaped right (missing `name`, missing `register`, `register` isn't a function, or `requires`/`priority` are the wrong type) — reported with what was expected.
1166
+ - The file fails to import (a syntax error, a bad import path inside the mod) — reported with the underlying error message.
1167
+ - Two mods claim the same `name` — reported against whichever file loaded second.
1168
+ - A `requires` entry names a mod that isn't present, or is part of a dependency cycle — see [Load order, dependencies, and conflicts](#load-order-dependencies-and-conflicts).
1169
+ - `register()` throws — reported with the thrown message, same as any other registration call in this library (see [Typed errors](#typed-errors) for what `registerLocale`/`createHolidayCalendar` themselves throw on bad input).
1170
+
1171
+ None of these bring down the CLI. A `mods/` folder that doesn't exist is the common case, not a failure — most runs won't have one, and the loader stays silent about it rather than printing "no mods found" noise on every command.
1172
+
1173
+ ### Using mods outside the CLI
1174
+
1175
+ `loadMods()` only exists in `scripts/loadMods.mjs`, not in the published library — it needs `fs`/`path`, and this package stays dependency-free and Node-agnostic on its actual import surface (`import { format } from 'temporal-fmt'` shouldn't drag in filesystem code for someone using this in a browser). If you're embedding `temporal-fmt` in your own app rather than using the CLI, copy that loader (or write your own — it's about 60 lines) and call it at your own startup with `buildModContext()` and `isMod()` from the library, which *are* published.
1176
+
873
1177
  ## Subpath imports
874
1178
 
875
1179
  Each capability area is also available as a subpath import, for anyone who wants a slice of the package rather than the whole thing:
@@ -888,6 +1192,8 @@ import { registerLocale } from 'temporal-fmt/locale';
888
1192
 
889
1193
  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.
890
1194
 
1195
+ `sideEffects: false` is set in `package.json`, so a bundler with tree-shaking enabled genuinely drops what you don't import. Measured with esbuild: `import { format } from 'temporal-fmt/format'` bundles to ~27KB, versus ~68KB for the same single function pulled from the bare `temporal-fmt` entry — the main entry point re-exports everything, so anything imported from it drags the whole graph along regardless of what you actually call. If bundle size matters for your use case, import from the subpath, not the package root.
1196
+
891
1197
  ## Migrating from Day.js or date-fns
892
1198
 
893
1199
  ### Token mapping
@@ -997,7 +1303,172 @@ Neither of these ships as part of this repository — separate packages, install
997
1303
 
998
1304
  ## Testing
999
1305
 
1000
- This library is heavily tested. The `node:test` suite (`test/*.test.js`) runs 1200+ 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.
1306
+ This library is heavily tested. The `node:test` suite (`test/*.test.js`) runs 1300+ 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.
1307
+
1308
+ # Conformance fixtures
1309
+
1310
+ `fixtures.json` is a portable, library-agnostic test-vector set for
1311
+ token-based Temporal formatters. It's written against a different
1312
+ library's token vocabulary, not temporal-fmt's — the fixtures are
1313
+ data, not code. Each case names an `op` (`format` / `parse` /
1314
+ `roundtrip`), an input, a pattern, and an expected result, so any
1315
+ library with a `format`/`parse` pair can be pointed at it.
1316
+
1317
+ `test/conformance.test.js` is the temporal-fmt-specific adapter. It
1318
+ translates fixture patterns into temporal-fmt's actual tokens, runs
1319
+ the cases against `format()`/`parse()`, and checks the result.
1320
+
1321
+ ## Why this is separate from `test/adversarial.test.js` and `test/fuzz.test.js`
1322
+
1323
+ Those two check that temporal-fmt is internally consistent under
1324
+ hostile input — clean throw or correct value, never a crash or a
1325
+ silently wrong one. The reference point there is the library's own
1326
+ logic.
1327
+
1328
+ This folder is different: it checks temporal-fmt against an external,
1329
+ shared set of tricky-but-well-defined cases — DST transitions, leap
1330
+ years, offset rendering, calendar limits — where "correct" comes from
1331
+ the fixture, not from temporal-fmt's own code.
1332
+
1333
+ ## Pattern translation
1334
+
1335
+ Two fixture tokens don't exist in temporal-fmt:
1336
+
1337
+ | Fixture token | temporal-fmt equivalent | Why |
1338
+ |---|---|---|
1339
+ | `ZZ` (always-signed offset, never `Z`) | `xxx` | Only the **uppercase** `X`/`XX`/`XXX` family collapses `+00:00` to `Z` (see `formatOffset()` in `src/tokens.ts`). Lowercase never does, which is exactly `ZZ`'s semantics. Mapping `ZZ` to `XXX` was tried first and is wrong — it fails `offset-ZZ-format-utc-not-Z` and `zone-utc-roundtrip`, both of which expect `+00:00`, not `Z`. |
1340
+ | `VV` (IANA zone id) | `zzz` | temporal-fmt's only zone-identity token. |
1341
+
1342
+ Translation happens in `translatePattern()` and skips anything inside
1343
+ a quoted literal span. Everything else in the fixture set — `yyyy`,
1344
+ `y`, `MM`, `dd`, `HH`, `mm`, `ss`, `S`..`SSSSSSSSS`, `h`, `a`,
1345
+ `X`/`XX`/`XXX` — already matches temporal-fmt's vocabulary directly.
1346
+
1347
+ ## `opinionated` cases
1348
+
1349
+ Some cases are flagged `"opinionated": true` right in the fixture.
1350
+ These encode a design choice of the fixture's source library, not a
1351
+ fact about dates, and temporal-fmt is allowed to disagree with them.
1352
+ The adapter still runs them — if temporal-fmt's behavior differs, it
1353
+ logs a divergence note (printed at the end of the run) instead of
1354
+ failing the suite.
1355
+
1356
+ Two cases currently diverge, both `yy`-pivot ones —
1357
+ `extreme-year-two-digit-pivot-low` and
1358
+ `extreme-year-two-digit-pivot-high`. temporal-fmt refuses `yy` in any
1359
+ format string that isn't a complete date (`yyyy`/`yy` + month + day),
1360
+ so `parse("yy-MM", ...)` throws an incomplete-date error before the
1361
+ question of *which* century a 2-digit year should resolve to ever
1362
+ comes up. The fixture's position — that bare `yy-MM` should resolve
1363
+ via the 00-68/69-99 ECMAScript pivot — is a convention, not a fact
1364
+ about dates; a library is free to pick a different pivot, or, as here,
1365
+ decline to guess a century from `yy` alone at all. Both are documented
1366
+ design choices with their own passing tests
1367
+ (`test/parse.test.js`, `yy pivot: ...`), not something in scope to
1368
+ "fix" by adopting the fixture's convention.
1369
+
1370
+ One other flagged case no longer diverges:
1371
+ **`shape-mixing-H-and-a-rejected`**. `resolveHour()`
1372
+ (`src/parse.ts`) used to cross-check `H` against `a` instead of
1373
+ banning the combination outright, so `13:05 PM` was accepted (13:00 is
1374
+ consistent with PM) and only a genuine contradiction like `01:05 PM`
1375
+ threw. That choice has since been reverted — `H` and `a` are now
1376
+ refused together outright, unconditionally, matching the fixture. The
1377
+ fixture's own `"opinion"` text on that case still describes the old
1378
+ behavior; it's fixture data, not something this adapter edits, so
1379
+ treat the `opinionated` flag there as historical rather than current.
1380
+
1381
+ ## History: divergences that have since been fixed
1382
+
1383
+ Everything below was once tracked in `KNOWN_FAILURES` at the top of
1384
+ `test/conformance.test.js`. That set is currently empty — every
1385
+ previously-found divergence has been resolved, either by fixing a
1386
+ real bug or by deliberately adopting the fixture's convention over a
1387
+ prior design choice. Kept here for context on what changed and why,
1388
+ in case any of it needs revisiting.
1389
+
1390
+ **Fixed — real bug: offset seconds were dropped, not rejected.**
1391
+ `formatOffset()` (`src/tokens.ts`) assumed every offset string was
1392
+ exactly 6 characters — sign, `HH`, `:`, `MM` — and never checked for a
1393
+ seconds component. Verified against a real `Temporal.ZonedDateTime`
1394
+ for a pre-1900 `America/New_York` date: the actual offset is
1395
+ `-04:56:02`, 9 characters, because pre-1883 New York ran on local mean
1396
+ time. The old code read that string's middle two digits as minutes,
1397
+ so `X` silently produced `-0402` (wrong) instead of refusing. Now:
1398
+ `X`/`XX`/`XXX`/`x`/`xx` throw when the offset has a seconds component
1399
+ (none of them have anywhere to put it), and `xxx` — the variant that
1400
+ plays `ZZ`'s "always-signed, never-Z" role — passes the full value
1401
+ through unchanged.
1402
+ - `offset-sub-minute-rejected-by-X`
1403
+ - `offset-sub-minute-passes-through-ZZ`
1404
+
1405
+ **Changed — offset-only `ZonedDateTime` construction, previously
1406
+ supported on purpose, is now refused.** `parse()` used to build a
1407
+ `ZonedDateTime` from an offset token alone, no `zzz` zone required.
1408
+ That was deliberate, not an oversight, but the fixture's position — an
1409
+ offset identifies a moment's distance from UTC, not a time zone, so
1410
+ building a `ZonedDateTime` from one alone papers over that distinction
1411
+ — was adopted instead. A pattern with an offset token and no `zzz` now
1412
+ throws; add `zzz` to the pattern (or parse into a
1413
+ `PlainDateTime`/`PlainDate`/`PlainTime` if a zone genuinely isn't
1414
+ needed).
1415
+ - `zone-required-for-zoneddatetime`
1416
+ - `zone-offset-token-rejected-on-plain-type`
1417
+
1418
+ **Added — `y` token (unpadded, variable-width year).** temporal-fmt
1419
+ previously had only `yyyy` (fixed 4 digits) and `yy` (2-digit,
1420
+ truncated). `y` formats and parses a year at any width, sign preserved
1421
+ for years before ISO year 0 — same semantics as `yyyy` minus the
1422
+ fixed width. It has no bounded fallback the way `yyyy` does when
1423
+ something digit-consuming follows (`yyyy` can fall back to an exact
1424
+ 4-digit fragment in that case; `y` being unpadded is the entire point
1425
+ of the token, so there's no narrower shape that still means the same
1426
+ thing). `buildCapturingPattern()` (`src/parsePattern.ts`) refuses at
1427
+ build time to place `y` directly next to another digit-reading token
1428
+ or a digit-leading literal, rather than trying to estimate an
1429
+ ambiguity cost for an unbounded-width fragment — there's no finite
1430
+ number of "width choices" to charge for "any number of digits."
1431
+ - `extreme-year-max-supported`
1432
+ - `extreme-year-negative`
1433
+ - `extreme-year-past-max-rejected` — previously passed even without a
1434
+ real `y` token, because "no valid pattern matches" for the
1435
+ then-unrecognized token happened to also throw. Now genuinely tests
1436
+ 275761 CE rejection, via the real max-year check on `y`'s parsed
1437
+ value.
1438
+
1439
+ **Fixed — misdiagnosed as a regex gap; the actual cause was `zzz`
1440
+ rejecting valid IANA zone aliases.** `offset-X-parse-accepts-four-digit`
1441
+ expects `X` to parse a 4-digit offset body (`+0530`) alongside a `zzz`
1442
+ zone. This was originally filed as "the capturing regex for `X` in
1443
+ `pattern.ts` doesn't offer the 4-digit shape as an alternative" — that
1444
+ diagnosis was wrong. The `X` regex fragment matches `+0530` correctly
1445
+ in isolation; the actual failure was `isValidTimeZone()`
1446
+ (`src/pattern.ts`) rejecting the fixture's zone name, `Asia/Kolkata`.
1447
+ `isValidTimeZone()` only checked `Intl.supportedValuesOf('timeZone')`,
1448
+ which lists canonical zone ids but not every IANA link/alias name —
1449
+ `Asia/Kolkata` is a legitimate, commonly-used alias for
1450
+ `Asia/Calcutta` that some ICU builds' `supportedValuesOf()` omits.
1451
+ Confirmed against `temporal-polyfill` directly:
1452
+ `Temporal.ZonedDateTime.from()` resolves `Asia/Kolkata` without
1453
+ complaint, so `parse()` was refusing input its own downstream
1454
+ construction step would have accepted. `isValidTimeZone()` now falls
1455
+ back to asking `Temporal` itself (a real `ZonedDateTime.from()` call)
1456
+ when the fast-path `Intl` lookup misses, rather than trusting only the
1457
+ `Intl` list.
1458
+
1459
+ ## Adapter mapping notes
1460
+
1461
+ - `parse(formatStr, input, options)` takes `(formatStr, input)` —
1462
+ reversed from the fixture's `pattern`/`input` field order.
1463
+ - temporal-fmt does have a typed error hierarchy (`TemporalFmtError`
1464
+ and its subclasses in `src/errors.ts`), but the fixture's three
1465
+ `expect.throws` values (`"ParseError"`, `"FormatError"`,
1466
+ `"InvalidPatternError"`) don't map cleanly onto temporal-fmt's
1467
+ dozen-plus subclasses, so the adapter doesn't try — it just checks
1468
+ that something extending `Error` was thrown.
1469
+ - `target` in the fixture is informational only. temporal-fmt's
1470
+ `parse()` infers the result shape from which fields the pattern
1471
+ captures, so the adapter never passes `target` as an input.
1001
1472
 
1002
1473
  ## Contributing
1003
1474