@lesto/router 0.1.0

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/src/index.ts ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * @lesto/router — the route-matching substrate for Lesto's code-first `lesto()` app.
3
+ *
4
+ * const table = new RouteTable<Handler>();
5
+ * table.add("GET", "/posts/:id", handler);
6
+ * table.match("GET", "/posts/3"); // { value: handler, params: { id: "3" } }
7
+ *
8
+ * Captured params are URL-decoded at match time, so `/posts/a%2Fb` binds the one
9
+ * param `"a/b"` (a `%2F` never smuggles a path separator) and a malformed `%`
10
+ * refuses with a coded `RouterError` the web tier maps to a 400 (see `RouteTable`).
11
+ * `pathFor` is the inverse — it encodes params back into a path that round-trips.
12
+ *
13
+ * The pattern compiler (`compile`) and type-level param inference (`ParamKeys` /
14
+ * `PathParams`) give `lesto()` handlers their `c.param(...)` keys with no codegen.
15
+ */
16
+
17
+ // The generic matcher the `lesto()` builder dispatches over, plus the shared
18
+ // pattern compiler and the type-level param inference that gives handlers their
19
+ // `c.param(...)` keys with no codegen.
20
+ export { pathFor, RouteTable } from "./table";
21
+ export type { Match } from "./table";
22
+ export { compile, escapeRegExp, PARAM_SEGMENT } from "./compile";
23
+ export type { CompiledPattern } from "./compile";
24
+ export type { CatchAllParamKeys, ParamKeys, PathParams, SingleParamKeys } from "./params";
25
+
26
+ // The file-based routing convention: scan a conventional dir (`app/`) into ordered
27
+ // route descriptors that compile to the same `:param` patterns above, so a
28
+ // file-route and a hand-written route share one router (the applier lives in
29
+ // `@lesto/web`'s `applyFileRoutes`, over these descriptors).
30
+ export {
31
+ BOUNDARY_KINDS,
32
+ compileFileRoutes,
33
+ dirKey,
34
+ NEAREST_BOUNDARY_KINDS,
35
+ ROUTE_FILE_NAMES,
36
+ } from "./file-routes";
37
+ export type {
38
+ BoundaryDepths,
39
+ BoundaryKind,
40
+ DiscoveredFile,
41
+ FileRoute,
42
+ FileRouteKind,
43
+ NearestBoundaryKind,
44
+ } from "./file-routes";
45
+ export { scanRoutes } from "./scan";
46
+ export type { DirEntry, DirReader } from "./scan";
47
+
48
+ export { LestoError, RouterError } from "./errors";
49
+ export type { RouterErrorCode } from "./errors";
package/src/params.ts ADDED
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Path params, inferred from the pattern at the type level.
3
+ *
4
+ * A route's `:param` segments are not just runtime captures — the compiler can
5
+ * read them straight off the pattern string. `ParamKeys<"/listings/:id">` is the
6
+ * literal union `"id"`; `PathParams<…>` lifts that union into the record a
7
+ * handler receives. This is the spine of Lesto's end-to-end typing: a handler for
8
+ * `"/posts/:postId/comments/:id"` knows, with no annotation and no codegen, that
9
+ * `c.param("postId")` and `c.param("id")` are the only valid keys.
10
+ *
11
+ * Purely type-level — these emit no JavaScript. The runtime captures still flow
12
+ * through {@link RouteTable}; these types only describe their shape.
13
+ */
14
+
15
+ /**
16
+ * The name a `:param` captures, stopping at a literal that follows it in the same
17
+ * segment.
18
+ *
19
+ * The runtime compiler captures a name as `[A-Za-z_][A-Za-z0-9_]*` (see
20
+ * `PARAM_SEGMENT`), so a pattern like `/files/:name.json` binds the param `name`
21
+ * and matches `.json` literally. A naive split-on-`/` would infer `name.json` and
22
+ * steer `c.param(...)` to a key that does not exist at runtime. This peels the
23
+ * trailing literal at the `.`/`-` separators a path segment uses, so the type
24
+ * agrees with the captured key.
25
+ */
26
+ type ParamName<Raw extends string> = Raw extends `${infer Name}.${string}`
27
+ ? ParamName<Name>
28
+ : Raw extends `${infer Name}-${string}`
29
+ ? ParamName<Name>
30
+ : Raw;
31
+
32
+ /**
33
+ * The union of single-segment `:param` names in a path pattern.
34
+ *
35
+ * Walks the literal: a `:name/` prefix yields `name` and recurses on the rest; a
36
+ * trailing `:name` yields the final name; anything without a `:` yields `never`
37
+ * (a static path has no single params). A name stops at the next `/` — or at a
38
+ * `.`/`-` literal within the segment — mirroring the identifier the runtime
39
+ * captures. Catch-alls (`*name`) are read separately by {@link CatchAllParamKeys},
40
+ * since they carry a different VALUE type.
41
+ */
42
+ export type SingleParamKeys<Path extends string> =
43
+ Path extends `${string}:${infer Param}/${infer Rest}`
44
+ ? ParamName<Param> | SingleParamKeys<`/${Rest}`>
45
+ : Path extends `${string}:${infer Param}`
46
+ ? ParamName<Param>
47
+ : never;
48
+
49
+ /**
50
+ * The catch-all name in a path pattern — the `name` of a `*name` / `*name?`, or
51
+ * `never` when there is none. A catch-all is always the FINAL token (the matcher
52
+ * refuses any other position), so it is read straight off the tail: the optional
53
+ * `*name?` form is matched first so the trailing `?` is a literal, not part of the
54
+ * captured name. Its value is a `string[]` (the run of segments it captured),
55
+ * which is why it is tracked apart from the single `:param` names.
56
+ *
57
+ * @example
58
+ * type A = CatchAllParamKeys<"/docs/*rest">; // "rest"
59
+ * type B = CatchAllParamKeys<"/docs/*rest?">; // "rest"
60
+ * type C = CatchAllParamKeys<"/listings/:id">; // never
61
+ */
62
+ export type CatchAllParamKeys<Path extends string> = Path extends `${string}*${infer Name}?`
63
+ ? Name
64
+ : Path extends `${string}*${infer Name}`
65
+ ? Name
66
+ : never;
67
+
68
+ /**
69
+ * The union of param names in a path pattern — single `:param` AND `*catchAll`
70
+ * names alike. The spine of `c.param(...)` key-checking; {@link PathParams} adds
71
+ * the per-name value type on top.
72
+ *
73
+ * @example
74
+ * type A = ParamKeys<"/listings/:id">; // "id"
75
+ * type B = ParamKeys<"/posts/:postId/c/:id">; // "postId" | "id"
76
+ * type C = ParamKeys<"/files/:name.json">; // "name"
77
+ * type D = ParamKeys<"/docs/*rest">; // "rest"
78
+ * type E = ParamKeys<"/about">; // never
79
+ */
80
+ export type ParamKeys<Path extends string> = SingleParamKeys<Path> | CatchAllParamKeys<Path>;
81
+
82
+ /**
83
+ * The record of path params for a pattern: each single `:param` mapped to `string`,
84
+ * each `*catchAll` mapped to `string[]`.
85
+ *
86
+ * Every single-segment capture is a `string` at runtime (the router never
87
+ * coerces); a catch-all capture is split on `/` into the `string[]` run of
88
+ * segments it matched (`[]` for an optional catch-all that matched none). A static
89
+ * path produces `{}` — no keys, nothing to read.
90
+ *
91
+ * @example
92
+ * type P = PathParams<"/listings/:id">; // { id: string }
93
+ * type Q = PathParams<"/docs/*rest">; // { rest: string[] }
94
+ * type R = PathParams<"/u/:id/*rest">; // { id: string; rest: string[] }
95
+ */
96
+ export type PathParams<Path extends string> = {
97
+ [Key in ParamKeys<Path>]: Key extends CatchAllParamKeys<Path> ? string[] : string;
98
+ };
package/src/scan.ts ADDED
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Walk a convention directory into the flat {@link DiscoveredFile} list the
3
+ * compiler takes — over an INJECTED reader, so the walk itself is pure logic.
4
+ *
5
+ * The reader is the one impure seam: `readDir(path)` lists a directory's entries
6
+ * (each a name + whether it is a directory). Everything else — descending the
7
+ * tree, recognizing `page`/`layout` by name, accumulating segments — is
8
+ * deterministic over what the reader returns, so the whole scan is exercised under
9
+ * a fake in-memory reader with no filesystem. The bin wires a real reader
10
+ * (`fs.readdir(..., { withFileTypes: true })`); a test hands a literal tree.
11
+ *
12
+ * Only the convention's recognized files (`page`, `layout`, `middleware`, and the
13
+ * `loading` / `error` / `not-found` boundaries — any recognized extension) are
14
+ * surfaced; every other file is ignored, so a co-located component, test, or
15
+ * stylesheet under the convention dir is never mistaken for a route. The EXTENSION is irrelevant
16
+ * to the convention — `page.tsx`, `page.ts`, `page.jsx`, `page.js` all name the
17
+ * directory's page — because the impure loader (the bin) is what resolves a
18
+ * concrete module path; the scan only needs the base name to classify the kind.
19
+ */
20
+
21
+ import type { DiscoveredFile, FileRouteKind } from "./file-routes";
22
+ import { ROUTE_FILE_NAMES } from "./file-routes";
23
+
24
+ /** One entry the reader yields for a directory: its name and whether it is a subdir. */
25
+ export interface DirEntry {
26
+ name: string;
27
+
28
+ isDirectory: boolean;
29
+ }
30
+
31
+ /**
32
+ * List a directory's immediate entries. The single impure seam of the scan — the
33
+ * bin wires `fs.readdir(path, { withFileTypes: true })`, a test a literal map.
34
+ * Async because a real filesystem read is.
35
+ */
36
+ export type DirReader = (path: string) => Promise<ReadonlyArray<DirEntry>>;
37
+
38
+ /**
39
+ * The base name of a route file is `<kind>.<ext>` — `page.tsx`, `layout.ts`,
40
+ * `middleware.ts`, `not-found.tsx`. We split on the FIRST dot so a name like
41
+ * `page.test.tsx` classifies as `page` only if its base is exactly `page`; a
42
+ * co-located `page.helper.tsx` has base `page` too, so we additionally require the
43
+ * name to be a recognized kind (`page`/`layout`/`middleware`/`loading`/`error`/
44
+ * `not-found`) followed by a SINGLE extension. (A boundary's hyphen is part of its
45
+ * base — `not-found` is one
46
+ * base name, not a dot-delimited compound.) This pulls the base name (everything
47
+ * before the first dot) and the remainder so the classifier can demand a single
48
+ * extension segment.
49
+ */
50
+ function baseAndExt(name: string): { base: string; rest: string } {
51
+ const dot = name.indexOf(".");
52
+
53
+ // A dotfile or extension-less name has no kind we recognize; `rest` empty marks it.
54
+ if (dot <= 0) return { base: name, rest: "" };
55
+
56
+ return { base: name.slice(0, dot), rest: name.slice(dot + 1) };
57
+ }
58
+
59
+ /**
60
+ * Classify a file name as a route kind, or `undefined` if it is not one.
61
+ *
62
+ * A route file is exactly `page.<ext>` or `layout.<ext>` with a SINGLE extension
63
+ * segment (no inner dot), so `page.tsx` and `layout.js` count while `page.test.tsx`,
64
+ * `page.module.css`, and a bare `page` do not — a test or a co-located stylesheet
65
+ * beside a real page is never mistaken for a second route file. The extension's
66
+ * value does not matter (the loader resolves the concrete path); only that there
67
+ * is exactly one.
68
+ */
69
+ function kindOf(name: string): FileRouteKind | undefined {
70
+ const { base, rest } = baseAndExt(name);
71
+
72
+ const kind = ROUTE_FILE_NAMES[base];
73
+
74
+ if (kind === undefined) return undefined;
75
+
76
+ // Exactly one extension segment: a `rest` with its own dot (`test.tsx`,
77
+ // `module.css`) is a co-located file, not the route file.
78
+ if (rest === "" || rest.includes(".")) return undefined;
79
+
80
+ return kind;
81
+ }
82
+
83
+ /**
84
+ * Scan a convention directory into the flat {@link DiscoveredFile} list the
85
+ * compiler takes, descending every subdirectory.
86
+ *
87
+ * `root` is the convention dir's own path (`app/`); the yielded segments are
88
+ * relative to it, so the root's own `page`/`layout` arrive with an empty segment
89
+ * chain and `app/listings/[id]/page.tsx` arrives as `segments: ["listings",
90
+ * "[id]"]`. The walk is breadth-stable: a directory's own route files are recorded
91
+ * before its children are descended, but the compiler re-orders for resolution, so
92
+ * the discovery order here only needs to be COMPLETE, not sorted.
93
+ *
94
+ * A `[param]` directory is descended like any other — it is just a segment whose
95
+ * name happens to compile to `:param` later; the scan stays oblivious to dynamic
96
+ * vs static, which keeps the one place that distinction matters in the compiler.
97
+ */
98
+ export async function scanRoutes(
99
+ readDir: DirReader,
100
+ root: string,
101
+ ): Promise<ReadonlyArray<DiscoveredFile>> {
102
+ const found: DiscoveredFile[] = [];
103
+
104
+ // Descend `dir` (whose path is `path`, whose segments-from-root are `segments`),
105
+ // recording its route files and recursing into its subdirectories.
106
+ const walk = async (path: string, segments: ReadonlyArray<string>): Promise<void> => {
107
+ const entries = await readDir(path);
108
+
109
+ // Record this directory's own page/layout files first, then descend — order
110
+ // within `found` is immaterial (the compiler sorts), so a simple two-pass over
111
+ // the entries keeps the walk readable.
112
+ for (const entry of entries) {
113
+ if (entry.isDirectory) continue;
114
+
115
+ const kind = kindOf(entry.name);
116
+
117
+ if (kind !== undefined) {
118
+ found.push({ kind, segments });
119
+ }
120
+ }
121
+
122
+ // The subdirectories, walked in turn — each adds one raw segment (its name).
123
+ const subdirs = entries.filter((entry) => entry.isDirectory);
124
+
125
+ await Promise.all(
126
+ subdirs.map((entry) => walk(joinPath(path, entry.name), [...segments, entry.name])),
127
+ );
128
+ };
129
+
130
+ await walk(root, []);
131
+
132
+ return found;
133
+ }
134
+
135
+ /**
136
+ * Join a directory path and a child name with a single `/`. The scan never builds
137
+ * absolute paths — it only re-feeds the result to the reader — so a plain slash
138
+ * join is enough, and it keeps the (already-pure) walk free of a `node:path`
139
+ * import that would tie it to one runtime.
140
+ */
141
+ function joinPath(dir: string, name: string): string {
142
+ return dir.endsWith("/") ? `${dir}${name}` : `${dir}/${name}`;
143
+ }
package/src/table.ts ADDED
@@ -0,0 +1,242 @@
1
+ /**
2
+ * A generic route table: method + path pattern → a value of the caller's choice.
3
+ *
4
+ * Where the legacy {@link Router} hard-codes its value to a `"controller#action"`
5
+ * string, `RouteTable<T>` is agnostic — the `lesto()` builder stores whatever it
6
+ * needs to run a route (a handler chain, a page definition) as the value, and the
7
+ * table only owns matching: compile each pattern once, then resolve a request to
8
+ * the first route whose verb and path both match, handing back the value and the
9
+ * captured params.
10
+ *
11
+ * Insertion order is resolution order: the first matching route wins, so a more
12
+ * specific pattern declared earlier shadows a broader one declared later. Pure
13
+ * over plain strings — no socket, no handler invocation — so every matching edge
14
+ * is unit-testable in isolation.
15
+ *
16
+ * ## Captured params are URL-decoded at match time (BREAKING, Wave 5)
17
+ *
18
+ * Matching runs against the *encoded* path, then each capture is
19
+ * `decodeURIComponent`-d before it reaches the caller. Two consequences a handler
20
+ * can now rely on:
21
+ *
22
+ * - A percent-encoded separator never smuggles a segment. The pattern's `[^/]+`
23
+ * capture matches `%2F` as ONE segment, so `/files/a%2Fb` binds the single
24
+ * param `"a/b"` — it does NOT split into two segments or match a two-segment
25
+ * pattern. Decoding happens *after* the segment boundary is fixed, so the
26
+ * route shape is decided on the wire form and the slash can never be forged
27
+ * into the path structure. (`%2e%2e` likewise decodes to the literal `".."`
28
+ * value — a string the handler sees, not a path operator the router honors.)
29
+ * - Unicode is real text. `/u/%E2%9C%93` binds `"✓"`, not its bytes.
30
+ *
31
+ * A param that is not a well-formed percent-encoding (a stray `%`, `%zz`, a
32
+ * truncated `%E2`) is a client-malformed request, not a server fault: the decode
33
+ * refuses with a coded {@link RouterError} (`ROUTER_MALFORMED_PARAM`) so the web
34
+ * tier maps it to a 400 instead of letting `decodeURIComponent`'s bare `URIError`
35
+ * escape as a 500. The reverse of this — building an encoded path from decoded
36
+ * params — is {@link pathFor}, which round-trips: `match(pathFor(p, v)).params`
37
+ * recovers `v`.
38
+ */
39
+
40
+ import { compile, PARAM_TOKEN } from "./compile";
41
+ import { RouterError } from "./errors";
42
+
43
+ /**
44
+ * Decode one captured segment, turning a malformed percent-sequence into a coded
45
+ * refusal instead of a bare `URIError`.
46
+ *
47
+ * `decodeURIComponent` throws a plain `URIError` on a stray or truncated `%`; left
48
+ * unhandled that surfaces as an opaque 500 for what is really a bad request. We
49
+ * catch exactly that case and re-raise a {@link RouterError} the web tier can map
50
+ * to a 400 by code (`ROUTER_MALFORMED_PARAM`).
51
+ */
52
+ const decodeParam = (paramName: string, raw: string): string => {
53
+ try {
54
+ return decodeURIComponent(raw);
55
+ } catch {
56
+ throw new RouterError(
57
+ "ROUTER_MALFORMED_PARAM",
58
+ `Route param "${paramName}" is not a valid percent-encoding: "${raw}".`,
59
+ { param: paramName, raw },
60
+ );
61
+ }
62
+ };
63
+
64
+ /** A compiled entry: its verb, the source pattern (for inspection), and the matcher. */
65
+ interface Entry<T> {
66
+ method: string;
67
+
68
+ pattern: string;
69
+
70
+ regExp: RegExp;
71
+
72
+ paramNames: ReadonlyArray<string>;
73
+
74
+ catchAllParams: ReadonlySet<string>;
75
+
76
+ value: T;
77
+ }
78
+
79
+ /**
80
+ * A successful match: the stored value and the params captured from the path.
81
+ *
82
+ * A single `:param` captures a `string`; a `*catchAll` captures the `string[]` run
83
+ * of segments it spanned (`[]` for an optional catch-all that matched none) — so a
84
+ * param value is `string | string[]`, narrowed per-key by `@lesto/router`'s
85
+ * `PathParams` wherever the pattern is known at the type level.
86
+ */
87
+ export interface Match<T> {
88
+ value: T;
89
+
90
+ params: Record<string, string | string[]>;
91
+ }
92
+
93
+ export class RouteTable<T> {
94
+ // Insertion order is resolution order: the first matching route wins.
95
+ private readonly entries: Entry<T>[] = [];
96
+
97
+ /**
98
+ * Register a route. The pattern is compiled once, here, so a malformed or
99
+ * ambiguous pattern (see {@link compile}) fails at declaration, not at request.
100
+ */
101
+ add(method: string, pattern: string, value: T): this {
102
+ const { regExp, paramNames, catchAllParams } = compile(pattern);
103
+
104
+ this.entries.push({ method, pattern, regExp, paramNames, catchAllParams, value });
105
+
106
+ return this;
107
+ }
108
+
109
+ /**
110
+ * Find the route that answers this method + path.
111
+ *
112
+ * Returns the stored value and the extracted params, or `undefined` when
113
+ * nothing matches — either no pattern fits the path, or the matching pattern
114
+ * wants a different verb. The verb check is first and cheapest; the RegExp runs
115
+ * only for a method that could answer.
116
+ */
117
+ match(method: string, path: string): Match<T> | undefined {
118
+ for (const entry of this.entries) {
119
+ if (entry.method !== method) continue;
120
+
121
+ const matched = entry.regExp.exec(path);
122
+
123
+ if (matched === null) continue;
124
+
125
+ // A NULL-PROTOTYPE map: param names come from the pattern, but a `:constructor`
126
+ // / `:__proto__` / dynamically-built name must read back as a plain key, never
127
+ // an inherited `Object.prototype` member (a truthy `params["constructor"]` could
128
+ // slip past an `if (!params[name])` check) and never mutate the object's proto.
129
+ const params: Record<string, string | string[]> = Object.create(null) as Record<
130
+ string,
131
+ string | string[]
132
+ >;
133
+
134
+ entry.paramNames.forEach((paramName, index) => {
135
+ // Group 0 is the whole match; captures start at 1, aligned with paramNames.
136
+ // The capture is the on-the-wire (encoded) form — decode it here, after the
137
+ // segment boundary is already fixed, so `%2F` can never smuggle a `/` into the
138
+ // route shape. A malformed `%` becomes a coded 400, not a 500.
139
+ const raw = matched[index + 1] as string | undefined;
140
+
141
+ if (entry.catchAllParams.has(paramName)) {
142
+ // A catch-all captured a `/`-joined run of segments — split on the literal
143
+ // boundary FIRST, then decode each, so the array element count is fixed on
144
+ // the wire form (a `%2F` decodes to `/` WITHIN an element, never a new one).
145
+ // An optional catch-all that matched nothing has no capture (`undefined`) → [].
146
+ params[paramName] =
147
+ raw === undefined
148
+ ? []
149
+ : raw.split("/").map((segment) => decodeParam(paramName, segment));
150
+ } else {
151
+ params[paramName] = decodeParam(paramName, raw as string);
152
+ }
153
+ });
154
+
155
+ return { value: entry.value, params };
156
+ }
157
+
158
+ return undefined;
159
+ }
160
+
161
+ /** Every registered route's verb + pattern, in resolution order, for inspection. */
162
+ list(): ReadonlyArray<{ method: string; pattern: string }> {
163
+ return this.entries.map((entry) => ({ method: entry.method, pattern: entry.pattern }));
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Build a concrete path from a pattern by substituting and URL-encoding its params
169
+ * — the reverse of {@link RouteTable.match}, so a link never hardcodes a URL.
170
+ *
171
+ * Each `:param` is replaced by `encodeURIComponent` of its `string` value; each
172
+ * `*catchAll` by its `string[]` value's segments, each encoded and joined by `/`.
173
+ * That encoding is the exact inverse of the `decodeURIComponent` `match` applies,
174
+ * so the two round-trip: a value containing a `/` (or any unicode) survives the
175
+ * trip out and back unchanged — `pathFor("/files/:p", { p: "a/b" })` yields
176
+ * `/files/a%2Fb`, which `match` decodes back to `{ p: "a/b" }` as one segment;
177
+ * `pathFor("/docs/*r", { r: ["a", "b"] })` yields `/docs/a/b`, decoded back to the
178
+ * two-element array. An OPTIONAL catch-all given `[]` drops its whole segment —
179
+ * `pathFor("/shop/*r?", { r: [] })` is `/shop`, and the root `pathFor("/*r?", { r:
180
+ * [] })` is `/`.
181
+ *
182
+ * Throws a coded {@link RouterError} (`ROUTER_MISSING_PARAM`) if the pattern needs
183
+ * a param the caller did not supply, supplied empty, or supplied with the wrong
184
+ * shape: a `[^/]+` capture matches one-or-more chars, so an empty single value
185
+ * yields a path that can never route back (`/files/` misses `/files/:p`), and a
186
+ * required catch-all needs at least one segment. All wiring bugs caught here, not
187
+ * broken links shipped to a user.
188
+ */
189
+ export const pathFor = (
190
+ pattern: string,
191
+ params: Record<string, string | readonly string[]> = {},
192
+ ): string => {
193
+ const missing = (paramName: string): RouterError =>
194
+ new RouterError(
195
+ "ROUTER_MISSING_PARAM",
196
+ `Pattern "${pattern}" needs a non-empty "${paramName}" param.`,
197
+ { pattern, param: paramName },
198
+ );
199
+
200
+ let out = "";
201
+ let lastIndex = 0;
202
+
203
+ for (const match of pattern.matchAll(PARAM_TOKEN)) {
204
+ const between = pattern.slice(lastIndex, match.index);
205
+ const singleName = match[1];
206
+
207
+ if (singleName !== undefined) {
208
+ const value = params[singleName];
209
+
210
+ if (typeof value !== "string" || value === "") throw missing(singleName);
211
+
212
+ out += between + encodeURIComponent(value);
213
+ } else {
214
+ const name = match[2] as string;
215
+ const optional = match[3] === "?";
216
+ const value = params[name];
217
+
218
+ // A catch-all takes the `string[]` run of segments; a missing or `string` value
219
+ // is the wrong shape (and narrows `value` to the array for the rest of the arm).
220
+ if (value === undefined || typeof value === "string") throw missing(name);
221
+
222
+ if (value.length === 0) {
223
+ if (!optional) throw missing(name);
224
+
225
+ // An empty optional catch-all contributes no segment: drop the preceding
226
+ // slash so the bare prefix stands (`/shop/*r?` → `/shop`), except at the root
227
+ // where the leading `/` is the path itself (`/*r?` → `/`).
228
+ out += between === "/" ? "/" : between.slice(0, -1);
229
+ } else if (value.some((segment) => segment === "")) {
230
+ // An empty element would emit a `//` the match regex rejects — refuse it, so
231
+ // the documented round-trip holds rather than shipping an unroutable link.
232
+ throw missing(name);
233
+ } else {
234
+ out += between + value.map((segment) => encodeURIComponent(segment)).join("/");
235
+ }
236
+ }
237
+
238
+ lastIndex = match.index + match[0].length;
239
+ }
240
+
241
+ return out + pattern.slice(lastIndex);
242
+ };