paramour 0.0.0 → 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/dist/codec.d.ts +76 -0
- package/dist/codec.js +110 -0
- package/dist/describe.d.ts +63 -0
- package/dist/describe.js +75 -0
- package/dist/errors.d.ts +55 -0
- package/dist/errors.js +128 -0
- package/dist/href.d.ts +59 -0
- package/dist/href.js +16 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +9 -0
- package/dist/p.d.ts +23 -0
- package/dist/p.js +265 -0
- package/dist/path.d.ts +108 -0
- package/dist/path.js +439 -0
- package/dist/route.d.ts +253 -0
- package/dist/route.js +210 -0
- package/dist/safe-decode.d.ts +16 -0
- package/dist/safe-decode.js +42 -0
- package/dist/schema.d.ts +10 -0
- package/dist/schema.js +16 -0
- package/dist/search.d.ts +139 -0
- package/dist/search.js +472 -0
- package/package.json +29 -5
- package/README.md +0 -28
package/dist/path.js
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
import { ParamourError, ParamsDecodeError, ParseError, SerializeError, } from "./errors.js";
|
|
2
|
+
import { encodeComponent, readInputValue } from "./search.js";
|
|
3
|
+
// Anchored per the wire-format spec's regex ethos; name charset excludes
|
|
4
|
+
// brackets so nesting can't smuggle through. Match order mirrors the type
|
|
5
|
+
// grammar: `[[...` before `[...` before `[`.
|
|
6
|
+
const OPTIONAL_CATCHALL_TOKEN = /^\[\[\.\.\.([^\][]+)\]\]$/;
|
|
7
|
+
const CATCHALL_TOKEN = /^\[\.\.\.([^\][]+)\]$/;
|
|
8
|
+
const SINGLE_TOKEN = /^\[([^\][]+)\]$/;
|
|
9
|
+
const GROUP_SEGMENT = /^\(.*\)$/;
|
|
10
|
+
/**
|
|
11
|
+
* Builds the path portion of an href (RL5): `/` plus the encoded segments
|
|
12
|
+
* joined with `/`. R2's element joining falls out of the same join as
|
|
13
|
+
* everything else; a fully-elided path (an optional catch-all at the root)
|
|
14
|
+
* yields "/".
|
|
15
|
+
*/
|
|
16
|
+
export function buildPath(route, params) {
|
|
17
|
+
return `/${encodeParams(route, params).join("/")}`;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Decodes a params source against a route's codecs (RL7), the sync twin of
|
|
21
|
+
* `route.parseParams` — mirrors decodeSearch: per-key {@link Issue}
|
|
22
|
+
* aggregation into {@link ParamsDecodeError}. Shape validation is strict:
|
|
23
|
+
* `[id]` given an array, a catch-all given a string, a missing required key,
|
|
24
|
+
* or a non-string element are recorded issues, and NOT `.catch()`-recoverable
|
|
25
|
+
* — a shape mismatch means the props came from a route this definition
|
|
26
|
+
* doesn't describe. Unknown source keys are never read (P8's spirit; Next
|
|
27
|
+
* includes parent-layout params).
|
|
28
|
+
*/
|
|
29
|
+
export function decodeParams(route, source, options) {
|
|
30
|
+
// The TS contract forbids non-object sources, but plain-JS callers reach
|
|
31
|
+
// here; fail branded and loud — a bad source is a contract violation, not
|
|
32
|
+
// a per-key decode issue.
|
|
33
|
+
const untrusted = source;
|
|
34
|
+
if (typeof untrusted !== "object" || untrusted === null) {
|
|
35
|
+
throw new ParamourError(`params source must be an object, got ${untrusted === null ? "null" : typeof untrusted}`);
|
|
36
|
+
}
|
|
37
|
+
// R5: App-Router surfaces arrive percent-encoded (decode by default); pages
|
|
38
|
+
// surfaces arrive already-decoded and opt out via `{ percentDecode: false }`.
|
|
39
|
+
const percentDecode = options?.percentDecode ?? true;
|
|
40
|
+
const config = route["~params"];
|
|
41
|
+
const issues = [];
|
|
42
|
+
// Built as entries so keys like "__proto__" become ordinary own properties
|
|
43
|
+
// of the result (Object.fromEntries uses define, not set, semantics).
|
|
44
|
+
const entries = [];
|
|
45
|
+
for (const segment of routeSegments(route)) {
|
|
46
|
+
if (segment.kind === "static")
|
|
47
|
+
continue;
|
|
48
|
+
const codec = requireCodec(config, segment.name, route.path);
|
|
49
|
+
// Own properties only: unknown keys are never read, and inherited
|
|
50
|
+
// Object.prototype members must not count as present values.
|
|
51
|
+
const value = Object.hasOwn(source, segment.name)
|
|
52
|
+
? source[segment.name]
|
|
53
|
+
: undefined;
|
|
54
|
+
if (segment.kind === "single") {
|
|
55
|
+
if (value === undefined) {
|
|
56
|
+
issues.push({
|
|
57
|
+
key: segment.name,
|
|
58
|
+
message: "required route param is missing",
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
else if (typeof value !== "string") {
|
|
62
|
+
// RL7: shape mismatches are recorded issues, never ParseErrors —
|
|
63
|
+
// .catch() cannot recover them.
|
|
64
|
+
issues.push({
|
|
65
|
+
key: segment.name,
|
|
66
|
+
message: `expected a single segment value, got ${Array.isArray(value) ? "an array" : typeof value}`,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
// R5: App-Router surfaces arrive percent-encoded; core decodes before
|
|
71
|
+
// the codec grammar sees the string (malformed → raw fallback). Pages
|
|
72
|
+
// surfaces are already-decoded, so `percentDecode: false` skips this.
|
|
73
|
+
const decoded = percentDecode ? percentDecodeSegment(value) : value;
|
|
74
|
+
try {
|
|
75
|
+
entries.push([segment.name, codec["~parseElement"](decoded)]);
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
// Per-key recovery, as in decodeSearch: .catch() only ever
|
|
79
|
+
// recovers parse *failures* (D2), never absence or shape.
|
|
80
|
+
if (error instanceof ParseError &&
|
|
81
|
+
codec["~catchValue"] !== undefined) {
|
|
82
|
+
entries.push([segment.name, codec["~catchValue"]()]);
|
|
83
|
+
}
|
|
84
|
+
else if (error instanceof ParseError) {
|
|
85
|
+
issues.push({ key: segment.name, message: error.message });
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (value === undefined) {
|
|
95
|
+
if (segment.kind === "optional-catchall") {
|
|
96
|
+
// Absent [[...x]] → [] (D6 normalization).
|
|
97
|
+
entries.push([segment.name, []]);
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
issues.push({
|
|
101
|
+
key: segment.name,
|
|
102
|
+
message: "required route param is missing",
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (!Array.isArray(value)) {
|
|
108
|
+
issues.push({
|
|
109
|
+
key: segment.name,
|
|
110
|
+
message: `expected catch-all values (an array), got ${typeof value}`,
|
|
111
|
+
});
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (segment.kind === "catchall" && value.length === 0) {
|
|
115
|
+
// RL7: no URL produces a present-but-empty required catch-all; only
|
|
116
|
+
// hand-built props can — mirrors R3's encode-side stance.
|
|
117
|
+
issues.push({
|
|
118
|
+
key: segment.name,
|
|
119
|
+
message: "required catch-all received no segment values",
|
|
120
|
+
});
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
// Copy FIRST, then validate and parse the copy (search.ts's snapshot
|
|
124
|
+
// ethos: impure index getters can't present strings to validation yet
|
|
125
|
+
// deliver junk to the codec).
|
|
126
|
+
const elements = [...value];
|
|
127
|
+
const parsed = [];
|
|
128
|
+
let failed = false;
|
|
129
|
+
for (const [index, element] of elements.entries()) {
|
|
130
|
+
if (typeof element !== "string") {
|
|
131
|
+
issues.push({
|
|
132
|
+
key: segment.name,
|
|
133
|
+
message: `element ${String(index)}: expected a string, got ${typeof element}`,
|
|
134
|
+
});
|
|
135
|
+
failed = true;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
// R5: decode each element before the codec grammar; R2's %2F round-trip
|
|
139
|
+
// (an element containing "/") is restored here (malformed → raw). Pages
|
|
140
|
+
// surfaces are already-decoded, so `percentDecode: false` skips this.
|
|
141
|
+
const decoded = percentDecode ? percentDecodeSegment(element) : element;
|
|
142
|
+
try {
|
|
143
|
+
parsed.push(codec["~parseElement"](decoded));
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
// Element-wise recovery (RL7, forced by D6): the codec describes ONE
|
|
147
|
+
// element, so a .catch() fallback is element-typed — each failing
|
|
148
|
+
// element recovers independently ("1","x","3" → 1, fallback, 3).
|
|
149
|
+
if (error instanceof ParseError && codec["~catchValue"] !== undefined) {
|
|
150
|
+
parsed.push(codec["~catchValue"]());
|
|
151
|
+
}
|
|
152
|
+
else if (error instanceof ParseError) {
|
|
153
|
+
issues.push({
|
|
154
|
+
key: segment.name,
|
|
155
|
+
message: `element ${String(index)}: ${error.message}`,
|
|
156
|
+
});
|
|
157
|
+
failed = true;
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (!failed)
|
|
165
|
+
entries.push([segment.name, parsed]);
|
|
166
|
+
}
|
|
167
|
+
if (issues.length > 0) {
|
|
168
|
+
throw new ParamsDecodeError(issues);
|
|
169
|
+
}
|
|
170
|
+
return Object.fromEntries(entries);
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Encodes a params input into ordered, already-percent-encoded URL segment
|
|
174
|
+
* strings (RL5) — ONE entry per emitted URL segment: a static segment is
|
|
175
|
+
* emitted verbatim, a single param contributes one entry (R1), a catch-all
|
|
176
|
+
* one per element (R2), an elided optional catch-all none (R3). Codec
|
|
177
|
+
* serialize errors and schema-refinement failures (N9) propagate unchanged,
|
|
178
|
+
* already branded at their own chokepoints.
|
|
179
|
+
*/
|
|
180
|
+
export function encodeParams(route, params) {
|
|
181
|
+
// The TS contract forbids non-object inputs, but plain-JS callers reach
|
|
182
|
+
// here; a null input must fail loud, not read as every-param-absent.
|
|
183
|
+
const untrusted = params;
|
|
184
|
+
if (typeof untrusted !== "object" || untrusted === null) {
|
|
185
|
+
throw new SerializeError(`params input must be an object, got ${untrusted === null ? "null" : typeof untrusted}`);
|
|
186
|
+
}
|
|
187
|
+
const values = untrusted;
|
|
188
|
+
const config = route["~params"];
|
|
189
|
+
const segments = [];
|
|
190
|
+
for (const segment of routeSegments(route)) {
|
|
191
|
+
if (segment.kind === "static") {
|
|
192
|
+
// RL2/RL5: the literal is URL-shaped and emitted as-is — static
|
|
193
|
+
// segments are never re-encoded.
|
|
194
|
+
segments.push(segment.raw);
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const codec = requireCodec(config, segment.name, route.path);
|
|
198
|
+
const serialized = serializeDynamicSegment(codec, segment, readInputValue(values, segment.name));
|
|
199
|
+
// Byte layer happens HERE, not in the shared chokepoint: encodeComponent
|
|
200
|
+
// percent-encodes each wire value and brands lone-surrogate URIErrors
|
|
201
|
+
// (S7) — the static surface (encodeStaticParams) hands Next the raw
|
|
202
|
+
// values instead. "none" (R3's elided optional catch-all) contributes
|
|
203
|
+
// no segments.
|
|
204
|
+
if (serialized.kind === "one") {
|
|
205
|
+
segments.push(encodeComponent(serialized.value));
|
|
206
|
+
}
|
|
207
|
+
else if (serialized.kind === "many") {
|
|
208
|
+
for (const value of serialized.values) {
|
|
209
|
+
segments.push(encodeComponent(value));
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return segments;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Encodes a params input into the per-param wire-value record the static
|
|
217
|
+
* generation surfaces expect: App Router `generateStaticParams` entries and
|
|
218
|
+
* Pages Router `getStaticPaths` `{ params }` objects (PR10's static story).
|
|
219
|
+
* Same codec serialization and R1–R4 validation as {@link encodeParams},
|
|
220
|
+
* with two deliberate differences: static segments are skipped (Next wants
|
|
221
|
+
* only the dynamic params, keyed by name), and values are NOT percent-encoded
|
|
222
|
+
* — Next percent-encodes static-params values itself when it materializes
|
|
223
|
+
* the concrete URLs, so pre-encoding here would double-encode (the
|
|
224
|
+
* encode-side mirror of R5's decode asymmetry). That also means the S7
|
|
225
|
+
* lone-surrogate brand stays an encodeParams concern: strings are handed to
|
|
226
|
+
* Next verbatim. An elided optional catch-all (R3) OMITS its key — an absent
|
|
227
|
+
* key is the base-path variant on both routers (Pages also accepts
|
|
228
|
+
* undefined/[]; omission is the one spelling valid on both).
|
|
229
|
+
*/
|
|
230
|
+
export function encodeStaticParams(route, params) {
|
|
231
|
+
// The TS contract forbids non-object inputs, but plain-JS callers reach
|
|
232
|
+
// here; a null input must fail loud, not read as every-param-absent.
|
|
233
|
+
const untrusted = params;
|
|
234
|
+
if (typeof untrusted !== "object" || untrusted === null) {
|
|
235
|
+
throw new SerializeError(`params input must be an object, got ${untrusted === null ? "null" : typeof untrusted}`);
|
|
236
|
+
}
|
|
237
|
+
const values = untrusted;
|
|
238
|
+
const config = route["~params"];
|
|
239
|
+
// Built as entries so keys like "__proto__" become ordinary own properties
|
|
240
|
+
// of the result (decodeParams' hygiene stance).
|
|
241
|
+
const entries = [];
|
|
242
|
+
for (const segment of routeSegments(route)) {
|
|
243
|
+
if (segment.kind === "static")
|
|
244
|
+
continue;
|
|
245
|
+
const codec = requireCodec(config, segment.name, route.path);
|
|
246
|
+
const serialized = serializeDynamicSegment(codec, segment, readInputValue(values, segment.name));
|
|
247
|
+
if (serialized.kind === "none")
|
|
248
|
+
continue;
|
|
249
|
+
entries.push([
|
|
250
|
+
segment.name,
|
|
251
|
+
serialized.kind === "one" ? serialized.value : serialized.values,
|
|
252
|
+
]);
|
|
253
|
+
}
|
|
254
|
+
return Object.fromEntries(entries);
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Tokenizes a path literal into segments, throwing ParamourError on every
|
|
258
|
+
* RL1 rejection. Shared by the route constructors (define-time validation)
|
|
259
|
+
* and the R-rule runtime here, so encode/decode never re-derive segment
|
|
260
|
+
* kinds.
|
|
261
|
+
*/
|
|
262
|
+
export function tokenizePath(path) {
|
|
263
|
+
// RL1: either would corrupt href's fixed path–query–fragment assembly (RL4).
|
|
264
|
+
if (path.includes("?")) {
|
|
265
|
+
throw new ParamourError(`route path must not contain "?": "${path}" (declare search params in the search config)`);
|
|
266
|
+
}
|
|
267
|
+
if (path.includes("#")) {
|
|
268
|
+
throw new ParamourError(`route path must not contain "#": "${path}" (pass fragments to href() via its hash option)`);
|
|
269
|
+
}
|
|
270
|
+
if (!path.startsWith("/")) {
|
|
271
|
+
throw new ParamourError(`route path must start with "/": "${path}"`);
|
|
272
|
+
}
|
|
273
|
+
if (path !== "/" && path.endsWith("/")) {
|
|
274
|
+
throw new ParamourError(`route path must not end with "/": "${path}"`);
|
|
275
|
+
}
|
|
276
|
+
if (path === "/")
|
|
277
|
+
return [];
|
|
278
|
+
const segments = [];
|
|
279
|
+
const seen = new Set();
|
|
280
|
+
for (const raw of path.slice(1).split("/")) {
|
|
281
|
+
if (raw === "") {
|
|
282
|
+
throw new ParamourError(`route path contains an empty segment: "${path}"`);
|
|
283
|
+
}
|
|
284
|
+
// RL2: path literals are URL-shaped, so group/slot spellings are
|
|
285
|
+
// filesystem paths by definition — the most likely migration mistake.
|
|
286
|
+
if (GROUP_SEGMENT.test(raw)) {
|
|
287
|
+
throw new ParamourError(`route paths are URL-shaped: "${raw}" in "${path}" is a route-group folder name; use the URL path without it`);
|
|
288
|
+
}
|
|
289
|
+
if (raw.startsWith("@")) {
|
|
290
|
+
throw new ParamourError(`route paths are URL-shaped: "${raw}" in "${path}" is a parallel-route slot; define the parent route instead`);
|
|
291
|
+
}
|
|
292
|
+
const segment = tokenizeSegment(raw, path);
|
|
293
|
+
if (segment.kind !== "static") {
|
|
294
|
+
// RL1: not expressible as a compile error — the mapped type silently
|
|
295
|
+
// collapses duplicate keys.
|
|
296
|
+
if (seen.has(segment.name)) {
|
|
297
|
+
throw new ParamourError(`route path declares param "${segment.name}" more than once: "${path}"`);
|
|
298
|
+
}
|
|
299
|
+
seen.add(segment.name);
|
|
300
|
+
}
|
|
301
|
+
segments.push(segment);
|
|
302
|
+
}
|
|
303
|
+
segments.forEach((segment, index) => {
|
|
304
|
+
// RL1: Next itself requires catch-alls to be final.
|
|
305
|
+
if ((segment.kind === "catchall" || segment.kind === "optional-catchall") &&
|
|
306
|
+
index < segments.length - 1) {
|
|
307
|
+
throw new ParamourError(`catch-all segment "${segment.raw}" must be the final segment: "${path}"`);
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
return segments;
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Percent-decodes one segment string on the decode side (R5). Next hands the
|
|
314
|
+
* `params`/`useParams()` surfaces percent-encoded values (issues #48058/#64952),
|
|
315
|
+
* so `/product/a%20b` arrives as `"a%20b"` and must become `"a b"` before the
|
|
316
|
+
* codec grammar runs. A malformed sequence (e.g. raw `"%zz"`, which makes
|
|
317
|
+
* `decodeURIComponent` throw `URIError`) falls back to the RAW string unchanged
|
|
318
|
+
* — it matches what the user typed and what Next serves, and never becomes a
|
|
319
|
+
* decode issue. If Next ever ships its own fix for #48058/#64952 and starts
|
|
320
|
+
* handing already-decoded values, this becomes a double-decode hazard to revisit.
|
|
321
|
+
*/
|
|
322
|
+
function percentDecodeSegment(value) {
|
|
323
|
+
try {
|
|
324
|
+
return decodeURIComponent(value);
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
return value;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Unreachable through the constructors' typed configs; a plain-JS caller can
|
|
332
|
+
* omit a param codec — or a hand-built route can lack `~params` entirely —
|
|
333
|
+
* which is a config-contract failure, not a decode issue.
|
|
334
|
+
*/
|
|
335
|
+
function requireCodec(config, name, path) {
|
|
336
|
+
const codec = config?.[name];
|
|
337
|
+
if (codec === undefined) {
|
|
338
|
+
throw new ParamourError(`route "${path}" declares no codec for param "${name}"`);
|
|
339
|
+
}
|
|
340
|
+
return codec;
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* A route's tokenized segments: the constructors compute them once at define
|
|
344
|
+
* time (`~segments`); a hand-built route lacking them falls back to
|
|
345
|
+
* tokenizing here, so the plain-JS contract is identical either way.
|
|
346
|
+
*/
|
|
347
|
+
function routeSegments(route) {
|
|
348
|
+
const segments = route["~segments"];
|
|
349
|
+
return segments ?? tokenizePath(route.path);
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Shape-validates and serializes ONE dynamic segment's input — the shared
|
|
353
|
+
* R1–R4 chokepoint for {@link encodeParams} (which then percent-encodes each
|
|
354
|
+
* value) and {@link encodeStaticParams} (which hands Next the raw wire
|
|
355
|
+
* values). "none" is R3's elided optional catch-all.
|
|
356
|
+
*/
|
|
357
|
+
function serializeDynamicSegment(codec, segment, value) {
|
|
358
|
+
if (segment.kind === "single") {
|
|
359
|
+
if (value === undefined) {
|
|
360
|
+
throw new SerializeError(`required route param "${segment.name}" is missing`);
|
|
361
|
+
}
|
|
362
|
+
// R1: one value, one segment.
|
|
363
|
+
return {
|
|
364
|
+
kind: "one",
|
|
365
|
+
value: serializeSegmentValue(codec, segment.name, value),
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
if (segment.kind === "optional-catchall" &&
|
|
369
|
+
(value === undefined || (Array.isArray(value) && value.length === 0))) {
|
|
370
|
+
// R3: [[...x]] given [] or absent emits no segments — the segment and
|
|
371
|
+
// its preceding "/" vanish, leaving the base path.
|
|
372
|
+
return { kind: "none" };
|
|
373
|
+
}
|
|
374
|
+
if (value === undefined) {
|
|
375
|
+
throw new SerializeError(`required route param "${segment.name}" is missing`);
|
|
376
|
+
}
|
|
377
|
+
if (!Array.isArray(value)) {
|
|
378
|
+
throw new SerializeError(`route param "${segment.name}" expects an array, got ${typeof value}`);
|
|
379
|
+
}
|
|
380
|
+
if (value.length === 0) {
|
|
381
|
+
// R3: a required catch-all given [] is a serialization error — Next
|
|
382
|
+
// has no route for it.
|
|
383
|
+
throw new SerializeError(`catch-all route param "${segment.name}" received an empty array`);
|
|
384
|
+
}
|
|
385
|
+
// R2: each element is serialized independently; on the path surface an
|
|
386
|
+
// element containing "/" becomes %2F and round-trips as a single element —
|
|
387
|
+
// core's decodeParams restores it via percentDecodeSegment (R5), since Next
|
|
388
|
+
// hands the encoded value straight back on the params surface (wire-spec
|
|
389
|
+
// open item 1). The static surface passes the array whole, so "/" needs no
|
|
390
|
+
// escaping there.
|
|
391
|
+
return {
|
|
392
|
+
kind: "many",
|
|
393
|
+
values: value.map((element) => serializeSegmentValue(codec, segment.name, element)),
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Serializes one segment value into its wire-string form. Enforces the
|
|
398
|
+
* serializer's string contract exactly as search.ts's serializeValue does —
|
|
399
|
+
* a plain-JS custom codec returning a non-string must never reach the byte
|
|
400
|
+
* layer as the literal text "undefined". Percent-encoding is deliberately
|
|
401
|
+
* NOT applied here: that is encodeParams' byte-layer step (S7), and the
|
|
402
|
+
* static surfaces must skip it.
|
|
403
|
+
*/
|
|
404
|
+
function serializeSegmentValue(codec, name, value) {
|
|
405
|
+
const serialized = codec["~serializeElement"](value);
|
|
406
|
+
if (typeof serialized !== "string") {
|
|
407
|
+
throw new SerializeError(`serializer for route param "${name}" must return a string, got ${typeof serialized}`);
|
|
408
|
+
}
|
|
409
|
+
if (serialized === "") {
|
|
410
|
+
// R4: "" would produce "//" or a vanishing segment — same rationale as R3.
|
|
411
|
+
throw new SerializeError(`route param "${name}" serialized to an empty string, which cannot form a path segment`);
|
|
412
|
+
}
|
|
413
|
+
return serialized;
|
|
414
|
+
}
|
|
415
|
+
function tokenizeSegment(raw, path) {
|
|
416
|
+
const optionalCatchAll = OPTIONAL_CATCHALL_TOKEN.exec(raw);
|
|
417
|
+
if (optionalCatchAll?.[1] !== undefined) {
|
|
418
|
+
return { kind: "optional-catchall", name: optionalCatchAll[1], raw };
|
|
419
|
+
}
|
|
420
|
+
const catchAll = CATCHALL_TOKEN.exec(raw);
|
|
421
|
+
if (catchAll?.[1] !== undefined) {
|
|
422
|
+
return { kind: "catchall", name: catchAll[1], raw };
|
|
423
|
+
}
|
|
424
|
+
// Mirrors SingleParamNames' catch-all exclusion: a `[...`-prefixed segment
|
|
425
|
+
// that failed the catch-all regex (only `[...]`, since the name charset
|
|
426
|
+
// already bans brackets) must fall through to malformed, or the runtime
|
|
427
|
+
// would mint a single param named "..." where the type layer sees static.
|
|
428
|
+
const single = SINGLE_TOKEN.exec(raw);
|
|
429
|
+
if (!raw.startsWith("[...") && single?.[1] !== undefined) {
|
|
430
|
+
return { kind: "single", name: single[1], raw };
|
|
431
|
+
}
|
|
432
|
+
// RL1: the type layer lets these fall through as static text (RL3), and
|
|
433
|
+
// pre-generation there is no registry to catch them; href would otherwise
|
|
434
|
+
// emit the token verbatim.
|
|
435
|
+
if (raw.includes("[") || raw.includes("]")) {
|
|
436
|
+
throw new ParamourError(`malformed dynamic segment "${raw}" in "${path}": expected [name], [...name], or [[...name]]`);
|
|
437
|
+
}
|
|
438
|
+
return { kind: "static", raw };
|
|
439
|
+
}
|