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/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
+ }