@timber-js/app 0.2.0-alpha.116 → 0.2.0-alpha.117
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/_chunks/{actions-DAVm0FQe.js → actions-Cw-7NfJJ.js} +8 -1
- package/dist/_chunks/actions-Cw-7NfJJ.js.map +1 -0
- package/dist/_chunks/{resolve-schema-DO8B0mO9.js → resolve-schema-23Mc8aLi.js} +1 -1
- package/dist/_chunks/{resolve-schema-DO8B0mO9.js.map → resolve-schema-23Mc8aLi.js.map} +1 -1
- package/dist/_chunks/{segment-context-EsfQpS--.js → segment-context-Bg0pFbLT.js} +1 -1
- package/dist/_chunks/{segment-context-EsfQpS--.js.map → segment-context-Bg0pFbLT.js.map} +1 -1
- package/dist/_chunks/tracing-DvKLzCFa.js.map +1 -1
- package/dist/client/index.js +1 -1
- package/dist/client/internal.js +1 -1
- package/dist/params/index.js +1 -1
- package/dist/server/als-registry.d.ts +12 -0
- package/dist/server/als-registry.d.ts.map +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/internal.js +68 -67
- package/dist/server/internal.js.map +1 -1
- package/dist/server/param-coercion.d.ts +18 -0
- package/dist/server/param-coercion.d.ts.map +1 -1
- package/dist/server/pipeline-phases.d.ts.map +1 -1
- package/dist/server/pipeline.d.ts +7 -0
- package/dist/server/pipeline.d.ts.map +1 -1
- package/dist/server/request-context.d.ts +9 -0
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/route-matcher.d.ts.map +1 -1
- package/dist/server/slot-resolver.d.ts +1 -1
- package/dist/server/slot-resolver.d.ts.map +1 -1
- package/dist/server/tree-match.d.ts +41 -0
- package/dist/server/tree-match.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/server/als-registry.ts +12 -0
- package/src/server/param-coercion.ts +58 -0
- package/src/server/pipeline-phases.ts +5 -0
- package/src/server/pipeline.ts +7 -0
- package/src/server/request-context.ts +31 -0
- package/src/server/route-element-builder.ts +12 -2
- package/src/server/route-matcher.ts +8 -168
- package/src/server/slot-resolver.ts +79 -112
- package/src/server/tree-match.ts +198 -0
- package/dist/_chunks/actions-DAVm0FQe.js.map +0 -1
|
@@ -26,6 +26,9 @@ import type { InterceptionContext, RouteMatch } from './pipeline.js';
|
|
|
26
26
|
import { DenySignal, RedirectSignal } from './primitives.js';
|
|
27
27
|
import { logRenderError } from './logger.js';
|
|
28
28
|
import type { ManifestSegmentNode } from './route-matcher.js';
|
|
29
|
+
import { setSlotParams } from './request-context.js';
|
|
30
|
+
import { coerceSlotParams } from './param-coercion.js';
|
|
31
|
+
import { matchUrlParts } from './tree-match.js';
|
|
29
32
|
|
|
30
33
|
type CreateElementFn = (...args: unknown[]) => React.ReactElement;
|
|
31
34
|
|
|
@@ -57,89 +60,6 @@ async function renderDefaultFallback(
|
|
|
57
60
|
return h(DefaultComp, {});
|
|
58
61
|
}
|
|
59
62
|
|
|
60
|
-
// ─── Segment Tree Matching ──────────────────────────────────────────────────
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Find a matching child node for a URL segment name.
|
|
64
|
-
*
|
|
65
|
-
* Tries matches in priority order:
|
|
66
|
-
* 1. Static segment (exact name match)
|
|
67
|
-
* 2. Dynamic segment ([param])
|
|
68
|
-
* 3. Catch-all or optional-catch-all ([...param] / [[...param]])
|
|
69
|
-
* 4. Group children (transparent wrappers)
|
|
70
|
-
*
|
|
71
|
-
* Returns `{ node, consumesRest }` where `consumesRest` is true for catch-all
|
|
72
|
-
* segments that consume all remaining URL parts.
|
|
73
|
-
*/
|
|
74
|
-
function findMatchingChild(
|
|
75
|
-
children: ManifestSegmentNode[],
|
|
76
|
-
segmentName: string
|
|
77
|
-
): { node: ManifestSegmentNode; consumesRest: boolean } | null {
|
|
78
|
-
// 1. Static match
|
|
79
|
-
for (const child of children) {
|
|
80
|
-
if (child.segmentType === 'static' && child.segmentName === segmentName) {
|
|
81
|
-
return { node: child, consumesRest: false };
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// 2. Dynamic match
|
|
86
|
-
for (const child of children) {
|
|
87
|
-
if (child.segmentType === 'dynamic') {
|
|
88
|
-
return { node: child, consumesRest: false };
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// 3. Catch-all match — consumes all remaining segments
|
|
93
|
-
for (const child of children) {
|
|
94
|
-
if (child.segmentType === 'catch-all' || child.segmentType === 'optional-catch-all') {
|
|
95
|
-
return { node: child, consumesRest: true };
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// 4. Group children (transparent)
|
|
100
|
-
for (const child of children) {
|
|
101
|
-
if (child.segmentType === 'group') {
|
|
102
|
-
for (const groupChild of child.children ?? []) {
|
|
103
|
-
if (groupChild.segmentName === segmentName) {
|
|
104
|
-
return { node: groupChild, consumesRest: false };
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
return null;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Walk a segment tree from `startNode`, matching each part in `parts` against
|
|
115
|
-
* child nodes. Returns the chain of matched nodes (including startNode) and the
|
|
116
|
-
* final node, or null if any part fails to match.
|
|
117
|
-
*/
|
|
118
|
-
function walkSegmentTree(
|
|
119
|
-
startNode: ManifestSegmentNode,
|
|
120
|
-
parts: { segmentName: string }[] | string[],
|
|
121
|
-
initialChain: ManifestSegmentNode[] = [startNode]
|
|
122
|
-
): { chain: ManifestSegmentNode[]; leaf: ManifestSegmentNode } | null {
|
|
123
|
-
const chain = [...initialChain];
|
|
124
|
-
let currentNode = startNode;
|
|
125
|
-
|
|
126
|
-
for (const part of parts) {
|
|
127
|
-
const segName = typeof part === 'string' ? part : part.segmentName;
|
|
128
|
-
const directChildren = currentNode.children ?? [];
|
|
129
|
-
const match = findMatchingChild(directChildren, segName);
|
|
130
|
-
|
|
131
|
-
if (!match) return null;
|
|
132
|
-
|
|
133
|
-
chain.push(match.node);
|
|
134
|
-
currentNode = match.node;
|
|
135
|
-
|
|
136
|
-
// Catch-all segments consume all remaining parts
|
|
137
|
-
if (match.consumesRest) break;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
return { chain, leaf: currentNode };
|
|
141
|
-
}
|
|
142
|
-
|
|
143
63
|
// ─── Slot Element Resolution ────────────────────────────────────────────────
|
|
144
64
|
|
|
145
65
|
/**
|
|
@@ -157,7 +77,8 @@ export async function resolveSlotElement(
|
|
|
157
77
|
slotNode: ManifestSegmentNode,
|
|
158
78
|
match: RouteMatch,
|
|
159
79
|
h: CreateElementFn,
|
|
160
|
-
interception?: InterceptionContext
|
|
80
|
+
interception?: InterceptionContext,
|
|
81
|
+
parentTreePath?: string
|
|
161
82
|
): Promise<React.ReactElement | null> {
|
|
162
83
|
// When interception is active, try to match intercepting children in this
|
|
163
84
|
// slot against the target pathname. If an intercepting child matches, render
|
|
@@ -169,6 +90,22 @@ export async function resolveSlotElement(
|
|
|
169
90
|
: findSlotMatch(slotNode, match);
|
|
170
91
|
|
|
171
92
|
if (slotMatch) {
|
|
93
|
+
// Store per-slot params in ALS so getSegmentParams(segmentPath) returns
|
|
94
|
+
// the slot's own param types (e.g. catch-all → string[] vs dynamic → string).
|
|
95
|
+
if (parentTreePath && Object.keys(slotMatch.slotParams).length > 0) {
|
|
96
|
+
// Build the full tree path: parent prefix + slot chain segment names.
|
|
97
|
+
const slotSuffix = slotMatch.chain
|
|
98
|
+
.map((s) => s.segmentName)
|
|
99
|
+
.filter(Boolean)
|
|
100
|
+
.join('/');
|
|
101
|
+
const prefix = parentTreePath === '/' ? '' : parentTreePath;
|
|
102
|
+
const fullSlotPath = `${prefix}/${slotSuffix}`;
|
|
103
|
+
|
|
104
|
+
// Coerce slot params through global schema codecs (same as main route).
|
|
105
|
+
const coerced = coerceSlotParams(slotMatch.chain, slotMatch.slotParams);
|
|
106
|
+
setSlotParams(fullSlotPath, coerced);
|
|
107
|
+
}
|
|
108
|
+
|
|
172
109
|
const SlotPage = await loadComponent(slotMatch.page);
|
|
173
110
|
if (SlotPage) {
|
|
174
111
|
// Load default.tsx fallback for notFound() handling in the slot page.
|
|
@@ -334,6 +271,8 @@ interface SlotMatchResult {
|
|
|
334
271
|
page: NonNullable<ManifestSegmentNode['page']>;
|
|
335
272
|
/** The full chain of slot nodes traversed (slot root → … → leaf with page). */
|
|
336
273
|
chain: ManifestSegmentNode[];
|
|
274
|
+
/** Params collected from the slot's segment chain (may differ from main route params). */
|
|
275
|
+
slotParams: Record<string, string | string[]>;
|
|
337
276
|
}
|
|
338
277
|
|
|
339
278
|
/**
|
|
@@ -374,26 +313,54 @@ function findSlotMatch(slotNode: ManifestSegmentNode, match: RouteMatch): SlotMa
|
|
|
374
313
|
// against the slot's children.
|
|
375
314
|
const remainingSegments = parentIndex >= 0 ? segments.slice(parentIndex + 1) : [];
|
|
376
315
|
|
|
377
|
-
//
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
316
|
+
// Extract raw URL parts from the remaining main-route segments.
|
|
317
|
+
// Use rawSegmentParams (pre-coercion) to ensure we get actual URL strings,
|
|
318
|
+
// not codec-transformed values. Fall back to segmentParams for tests/compat.
|
|
319
|
+
const rawParams = match.rawSegmentParams ?? match.segmentParams ?? {};
|
|
320
|
+
const urlParts = extractUrlParts(remainingSegments, rawParams);
|
|
321
|
+
|
|
322
|
+
// Match URL parts against the slot's segment tree using the shared walker.
|
|
323
|
+
// Handles zero parts (slot's own page), catch-all, optional-catch-all,
|
|
324
|
+
// groups, backtracking, and correct priority ordering.
|
|
325
|
+
const result = matchUrlParts(slotNode, urlParts);
|
|
326
|
+
if (!result) return null;
|
|
327
|
+
|
|
328
|
+
const leaf = result.chain[result.chain.length - 1];
|
|
329
|
+
if (!leaf.page) return null;
|
|
330
|
+
|
|
331
|
+
return { page: leaf.page, chain: result.chain, slotParams: result.params };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Extract raw URL path parts from a main-route segment chain.
|
|
336
|
+
*
|
|
337
|
+
* Each dynamic segment consumed one URL part (its param value).
|
|
338
|
+
* Static segments consumed their own name. Groups are transparent.
|
|
339
|
+
* Catch-all segments consumed multiple parts (their param value is string[]).
|
|
340
|
+
*
|
|
341
|
+
* Uses rawSegmentParams (pre-coercion) to get actual URL strings.
|
|
342
|
+
* Values are coerced to String() defensively in case a codec leaked
|
|
343
|
+
* a non-string value into the params map.
|
|
344
|
+
*/
|
|
345
|
+
function extractUrlParts(
|
|
346
|
+
segments: ManifestSegmentNode[],
|
|
347
|
+
mainParams: Record<string, string | string[]>
|
|
348
|
+
): string[] {
|
|
349
|
+
const parts: string[] = [];
|
|
350
|
+
for (const seg of segments) {
|
|
351
|
+
if (seg.segmentType === 'group') continue;
|
|
352
|
+
if (seg.segmentType === 'static') {
|
|
353
|
+
parts.push(seg.segmentName);
|
|
354
|
+
} else if (seg.paramName) {
|
|
355
|
+
const value = mainParams[seg.paramName];
|
|
356
|
+
if (Array.isArray(value)) {
|
|
357
|
+
parts.push(...value.map(String));
|
|
358
|
+
} else if (value !== undefined) {
|
|
359
|
+
parts.push(String(value));
|
|
386
360
|
}
|
|
387
361
|
}
|
|
388
|
-
return null;
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
// Walk the slot's children to match remaining URL segments
|
|
392
|
-
const result = walkSegmentTree(slotNode, remainingSegments);
|
|
393
|
-
if (result && result.leaf.page) {
|
|
394
|
-
return { page: result.leaf.page, chain: result.chain };
|
|
395
362
|
}
|
|
396
|
-
return
|
|
363
|
+
return parts;
|
|
397
364
|
}
|
|
398
365
|
|
|
399
366
|
/**
|
|
@@ -429,19 +396,19 @@ function findInterceptingMatch(
|
|
|
429
396
|
}
|
|
430
397
|
if (matchIdx < 0) continue;
|
|
431
398
|
|
|
432
|
-
//
|
|
399
|
+
// Match remaining target parts against the intercepting child's sub-tree.
|
|
433
400
|
const remaining = targetParts.slice(matchIdx + 1);
|
|
434
|
-
|
|
435
|
-
if (
|
|
436
|
-
|
|
437
|
-
|
|
401
|
+
const result = matchUrlParts(child, remaining);
|
|
402
|
+
if (result) {
|
|
403
|
+
const leaf = result.chain[result.chain.length - 1];
|
|
404
|
+
if (leaf.page) {
|
|
405
|
+
return {
|
|
406
|
+
page: leaf.page,
|
|
407
|
+
// Prepend slotNode — matchUrlParts starts from child, not slot root
|
|
408
|
+
chain: [slotNode, ...result.chain],
|
|
409
|
+
slotParams: result.params,
|
|
410
|
+
};
|
|
438
411
|
}
|
|
439
|
-
continue;
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
const result = walkSegmentTree(child, remaining, [slotNode, child]);
|
|
443
|
-
if (result && result.leaf.page) {
|
|
444
|
-
return { page: result.leaf.page, chain: result.chain };
|
|
445
412
|
}
|
|
446
413
|
}
|
|
447
414
|
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared URL-matching tree walker for segment trees.
|
|
3
|
+
*
|
|
4
|
+
* Both the main route matcher (`route-matcher.ts`) and slot resolver
|
|
5
|
+
* (`slot-resolver.ts`) need to walk a SegmentNode tree, matching URL
|
|
6
|
+
* parts against children with the same priority semantics:
|
|
7
|
+
*
|
|
8
|
+
* static > dynamic > catch-all > optional-catch-all
|
|
9
|
+
*
|
|
10
|
+
* Before this module, each had its own walker with subtle divergences
|
|
11
|
+
* (different group handling, missing backtracking, incomplete
|
|
12
|
+
* optional-catch-all support). This shared walker is the single
|
|
13
|
+
* source of truth for URL-to-segment matching.
|
|
14
|
+
*
|
|
15
|
+
* See design/07-routing.md §"Request Lifecycle" and TIM-940.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { SegmentNode } from '../routing/types.js';
|
|
19
|
+
|
|
20
|
+
// ─── Public API ───────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
/** Result of matching URL parts against a segment tree. */
|
|
23
|
+
export interface TreeMatchResult<TFile> {
|
|
24
|
+
/** Matched segment chain from root to leaf, inclusive. */
|
|
25
|
+
chain: SegmentNode<TFile>[];
|
|
26
|
+
/** Collected params, keyed by param name. Catch-all params are string[]. */
|
|
27
|
+
params: Record<string, string | string[]>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Match URL path parts against a segment tree.
|
|
32
|
+
*
|
|
33
|
+
* Walks the tree depth-first with backtracking, applying priority
|
|
34
|
+
* ordering globally across group boundaries. Returns the matched
|
|
35
|
+
* segment chain and collected params, or null if no route matches.
|
|
36
|
+
*
|
|
37
|
+
* A match requires a terminal node (one with `page` or `route`) at
|
|
38
|
+
* the leaf. Catch-all and optional-catch-all segments only match if
|
|
39
|
+
* they are terminal.
|
|
40
|
+
*
|
|
41
|
+
* @param root The root segment node to start matching from.
|
|
42
|
+
* @param parts URL path parts (e.g. ['blog', 'hello-world']).
|
|
43
|
+
* Empty array matches the root itself (if terminal).
|
|
44
|
+
*/
|
|
45
|
+
export function matchUrlParts<TFile>(
|
|
46
|
+
root: SegmentNode<TFile>,
|
|
47
|
+
parts: string[]
|
|
48
|
+
): TreeMatchResult<TFile> | null {
|
|
49
|
+
const chain: SegmentNode<TFile>[] = [];
|
|
50
|
+
const params: Record<string, string | string[]> = {};
|
|
51
|
+
|
|
52
|
+
if (matchNode(root, parts, 0, chain, params)) {
|
|
53
|
+
return { chain, params };
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ─── Internal ─────────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A child node flattened through group segments, along with the
|
|
62
|
+
* chain of group nodes that must appear in the segment chain
|
|
63
|
+
* before the child to preserve the tree path.
|
|
64
|
+
*/
|
|
65
|
+
interface EffectiveChild<TFile> {
|
|
66
|
+
child: SegmentNode<TFile>;
|
|
67
|
+
groupChain: SegmentNode<TFile>[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Flatten children through group segments.
|
|
72
|
+
*
|
|
73
|
+
* Groups are transparent for URL matching — their non-group descendants
|
|
74
|
+
* are returned with the chain of group nodes leading to them. This lets
|
|
75
|
+
* the caller apply priority ordering (static > dynamic > catch-all >
|
|
76
|
+
* optional-catch-all) across all groups uniformly instead of per-group.
|
|
77
|
+
*/
|
|
78
|
+
function collectEffectiveChildren<TFile>(
|
|
79
|
+
node: SegmentNode<TFile>,
|
|
80
|
+
groupChain: SegmentNode<TFile>[] = []
|
|
81
|
+
): EffectiveChild<TFile>[] {
|
|
82
|
+
const result: EffectiveChild<TFile>[] = [];
|
|
83
|
+
for (const child of node.children) {
|
|
84
|
+
if (child.segmentType === 'group') {
|
|
85
|
+
const nested = collectEffectiveChildren(child, [...groupChain, child]);
|
|
86
|
+
result.push(...nested);
|
|
87
|
+
} else {
|
|
88
|
+
result.push({ child, groupChain });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return result;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** A node is terminal if it can serve a response (has a page or route handler). */
|
|
95
|
+
function isTerminal<TFile>(node: SegmentNode<TFile>): boolean {
|
|
96
|
+
return !!(node.page || node.route);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Recursive depth-first matcher with backtracking.
|
|
101
|
+
*
|
|
102
|
+
* Mutates `chain` and `params` in place for performance; backtracks
|
|
103
|
+
* (restores previous state) when a candidate doesn't lead to a match.
|
|
104
|
+
*/
|
|
105
|
+
function matchNode<TFile>(
|
|
106
|
+
node: SegmentNode<TFile>,
|
|
107
|
+
parts: string[],
|
|
108
|
+
index: number,
|
|
109
|
+
chain: SegmentNode<TFile>[],
|
|
110
|
+
params: Record<string, string | string[]>
|
|
111
|
+
): boolean {
|
|
112
|
+
chain.push(node);
|
|
113
|
+
|
|
114
|
+
// All parts consumed — check if this node is terminal
|
|
115
|
+
if (index >= parts.length) {
|
|
116
|
+
if (isTerminal(node)) return true;
|
|
117
|
+
|
|
118
|
+
// Groups don't consume URL segments, so a group child's page can
|
|
119
|
+
// match at the same URL depth. Recurse into groups transparently.
|
|
120
|
+
for (const child of node.children) {
|
|
121
|
+
if (child.segmentType === 'group') {
|
|
122
|
+
if (matchNode(child, parts, index, chain, params)) {
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Optional catch-all with zero segments (direct and through groups).
|
|
129
|
+
// The param is NOT set — matching Next.js semantics for [[...slug]]
|
|
130
|
+
// with no path segments.
|
|
131
|
+
const effective = collectEffectiveChildren(node);
|
|
132
|
+
for (const { child, groupChain } of effective) {
|
|
133
|
+
if (child.segmentType === 'optional-catch-all' && isTerminal(child)) {
|
|
134
|
+
chain.push(...groupChain, child);
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
chain.pop();
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const part = parts[index];
|
|
144
|
+
|
|
145
|
+
// Flatten children through groups so priority ordering applies globally.
|
|
146
|
+
const effective = collectEffectiveChildren(node);
|
|
147
|
+
|
|
148
|
+
// 1. Static segments (exact name match)
|
|
149
|
+
for (const { child, groupChain } of effective) {
|
|
150
|
+
if (child.segmentType === 'static' && child.segmentName === part) {
|
|
151
|
+
chain.push(...groupChain);
|
|
152
|
+
if (matchNode(child, parts, index + 1, chain, params)) {
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
chain.length -= groupChain.length;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 2. Dynamic segments ([param])
|
|
160
|
+
for (const { child, groupChain } of effective) {
|
|
161
|
+
if (child.segmentType === 'dynamic' && child.paramName) {
|
|
162
|
+
chain.push(...groupChain);
|
|
163
|
+
const prev = params[child.paramName];
|
|
164
|
+
params[child.paramName] = part;
|
|
165
|
+
if (matchNode(child, parts, index + 1, chain, params)) {
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
// Backtrack
|
|
169
|
+
chain.length -= groupChain.length;
|
|
170
|
+
if (prev !== undefined) {
|
|
171
|
+
params[child.paramName] = prev;
|
|
172
|
+
} else {
|
|
173
|
+
delete params[child.paramName];
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// 3. Catch-all segments ([...param]) — must be terminal
|
|
179
|
+
for (const { child, groupChain } of effective) {
|
|
180
|
+
if (child.segmentType === 'catch-all' && child.paramName && isTerminal(child)) {
|
|
181
|
+
chain.push(...groupChain, child);
|
|
182
|
+
params[child.paramName] = parts.slice(index);
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// 4. Optional catch-all segments ([[...param]]) — must be terminal
|
|
188
|
+
for (const { child, groupChain } of effective) {
|
|
189
|
+
if (child.segmentType === 'optional-catch-all' && child.paramName && isTerminal(child)) {
|
|
190
|
+
chain.push(...groupChain, child);
|
|
191
|
+
params[child.paramName] = parts.slice(index);
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
chain.pop();
|
|
197
|
+
return false;
|
|
198
|
+
}
|