@fedify/uri-template 2.0.0-pr.475.1 → 2.3.0-dev.1145

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/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright 2024–2025 Hong Minhee
3
+ Copyright 2024–2026 Hong Minhee
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy of
6
6
  this software and associated documentation files (the "Software"), to deal in
package/README.md CHANGED
@@ -1,78 +1,284 @@
1
1
  <!-- deno-fmt-ignore-file -->
2
2
 
3
- @fedify/uri-template: RFC 6570 URI Template implementation
4
- ===========================================================
3
+ @fedify/uri-template: Round-trip RFC 6570 URI Template library
4
+ ==============================================================
5
5
 
6
6
  [![JSR][JSR badge]][JSR]
7
7
  [![npm][npm badge]][npm]
8
8
 
9
- This package provides [RFC 6570] fully compliant URI template expansion and
10
- pattern matching library. Supports symmetric matching where
11
- `expand(match(url))` and `match(expand(vars))` behave predictably.
9
+ This package provides an [RFC 6570] URI Template implementation that performs
10
+ both expansion and pattern matching with round-trip verification. It is part of
11
+ the [Fedify] framework but can be used independently.
12
12
 
13
- [JSR]: https://jsr.io/@fedify/uri-template
14
13
  [JSR badge]: https://jsr.io/badges/@fedify/uri-template
15
- [npm]: https://www.npmjs.com/package/@fedify/uri-template
14
+ [JSR]: https://jsr.io/@fedify/uri-template
16
15
  [npm badge]: https://img.shields.io/npm/v/@fedify/uri-template?logo=npm
16
+ [npm]: https://www.npmjs.com/package/@fedify/uri-template
17
17
  [RFC 6570]: https://datatracker.ietf.org/doc/html/rfc6570
18
+ [Fedify]: https://fedify.dev/
19
+
20
+
21
+ Why `@fedify/uri-template`?
22
+ ---------------------------
23
+
24
+ Fedify previously relied on two independent third-party implementations:
25
+ [url-template] for URI Template expansion and [uri-template-router] for
26
+ route matching. `@fedify/uri-template` replaces both with one strict RFC 6570
27
+ parser and one expansion/matching model.
28
+
29
+ [url-template]: https://www.npmjs.com/package/url-template
30
+ [uri-template-router]: https://www.npmjs.com/package/uri-template-router
31
+
32
+ ### Why replacing [url-template] with `Template`?
33
+
34
+ [url-template] describes itself as an RFC 6570 implementation, but its behavior
35
+ is not strict enough for Fedify's URI routing and round-trip matching needs.
36
+ The test in *old/url-template.test.ts* records the differences against
37
+ `npm:url-template@^3.1.1`.
38
+
39
+ The important failures are:
40
+
41
+ - It double-encodes pct-encoded triplets in variable names when named
42
+ operators emit the variable name. For example, `{?abc%20def}` expands to
43
+ `?abc%2520def=spaced` instead of `?abc%20def=spaced`. [RFC 6570 §2.3]
44
+ allows `pct-encoded` inside `varname` and treats it as part of the
45
+ variable name. [RFC 6570 §3.2.8] emits the variable name as a literal
46
+ string, and [RFC 6570 §2.1] permits `pct-encoded` literals. Therefore
47
+ `%20` and `%41` must be preserved, not encoded again as `%2520` and
48
+ `%2541`.
49
+ - It accepts malformed templates instead of reporting syntax errors.
50
+ [RFC 6570 §2] requires expressions to be delimited by matching braces,
51
+ [RFC 6570 §2.1] excludes raw braces, control characters, spaces, raw `%`
52
+ outside a pct-encoded triplet, and other forbidden literal characters, and
53
+ [RFC 6570 §3] says grammar errors should indicate their location and type
54
+ to the invoking application. `@fedify/uri-template` reports these cases as
55
+ typed errors.
56
+ - It applies prefix modifiers to composite values such as lists and
57
+ associative arrays. [RFC 6570 §2.4.1] states that prefix modifiers are not
58
+ applicable to variables with composite values, so `{list:3}`, `{keys:3}`,
59
+ and `{count:2}` must fail.
60
+
61
+ `Template` was written as a new implementation instead of wrapping
62
+ [url-template] because Fedify needs strict RFC 6570 expansion, typed syntax
63
+ errors, and round-trip-checked matching behavior. Applications that need a
64
+ looser parser can opt in explicitly: `strict: false` passes parse and expansion
65
+ errors to `report` without throwing, and a custom `report` function can allow
66
+ all errors or throw only for selected error classes.
67
+
68
+ [RFC 6570 §2.3]: https://datatracker.ietf.org/doc/html/rfc6570#section-2.3
69
+ [RFC 6570 §3.2.8]: https://datatracker.ietf.org/doc/html/rfc6570#section-3.2.8
70
+ [RFC 6570 §2.1]: https://datatracker.ietf.org/doc/html/rfc6570#section-2.1
71
+ [RFC 6570 §2]: https://datatracker.ietf.org/doc/html/rfc6570#section-2
72
+ [RFC 6570 §3]: https://datatracker.ietf.org/doc/html/rfc6570#section-3
73
+ [RFC 6570 §2.4.1]: https://datatracker.ietf.org/doc/html/rfc6570#section-2.4.1
74
+
75
+ ### Why replacing [uri-template-router] with `Router`?
76
+
77
+ The previous router shape combined two independent third-party
78
+ implementations: [url-template] for building URLs and [uri-template-router] for
79
+ matching URLs. *old/uri-template-router.test.ts* defines that old shape as
80
+ closely as possible so the differences are visible under the same route API.
81
+
82
+ The important differences are:
83
+
84
+ - Build, match, and variable extraction all use the same strict RFC 6570
85
+ parser. The previous router expanded with [url-template] but matched with
86
+ [uri-template-router], so a value could be encoded by one implementation
87
+ and decoded by another with different rules.
88
+ - Route matches are round-trip checked. A candidate route is accepted only
89
+ when the recovered values expand back to the exact input URI. This rejects
90
+ matches that look plausible after decoding but cannot reproduce the
91
+ original URI. By default, matching is exact against the input URI.
92
+ `trailingSlashInsensitive` can be enabled to use a looser path lookup for
93
+ trailing slash differences before applying the same round-trip check.
94
+ - Pct-encoded triplets are preserved where RFC 6570 treats them as syntax.
95
+ Literal triplets, pct-encoded variable names, and named query parameters
96
+ such as `{?abc%20def}` remain `%20` instead of becoming `%2520`.
97
+ - Reserved expansion values keep their encoded form when that is what the
98
+ URI contained. Under the previous matching path, `/files/a%2Fb` could be
99
+ reported as `a/b`, `/files/%30%23` as `0#`, and pct-encoded UTF-8 octets as
100
+ Unicode text. Those values do not round-trip to the original URI under the
101
+ same template.
102
+ - Path templates are validated by `Router.compile()` before registration.
103
+ The standalone router accepts ordinary slash-prefixed paths and the
104
+ leading path-expansion form—a template that begins with a `{/var}`
105
+ expression, such as `{/identifier}/inbox`. Accepting that shape is a
106
+ standalone-router capability and is independent of Fedify: Fedify's own
107
+ dispatcher routes apply a non-empty constraint to required identifiers
108
+ (`nullable` defaults to `false`), so a leading path-expansion route
109
+ registers but only matches when the variable is actually bound, and the
110
+ Fedify builder may reject such a shape for routes whose callback
111
+ contract requires a concrete `identifier`.
112
+ - `Router.variables()` and `Router.compile()` expose variable extraction
113
+ without mutating a router. The legacy `Router.add()` returned variables as
114
+ a side effect of registering the route.
115
+ - Candidate lookup combines a token-level state trie with a fallback prefix
116
+ trie. Indexable path templates—those whose expressions each hold a single
117
+ variable with the `""`, `/`, or `+` operator and never sit directly
118
+ adjacent to another expression—are walked token by token in the state
119
+ trie. Shapes that cannot be safely indexed fall back to a prefix trie
120
+ keyed by the initial literal prefix of each route. Candidates from both
121
+ tries are merged, deduplicated, and ordered deterministically by literal
122
+ length, initial literal prefix length, variable count, and insertion order
123
+ before the round-trip matcher runs.
124
+ - Cloning and route replacement do not depend on copying private mutable
125
+ state from [uri-template-router]. The router stores compiled templates and
126
+ active route entries directly, which keeps the implementation independent
127
+ and dependency-free at runtime.
128
+
129
+ The concrete differences from the previous [url-template] and
130
+ [uri-template-router] libraries are encoded as repository-only compatibility
131
+ tests under *packages/uri-template/old/* in the package's source repository.
132
+ Those tests intentionally fail when run with `deno task test:old` because they
133
+ execute the older libraries against Fedify's expected behavior and document the
134
+ known legacy gaps.
18
135
 
19
136
 
20
137
  Features
21
138
  --------
22
139
 
23
- - **Full RFC 6570 Level 4 support**: Handles all operators and modifiers
24
- (explode `*`, prefix `:n`)
25
- - **Symmetric pattern matching**:
26
- - `opaque`: byte-for-byte exact round-trips
27
- - `cooked`: human-readable decoded values
28
- - `lossless`: preserves both raw and decoded forms
29
- - **Strict percent-encoding validation**: Prevents malformed sequences
30
- (`%GZ`, etc.)
31
- - **Deterministic expansion**: Correctly handles undefined/empty values per
32
- RFC rules
140
+ - Full RFC 6570 expansion for all expression types
141
+ (`{var}`, `{+var}`, `{#var}`, `{.var}`, `{/var}`, `{;var}`, `{?var}`,
142
+ `{&var}`)
143
+ - Round-trip pattern matching that mirrors expansion: when `match(uri)`
144
+ returns values, `expand(values) === uri`
145
+ - Per-variable matching constraints (`nullable`, `multiple`) with safe
146
+ defaults
147
+ - Strict TypeScript types with no `any` in the public surface
148
+ - Zero runtime dependencies
33
149
 
34
150
 
35
- Installation
36
- ------------
151
+ Route variable constraints
152
+ --------------------------
37
153
 
38
- ~~~~ sh
39
- deno add jsr:@fedify/uri-template # Deno
40
- npm add @fedify/uri-template # npm
41
- pnpm add @fedify/uri-template # pnpm
42
- yarn add @fedify/uri-template # Yarn
43
- bun add @fedify/uri-template # Bun
154
+ `Router` registers every RFC 6570 operator, but matching is constrained
155
+ per template variable. `Router.add()`, `Router.register()`, the
156
+ constructor, and `Router.from()` accept the route as a
157
+ `[pathOrPattern, name, options?]` tuple, where the optional third element
158
+ is the per-route options object:
159
+
160
+ ~~~~ typescript
161
+ import { Router } from "@fedify/uri-template";
162
+
163
+ const router = new Router();
164
+ router.add("/users/{identifier}", "actor");
165
+ router.add("/search{?q}", "search", {
166
+ variables: { q: { nullable: true } },
167
+ });
44
168
  ~~~~
45
169
 
170
+ `options.variables` maps a variable name to a partial constraint; any
171
+ field you omit falls back to its default, and any template variable you
172
+ do not list is still constrained with the all-default constraint. The
173
+ constraint fields are:
46
174
 
47
- Usage
48
- -----
175
+ - **`nullable`** defaults to `false`: a variable that is unbound or binds
176
+ to an empty value makes the route a no-match (the router falls back to
177
+ the next candidate). Pass `{ nullable: true }` to opt out, so an
178
+ optional operator such as `{?q}` may match with `q` absent. Because
179
+ of this default, optional-operator and leading-path-expansion routes
180
+ register successfully but only match when the variable is actually
181
+ present and non-empty.
182
+ - **`multiple`** is derived from the variable specification: explode
183
+ (`{tags*}`) implies `true` and binds `readonly string[]`; a prefix
184
+ modifier (`{id:3}`) implies `false`; a plain variable defaults to
185
+ `false` (binding `string`) but may be set either way. Specifying a
186
+ `multiple` that contradicts the derived value, or using the same
187
+ variable name with conflicting explode/prefix modifiers (`{x}` and
188
+ `{x*}`), throws `ConflictingVarSpecError` at registration time.
189
+ - **`duplicable`** defaults to `false`: a variable that appears in more
190
+ than one variable specification within the same template throws
191
+ `DuplicateRouteVariableError` at registration time. Set it to `true`
192
+ to allow repeated occurrences; their bindings must still agree when a
193
+ URI is matched.
194
+ - **`prefixable`** defaults to `false`: a `{var:N}` prefix-modifier
195
+ specification throws `DisallowedVarSpecModifierError` unless the
196
+ variable is marked `{ prefixable: true }`.
197
+ - **`explodable`** defaults to `false`: it is a *registration
198
+ permission*, not an output-shape declaration. A `{var*}`
199
+ explode-modifier specification throws `DisallowedVarSpecModifierError`
200
+ unless the variable is marked `{ explodable: true }`; the option by
201
+ itself does not turn a value into a list. A value becomes a
202
+ `readonly string[]` only when the template actually uses the explode
203
+ modifier (`{var*}`), because that varspec is what resolves `multiple`
204
+ to `true`. The same `{ explodable: true }` set on a non-exploded spec
205
+ such as `/users/{id}` still binds a scalar `string` at runtime.
206
+ - **`operatables`** defaults to `[]`, which permits every operator.
207
+ When set to a non-empty list of operators (`""`, `"+"`, `"#"`, `"."`,
208
+ `"/"`, `";"`, `"?"`, `"&"`), using the variable under any operator
209
+ outside the list throws `DisallowedOperatorError` at registration
210
+ time.
211
+
212
+ The options object also accepts **`exact`**, which defaults to `true`:
213
+ when you supply a `variables` object, its keys must match the template's
214
+ variables exactly—every template variable must be listed and no unknown
215
+ key may appear, otherwise registration throws
216
+ `RouteTemplateOptionsNotMatchedError`. Set `{ exact: false }` to relax
217
+ this so unlisted variables keep their defaults and unknown keys are
218
+ ignored. Routes registered without a `variables` object are unaffected
219
+ and keep every default.
49
220
 
50
221
  ~~~~ typescript
51
- import { compile } from "@fedify/uri-template";
222
+ const router = new Router();
52
223
 
53
- const tmpl = compile("/repos{/owner,repo}{?q,lang}");
224
+ // Throws RouteTemplateOptionsNotMatchedError: `id` is not listed.
225
+ router.add("/posts/{slug}/{id}", "post", {
226
+ variables: { slug: { nullable: true } },
227
+ });
54
228
 
55
- // Expansion
56
- const url = tmpl.expand({ owner: "foo", repo: "hello/world", q: "a b" });
57
- // => "/repos/foo/hello%2Fworld?q=a%20b"
229
+ // OK: opt out of the exact-keys check.
230
+ router.add("/posts/{slug}/{id}", "post", {
231
+ exact: false,
232
+ variables: { slug: { nullable: true } },
233
+ });
58
234
 
59
- // Matching
60
- const result = tmpl.match("/repos/foo/hello%2Fworld?q=a%20b", {
61
- encoding: "cooked"
235
+ // OK: explode requires opting in.
236
+ router.add("/tags{/tags*}", "tags", {
237
+ variables: { tags: { explodable: true } },
62
238
  });
63
- // => { owner: "foo", repo: "hello/world", q: "a b" }
64
239
  ~~~~
65
240
 
66
- **Matching options:**
241
+ `Router.route()` is generic over the constraint map, so `values` narrows
242
+ accordingly:
243
+
244
+ ~~~~ typescript
245
+ const constraints = {
246
+ identifier: { nullable: false, multiple: false },
247
+ } as const;
248
+ router.add("/users/{identifier}", "actor", { variables: constraints });
249
+
250
+ const matched = router.route<typeof constraints>("/users/alice");
251
+ if (matched != null) {
252
+ const id: string = matched.values.identifier;
253
+ }
254
+ ~~~~
255
+
256
+ The narrowed type is derived from `multiple` and `nullable` only, never
257
+ from `explodable`. Since `explodable` governs registration rather than
258
+ the resolved value shape, an exploded route must carry `multiple: true`
259
+ in the type argument—not merely `explodable: true`—for `values` to narrow
260
+ to `readonly string[]`:
67
261
 
68
- - `encoding`: `"opaque"` (default, preserves raw) | `"cooked"` (decoded) |
69
- `"lossless"` (both)
70
- - `strict`: `true` (default, strict) | `false` (lenient parsing)
262
+ ~~~~ typescript
263
+ const tagConstraints = {
264
+ tags: { explodable: true, multiple: true },
265
+ } as const;
266
+ router.add("/tags{?tags*}", "tags", { variables: tagConstraints });
71
267
 
268
+ const matched = router.route<typeof tagConstraints>("/tags?tags=a&tags=b");
269
+ if (matched != null) {
270
+ const tags: readonly string[] = matched.values.tags;
271
+ }
272
+ ~~~~
72
273
 
73
- Documentation
74
- -------------
75
274
 
76
- For detailed implementation details, see [*specification.md*].
275
+ Installation
276
+ ------------
77
277
 
78
- [*specification.md*]: ./docs/specification.md
278
+ ~~~~ bash
279
+ deno add jsr:@fedify/uri-template # Deno
280
+ npm add @fedify/uri-template # npm
281
+ pnpm add @fedify/uri-template # pnpm
282
+ yarn add @fedify/uri-template # Yarn
283
+ bun add @fedify/uri-template # Bun
284
+ ~~~~