@timber-js/app 0.2.0-alpha.205 → 0.2.0-alpha.206
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/{chains-BjGKq9Je.js → chains-B4bgOl2g.js} +2 -2
- package/dist/_chunks/{chains-BjGKq9Je.js.map → chains-B4bgOl2g.js.map} +1 -1
- package/dist/_chunks/{cli-check-3WE_GyoL.js → cli-check-BqOylm-3.js} +2 -2
- package/dist/_chunks/{cli-check-3WE_GyoL.js.map → cli-check-BqOylm-3.js.map} +1 -1
- package/dist/_chunks/{cli-schema-sync-D3iIZjku.js → cli-schema-sync--LZKs8Zn.js} +2 -2
- package/dist/_chunks/{cli-schema-sync-D3iIZjku.js.map → cli-schema-sync--LZKs8Zn.js.map} +1 -1
- package/dist/_chunks/{dev-server-Dbdgx2dD.js → dev-server-BzBxgYAV.js} +3 -3
- package/dist/_chunks/{dev-server-Dbdgx2dD.js.map → dev-server-BzBxgYAV.js.map} +1 -1
- package/dist/_chunks/{live-graph-BM2UbbNx.js → live-graph-Bm5WTvcr.js} +3 -3
- package/dist/_chunks/{live-graph-BM2UbbNx.js.map → live-graph-Bm5WTvcr.js.map} +1 -1
- package/dist/_chunks/{poison-scan-g83aAXhs.js → poison-scan-DG5eI2_z.js} +2 -2
- package/dist/_chunks/{poison-scan-g83aAXhs.js.map → poison-scan-DG5eI2_z.js.map} +1 -1
- package/dist/_chunks/{scanner-BhNruzv0.js → scanner-BQ5sPIJ7.js} +2 -2
- package/dist/_chunks/{scanner-BhNruzv0.js.map → scanner-BQ5sPIJ7.js.map} +1 -1
- package/dist/_chunks/{tree-match-CdbvYTBz.js → tree-match-6yihorhk.js} +49 -2
- package/dist/_chunks/tree-match-6yihorhk.js.map +1 -0
- package/dist/_chunks/{walkers-BNsswm-h.js → walkers-CN2N_bA-.js} +2 -2
- package/dist/_chunks/{walkers-BNsswm-h.js.map → walkers-CN2N_bA-.js.map} +1 -1
- package/dist/analyze/crawl-entry.js +2 -2
- package/dist/analyze/graph-command.js +2 -2
- package/dist/cli.js +3 -3
- package/dist/index.js +3 -3
- package/dist/routing/index.js +2 -2
- package/dist/server/tree-match.d.ts +10 -0
- package/dist/server/tree-match.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/server/route-matcher.ts +73 -20
- package/src/server/tree-match.ts +250 -18
- package/dist/_chunks/tree-match-CdbvYTBz.js.map +0 -1
package/src/server/tree-match.ts
CHANGED
|
@@ -66,6 +66,27 @@ export function matchUrlParts<TFile>(
|
|
|
66
66
|
return null;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Yield every match for the given URL parts, in priority order.
|
|
71
|
+
*
|
|
72
|
+
* Same matching semantics as `matchUrlParts` — static > affixed-dynamic >
|
|
73
|
+
* bare-dynamic > catch-all > optional-catch-all — but instead of returning
|
|
74
|
+
* the first match, it yields all of them. Used by the metadata route matcher,
|
|
75
|
+
* which needs to try alternate owner segments when the first match doesn't
|
|
76
|
+
* own the requested metadata file.
|
|
77
|
+
*/
|
|
78
|
+
export function* matchUrlPartsAll<TFile>(
|
|
79
|
+
root: SegmentNode<TFile>,
|
|
80
|
+
parts: string[],
|
|
81
|
+
options?: TreeMatchOptions
|
|
82
|
+
): Generator<TreeMatchResult<TFile>> {
|
|
83
|
+
const chain: SegmentNode<TFile>[] = [];
|
|
84
|
+
const params: Record<string, string | string[]> = {};
|
|
85
|
+
const requireTerminal = options?.requireTerminal ?? true;
|
|
86
|
+
|
|
87
|
+
yield* matchNodeAll(root, parts, 0, chain, params, requireTerminal);
|
|
88
|
+
}
|
|
89
|
+
|
|
69
90
|
// ─── Internal ─────────────────────────────────────────────────────────────
|
|
70
91
|
|
|
71
92
|
/**
|
|
@@ -107,6 +128,59 @@ function isTerminal<TFile>(node: SegmentNode<TFile>): boolean {
|
|
|
107
128
|
return !!(node.page || node.route);
|
|
108
129
|
}
|
|
109
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Specificity tier for a segment. Lower = more specific. Matches the
|
|
133
|
+
* priority order in `matchNode`: static > affixed-dynamic > bare-dynamic >
|
|
134
|
+
* catch-all > optional-catch-all.
|
|
135
|
+
*/
|
|
136
|
+
function segmentTier<TFile>(node: SegmentNode<TFile>): number {
|
|
137
|
+
if (node.segmentType === 'static') return 0;
|
|
138
|
+
if (node.segmentType === 'dynamic' && (node.paramPrefix || node.paramSuffix)) return 1;
|
|
139
|
+
if (node.segmentType === 'dynamic') return 2;
|
|
140
|
+
if (node.segmentType === 'catch-all') return 3;
|
|
141
|
+
if (node.segmentType === 'optional-catch-all') return 4;
|
|
142
|
+
return 2;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Score a bare-dynamic candidate by running `matchNode` against its subtree
|
|
147
|
+
* with the remaining URL parts, then reading the tier of each segment in
|
|
148
|
+
* the matched chain. Returns a tier array that is compared lexicographically
|
|
149
|
+
* — the candidate whose chain has a lower tier at the first differing
|
|
150
|
+
* position is more specific.
|
|
151
|
+
*
|
|
152
|
+
* Delegates entirely to `matchNode`, so every segment type and priority
|
|
153
|
+
* rule is inherited automatically — no second copy to drift.
|
|
154
|
+
*/
|
|
155
|
+
function subtreeMatchScore<TFile>(
|
|
156
|
+
node: SegmentNode<TFile>,
|
|
157
|
+
parts: string[],
|
|
158
|
+
index: number,
|
|
159
|
+
requireTerminal: boolean
|
|
160
|
+
): number[] {
|
|
161
|
+
const chain: SegmentNode<TFile>[] = [];
|
|
162
|
+
const params: Record<string, string | string[]> = {};
|
|
163
|
+
if (!matchNode(node, parts, index, chain, params, requireTerminal)) return [];
|
|
164
|
+
// Skip the first entry (the candidate node itself — it's the bare-dynamic
|
|
165
|
+
// we're scoring, which is the same tier for all candidates).
|
|
166
|
+
return chain.slice(1).map((seg) => segmentTier(seg));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Compare two tier arrays lexicographically. Negative means `a` is more
|
|
171
|
+
* specific (should be tried first).
|
|
172
|
+
*/
|
|
173
|
+
function compareTierArrays(a: number[], b: number[]): number {
|
|
174
|
+
const len = Math.max(a.length, b.length);
|
|
175
|
+
for (let i = 0; i < len; i++) {
|
|
176
|
+
// A shorter match is less specific — missing entries treated as worst tier.
|
|
177
|
+
const ta = i < a.length ? a[i] : 5;
|
|
178
|
+
const tb = i < b.length ? b[i] : 5;
|
|
179
|
+
if (ta !== tb) return ta - tb;
|
|
180
|
+
}
|
|
181
|
+
return 0;
|
|
182
|
+
}
|
|
183
|
+
|
|
110
184
|
/** Combined affix length for specificity ordering. */
|
|
111
185
|
function affixLength<TFile>(node: SegmentNode<TFile>): number {
|
|
112
186
|
return (node.paramPrefix?.length ?? 0) + (node.paramSuffix?.length ?? 0);
|
|
@@ -206,32 +280,177 @@ function matchNode<TFile>(
|
|
|
206
280
|
}
|
|
207
281
|
|
|
208
282
|
// 3. Bare dynamic segments ([param])
|
|
283
|
+
// When multiple bare dynamics compete (common with route groups that
|
|
284
|
+
// flatten into the same parent), sort by subtree specificity so a
|
|
285
|
+
// branch containing a static match for the NEXT URL part is tried
|
|
286
|
+
// first. Without this, the winner depends on children order and
|
|
287
|
+
// renaming a route group can flip it — the exact shape the collision
|
|
288
|
+
// validator exists to reject.
|
|
289
|
+
const bareDynamic = effective.filter(
|
|
290
|
+
({ child }) =>
|
|
291
|
+
child.segmentType === 'dynamic' && child.paramName && !child.paramPrefix && !child.paramSuffix
|
|
292
|
+
);
|
|
293
|
+
if (bareDynamic.length > 1 && index + 1 < parts.length) {
|
|
294
|
+
const scores = new Map<SegmentNode<TFile>, number[]>();
|
|
295
|
+
for (const { child } of bareDynamic) {
|
|
296
|
+
scores.set(child, subtreeMatchScore(child, parts, index + 1, requireTerminal));
|
|
297
|
+
}
|
|
298
|
+
bareDynamic.sort((a, b) => compareTierArrays(scores.get(a.child)!, scores.get(b.child)!));
|
|
299
|
+
}
|
|
300
|
+
for (const { child, groupChain } of bareDynamic) {
|
|
301
|
+
chain.push(...groupChain);
|
|
302
|
+
const prev = params[child.paramName!];
|
|
303
|
+
params[child.paramName!] = part;
|
|
304
|
+
if (matchNode(child, parts, index + 1, chain, params, requireTerminal)) {
|
|
305
|
+
return true;
|
|
306
|
+
}
|
|
307
|
+
// Backtrack
|
|
308
|
+
chain.length -= groupChain.length;
|
|
309
|
+
if (prev !== undefined) {
|
|
310
|
+
params[child.paramName!] = prev;
|
|
311
|
+
} else {
|
|
312
|
+
delete params[child.paramName!];
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// 4. Catch-all segments ([...param]) — must be terminal unless the
|
|
317
|
+
// caller passed requireTerminal:false (metadata route matching needs to
|
|
318
|
+
// find the owning segment even when it has no page).
|
|
209
319
|
for (const { child, groupChain } of effective) {
|
|
210
320
|
if (
|
|
211
|
-
child.segmentType === '
|
|
321
|
+
child.segmentType === 'catch-all' &&
|
|
212
322
|
child.paramName &&
|
|
213
|
-
!child
|
|
214
|
-
!child.paramSuffix
|
|
323
|
+
(!requireTerminal || isTerminal(child))
|
|
215
324
|
) {
|
|
216
|
-
chain.push(...groupChain);
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
325
|
+
chain.push(...groupChain, child);
|
|
326
|
+
params[child.paramName] = parts.slice(index);
|
|
327
|
+
return true;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// 5. Optional catch-all segments ([[...param]]) — same terminal rule
|
|
332
|
+
for (const { child, groupChain } of effective) {
|
|
333
|
+
if (
|
|
334
|
+
child.segmentType === 'optional-catch-all' &&
|
|
335
|
+
child.paramName &&
|
|
336
|
+
(!requireTerminal || isTerminal(child))
|
|
337
|
+
) {
|
|
338
|
+
chain.push(...groupChain, child);
|
|
339
|
+
params[child.paramName] = parts.slice(index);
|
|
340
|
+
return true;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
chain.pop();
|
|
345
|
+
return false;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Generator twin of `matchNode` — yields every match instead of returning
|
|
350
|
+
* the first. Each yield snapshots `chain` and `params` so the caller gets
|
|
351
|
+
* independent results. After yielding, backtracking continues to find the
|
|
352
|
+
* next match.
|
|
353
|
+
*/
|
|
354
|
+
function* matchNodeAll<TFile>(
|
|
355
|
+
node: SegmentNode<TFile>,
|
|
356
|
+
parts: string[],
|
|
357
|
+
index: number,
|
|
358
|
+
chain: SegmentNode<TFile>[],
|
|
359
|
+
params: Record<string, string | string[]>,
|
|
360
|
+
requireTerminal: boolean
|
|
361
|
+
): Generator<TreeMatchResult<TFile>> {
|
|
362
|
+
chain.push(node);
|
|
363
|
+
|
|
364
|
+
if (index >= parts.length) {
|
|
365
|
+
if (!requireTerminal || isTerminal(node)) {
|
|
366
|
+
yield { chain: [...chain], params: { ...params } };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
for (const child of node.children) {
|
|
370
|
+
if (child.segmentType === 'group') {
|
|
371
|
+
yield* matchNodeAll(child, parts, index, chain, params, requireTerminal);
|
|
221
372
|
}
|
|
222
|
-
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const effective = collectEffectiveChildren(node);
|
|
376
|
+
for (const { child, groupChain } of effective) {
|
|
377
|
+
if (child.segmentType === 'optional-catch-all' && (!requireTerminal || isTerminal(child))) {
|
|
378
|
+
chain.push(...groupChain, child);
|
|
379
|
+
yield { chain: [...chain], params: { ...params } };
|
|
380
|
+
chain.length -= groupChain.length + 1;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
chain.pop();
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const part = parts[index];
|
|
389
|
+
const effective = collectEffectiveChildren(node);
|
|
390
|
+
|
|
391
|
+
// 1. Static segments
|
|
392
|
+
for (const { child, groupChain } of effective) {
|
|
393
|
+
if (child.segmentType === 'static' && child.segmentName === part) {
|
|
394
|
+
chain.push(...groupChain);
|
|
395
|
+
yield* matchNodeAll(child, parts, index + 1, chain, params, requireTerminal);
|
|
396
|
+
chain.length -= groupChain.length;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// 2. Affixed dynamic segments
|
|
401
|
+
const affixed = effective.filter(
|
|
402
|
+
({ child }) =>
|
|
403
|
+
child.segmentType === 'dynamic' && child.paramName && (child.paramPrefix || child.paramSuffix)
|
|
404
|
+
);
|
|
405
|
+
affixed.sort((a, b) => affixLength(b.child) - affixLength(a.child));
|
|
406
|
+
for (const { child, groupChain } of affixed) {
|
|
407
|
+
const prefix = child.paramPrefix ?? '';
|
|
408
|
+
const suffix = child.paramSuffix ?? '';
|
|
409
|
+
if (
|
|
410
|
+
part.length > prefix.length + suffix.length &&
|
|
411
|
+
(!prefix || part.startsWith(prefix)) &&
|
|
412
|
+
(!suffix || part.endsWith(suffix))
|
|
413
|
+
) {
|
|
414
|
+
const value = part.slice(prefix.length, part.length - (suffix.length || 0));
|
|
415
|
+
chain.push(...groupChain);
|
|
416
|
+
const prev = params[child.paramName!];
|
|
417
|
+
params[child.paramName!] = value;
|
|
418
|
+
yield* matchNodeAll(child, parts, index + 1, chain, params, requireTerminal);
|
|
223
419
|
chain.length -= groupChain.length;
|
|
224
420
|
if (prev !== undefined) {
|
|
225
|
-
params[child.paramName] = prev;
|
|
421
|
+
params[child.paramName!] = prev;
|
|
226
422
|
} else {
|
|
227
|
-
delete params[child.paramName];
|
|
423
|
+
delete params[child.paramName!];
|
|
228
424
|
}
|
|
229
425
|
}
|
|
230
426
|
}
|
|
231
427
|
|
|
232
|
-
//
|
|
233
|
-
|
|
234
|
-
|
|
428
|
+
// 3. Bare dynamic segments
|
|
429
|
+
const bareDynamic = effective.filter(
|
|
430
|
+
({ child }) =>
|
|
431
|
+
child.segmentType === 'dynamic' && child.paramName && !child.paramPrefix && !child.paramSuffix
|
|
432
|
+
);
|
|
433
|
+
if (bareDynamic.length > 1 && index + 1 < parts.length) {
|
|
434
|
+
const scores = new Map<SegmentNode<TFile>, number[]>();
|
|
435
|
+
for (const { child } of bareDynamic) {
|
|
436
|
+
scores.set(child, subtreeMatchScore(child, parts, index + 1, requireTerminal));
|
|
437
|
+
}
|
|
438
|
+
bareDynamic.sort((a, b) => compareTierArrays(scores.get(a.child)!, scores.get(b.child)!));
|
|
439
|
+
}
|
|
440
|
+
for (const { child, groupChain } of bareDynamic) {
|
|
441
|
+
chain.push(...groupChain);
|
|
442
|
+
const prev = params[child.paramName!];
|
|
443
|
+
params[child.paramName!] = part;
|
|
444
|
+
yield* matchNodeAll(child, parts, index + 1, chain, params, requireTerminal);
|
|
445
|
+
chain.length -= groupChain.length;
|
|
446
|
+
if (prev !== undefined) {
|
|
447
|
+
params[child.paramName!] = prev;
|
|
448
|
+
} else {
|
|
449
|
+
delete params[child.paramName!];
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// 4. Catch-all segments
|
|
235
454
|
for (const { child, groupChain } of effective) {
|
|
236
455
|
if (
|
|
237
456
|
child.segmentType === 'catch-all' &&
|
|
@@ -239,12 +458,19 @@ function matchNode<TFile>(
|
|
|
239
458
|
(!requireTerminal || isTerminal(child))
|
|
240
459
|
) {
|
|
241
460
|
chain.push(...groupChain, child);
|
|
461
|
+
const prev = params[child.paramName];
|
|
242
462
|
params[child.paramName] = parts.slice(index);
|
|
243
|
-
|
|
463
|
+
yield { chain: [...chain], params: { ...params } };
|
|
464
|
+
chain.length -= groupChain.length + 1;
|
|
465
|
+
if (prev !== undefined) {
|
|
466
|
+
params[child.paramName] = prev;
|
|
467
|
+
} else {
|
|
468
|
+
delete params[child.paramName];
|
|
469
|
+
}
|
|
244
470
|
}
|
|
245
471
|
}
|
|
246
472
|
|
|
247
|
-
// 5. Optional catch-all segments
|
|
473
|
+
// 5. Optional catch-all segments
|
|
248
474
|
for (const { child, groupChain } of effective) {
|
|
249
475
|
if (
|
|
250
476
|
child.segmentType === 'optional-catch-all' &&
|
|
@@ -252,11 +478,17 @@ function matchNode<TFile>(
|
|
|
252
478
|
(!requireTerminal || isTerminal(child))
|
|
253
479
|
) {
|
|
254
480
|
chain.push(...groupChain, child);
|
|
481
|
+
const prev = params[child.paramName];
|
|
255
482
|
params[child.paramName] = parts.slice(index);
|
|
256
|
-
|
|
483
|
+
yield { chain: [...chain], params: { ...params } };
|
|
484
|
+
chain.length -= groupChain.length + 1;
|
|
485
|
+
if (prev !== undefined) {
|
|
486
|
+
params[child.paramName] = prev;
|
|
487
|
+
} else {
|
|
488
|
+
delete params[child.paramName];
|
|
489
|
+
}
|
|
257
490
|
}
|
|
258
491
|
}
|
|
259
492
|
|
|
260
493
|
chain.pop();
|
|
261
|
-
return false;
|
|
262
494
|
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"tree-match-CdbvYTBz.js","names":[],"sources":["../../src/server/tree-match.ts"],"sourcesContent":["/**\n * Shared URL-matching tree walker for segment trees.\n *\n * Both the main route matcher (`route-matcher.ts`) and slot resolver\n * (`slot-resolver.ts`) need to walk a SegmentNode tree, matching URL\n * parts against children with the same priority semantics:\n *\n * static > dynamic > catch-all > optional-catch-all\n *\n * Before this module, each had its own walker with subtle divergences\n * (different group handling, missing backtracking, incomplete\n * optional-catch-all support). This shared walker is the single\n * source of truth for URL-to-segment matching.\n *\n * See design/07-routing.md §\"Request Lifecycle\" and TIM-940.\n */\n\nimport type { SegmentNode } from '../routing/types.ts';\n\n// ─── Public API ───────────────────────────────────────────────────────────\n\n/** Result of matching URL parts against a segment tree. */\nexport interface TreeMatchResult<TFile> {\n /** Matched segment chain from root to leaf, inclusive. */\n chain: SegmentNode<TFile>[];\n /** Collected params, keyed by param name. Catch-all params are string[]. */\n params: Record<string, string | string[]>;\n}\n\nexport interface TreeMatchOptions {\n /**\n * When true (default), only match if the leaf node has a page or route\n * handler. Set to false for metadata route matching, where we just need\n * to find the owning segment regardless of whether it has a page.\n */\n requireTerminal?: boolean;\n}\n\n/**\n * Match URL path parts against a segment tree.\n *\n * Walks the tree depth-first with backtracking, applying priority\n * ordering globally across group boundaries. Returns the matched\n * segment chain and collected params, or null if no route matches.\n *\n * By default, a match requires a terminal node (one with `page` or\n * `route`) at the leaf. Pass `{ requireTerminal: false }` to match\n * any segment (used by metadata route matching).\n *\n * @param root The root segment node to start matching from.\n * @param parts URL path parts (e.g. ['blog', 'hello-world']).\n * Empty array matches the root itself (if terminal).\n */\nexport function matchUrlParts<TFile>(\n root: SegmentNode<TFile>,\n parts: string[],\n options?: TreeMatchOptions\n): TreeMatchResult<TFile> | null {\n const chain: SegmentNode<TFile>[] = [];\n const params: Record<string, string | string[]> = {};\n const requireTerminal = options?.requireTerminal ?? true;\n\n if (matchNode(root, parts, 0, chain, params, requireTerminal)) {\n return { chain, params };\n }\n return null;\n}\n\n// ─── Internal ─────────────────────────────────────────────────────────────\n\n/**\n * A child node flattened through group segments, along with the\n * chain of group nodes that must appear in the segment chain\n * before the child to preserve the tree path.\n */\ninterface EffectiveChild<TFile> {\n child: SegmentNode<TFile>;\n groupChain: SegmentNode<TFile>[];\n}\n\n/**\n * Flatten children through group segments.\n *\n * Groups are transparent for URL matching — their non-group descendants\n * are returned with the chain of group nodes leading to them. This lets\n * the caller apply priority ordering (static > dynamic > catch-all >\n * optional-catch-all) across all groups uniformly instead of per-group.\n */\nfunction collectEffectiveChildren<TFile>(\n node: SegmentNode<TFile>,\n groupChain: SegmentNode<TFile>[] = []\n): EffectiveChild<TFile>[] {\n const result: EffectiveChild<TFile>[] = [];\n for (const child of node.children) {\n if (child.segmentType === 'group') {\n const nested = collectEffectiveChildren(child, [...groupChain, child]);\n result.push(...nested);\n } else {\n result.push({ child, groupChain });\n }\n }\n return result;\n}\n\n/** A node is terminal if it can serve a response (has a page or route handler). */\nfunction isTerminal<TFile>(node: SegmentNode<TFile>): boolean {\n return !!(node.page || node.route);\n}\n\n/** Combined affix length for specificity ordering. */\nfunction affixLength<TFile>(node: SegmentNode<TFile>): number {\n return (node.paramPrefix?.length ?? 0) + (node.paramSuffix?.length ?? 0);\n}\n\n/**\n * Recursive depth-first matcher with backtracking.\n *\n * Mutates `chain` and `params` in place for performance; backtracks\n * (restores previous state) when a candidate doesn't lead to a match.\n */\nfunction matchNode<TFile>(\n node: SegmentNode<TFile>,\n parts: string[],\n index: number,\n chain: SegmentNode<TFile>[],\n params: Record<string, string | string[]>,\n requireTerminal: boolean\n): boolean {\n chain.push(node);\n\n // All parts consumed — check if this node is terminal (or any node if !requireTerminal)\n if (index >= parts.length) {\n if (!requireTerminal || isTerminal(node)) return true;\n\n // Groups don't consume URL segments, so a group child's page can\n // match at the same URL depth. Recurse into groups transparently.\n for (const child of node.children) {\n if (child.segmentType === 'group') {\n if (matchNode(child, parts, index, chain, params, requireTerminal)) {\n return true;\n }\n }\n }\n\n // Optional catch-all with zero segments (direct and through groups).\n // The param is NOT set — matching Next.js semantics for [[...slug]]\n // with no path segments.\n const effective = collectEffectiveChildren(node);\n for (const { child, groupChain } of effective) {\n if (child.segmentType === 'optional-catch-all' && (!requireTerminal || isTerminal(child))) {\n chain.push(...groupChain, child);\n return true;\n }\n }\n\n chain.pop();\n return false;\n }\n\n const part = parts[index];\n\n // Flatten children through groups so priority ordering applies globally.\n const effective = collectEffectiveChildren(node);\n\n // 1. Static segments (exact name match)\n for (const { child, groupChain } of effective) {\n if (child.segmentType === 'static' && child.segmentName === part) {\n chain.push(...groupChain);\n if (matchNode(child, parts, index + 1, chain, params, requireTerminal)) {\n return true;\n }\n chain.length -= groupChain.length;\n }\n }\n\n // 2. Affixed dynamic segments (e.g. img-[id].png) — more specific than bare dynamic.\n // Sorted by specificity: longer combined prefix+suffix wins.\n const affixed = effective.filter(\n ({ child }) =>\n child.segmentType === 'dynamic' && child.paramName && (child.paramPrefix || child.paramSuffix)\n );\n affixed.sort((a, b) => affixLength(b.child) - affixLength(a.child));\n for (const { child, groupChain } of affixed) {\n const prefix = child.paramPrefix ?? '';\n const suffix = child.paramSuffix ?? '';\n if (\n part.length > prefix.length + suffix.length &&\n (!prefix || part.startsWith(prefix)) &&\n (!suffix || part.endsWith(suffix))\n ) {\n const value = part.slice(prefix.length, part.length - (suffix.length || 0));\n chain.push(...groupChain);\n const prev = params[child.paramName!];\n params[child.paramName!] = value;\n if (matchNode(child, parts, index + 1, chain, params, requireTerminal)) {\n return true;\n }\n // Backtrack\n chain.length -= groupChain.length;\n if (prev !== undefined) {\n params[child.paramName!] = prev;\n } else {\n delete params[child.paramName!];\n }\n }\n }\n\n // 3. Bare dynamic segments ([param])\n for (const { child, groupChain } of effective) {\n if (\n child.segmentType === 'dynamic' &&\n child.paramName &&\n !child.paramPrefix &&\n !child.paramSuffix\n ) {\n chain.push(...groupChain);\n const prev = params[child.paramName];\n params[child.paramName] = part;\n if (matchNode(child, parts, index + 1, chain, params, requireTerminal)) {\n return true;\n }\n // Backtrack\n chain.length -= groupChain.length;\n if (prev !== undefined) {\n params[child.paramName] = prev;\n } else {\n delete params[child.paramName];\n }\n }\n }\n\n // 4. Catch-all segments ([...param]) — must be terminal unless the\n // caller passed requireTerminal:false (metadata route matching needs to\n // find the owning segment even when it has no page).\n for (const { child, groupChain } of effective) {\n if (\n child.segmentType === 'catch-all' &&\n child.paramName &&\n (!requireTerminal || isTerminal(child))\n ) {\n chain.push(...groupChain, child);\n params[child.paramName] = parts.slice(index);\n return true;\n }\n }\n\n // 5. Optional catch-all segments ([[...param]]) — same terminal rule\n for (const { child, groupChain } of effective) {\n if (\n child.segmentType === 'optional-catch-all' &&\n child.paramName &&\n (!requireTerminal || isTerminal(child))\n ) {\n chain.push(...groupChain, child);\n params[child.paramName] = parts.slice(index);\n return true;\n }\n }\n\n chain.pop();\n return false;\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAqDA,SAAgB,cACd,MACA,OACA,SAC+B;CAC/B,MAAM,QAA8B,CAAC;CACrC,MAAM,SAA4C,CAAC;CAGnD,IAAI,UAAU,MAAM,OAAO,GAAG,OAAO,QAFb,SAAS,mBAAmB,IAEQ,GAC1D,OAAO;EAAE;EAAO;CAAO;CAEzB,OAAO;AACT;;;;;;;;;AAsBA,SAAS,yBACP,MACA,aAAmC,CAAC,GACX;CACzB,MAAM,SAAkC,CAAC;CACzC,KAAK,MAAM,SAAS,KAAK,UACvB,IAAI,MAAM,gBAAgB,SAAS;EACjC,MAAM,SAAS,yBAAyB,OAAO,CAAC,GAAG,YAAY,KAAK,CAAC;EACrE,OAAO,KAAK,GAAG,MAAM;CACvB,OACE,OAAO,KAAK;EAAE;EAAO;CAAW,CAAC;CAGrC,OAAO;AACT;;AAGA,SAAS,WAAkB,MAAmC;CAC5D,OAAO,CAAC,EAAE,KAAK,QAAQ,KAAK;AAC9B;;AAGA,SAAS,YAAmB,MAAkC;CAC5D,QAAQ,KAAK,aAAa,UAAU,MAAM,KAAK,aAAa,UAAU;AACxE;;;;;;;AAQA,SAAS,UACP,MACA,OACA,OACA,OACA,QACA,iBACS;CACT,MAAM,KAAK,IAAI;CAGf,IAAI,SAAS,MAAM,QAAQ;EACzB,IAAI,CAAC,mBAAmB,WAAW,IAAI,GAAG,OAAO;EAIjD,KAAK,MAAM,SAAS,KAAK,UACvB,IAAI,MAAM,gBAAgB,SACpB;OAAA,UAAU,OAAO,OAAO,OAAO,OAAO,QAAQ,eAAe,GAC/D,OAAO;EAAA;EAQb,MAAM,YAAY,yBAAyB,IAAI;EAC/C,KAAK,MAAM,EAAE,OAAO,gBAAgB,WAClC,IAAI,MAAM,gBAAgB,yBAAyB,CAAC,mBAAmB,WAAW,KAAK,IAAI;GACzF,MAAM,KAAK,GAAG,YAAY,KAAK;GAC/B,OAAO;EACT;EAGF,MAAM,IAAI;EACV,OAAO;CACT;CAEA,MAAM,OAAO,MAAM;CAGnB,MAAM,YAAY,yBAAyB,IAAI;CAG/C,KAAK,MAAM,EAAE,OAAO,gBAAgB,WAClC,IAAI,MAAM,gBAAgB,YAAY,MAAM,gBAAgB,MAAM;EAChE,MAAM,KAAK,GAAG,UAAU;EACxB,IAAI,UAAU,OAAO,OAAO,QAAQ,GAAG,OAAO,QAAQ,eAAe,GACnE,OAAO;EAET,MAAM,UAAU,WAAW;CAC7B;CAKF,MAAM,UAAU,UAAU,QACvB,EAAE,YACD,MAAM,gBAAgB,aAAa,MAAM,cAAc,MAAM,eAAe,MAAM,YACtF;CACA,QAAQ,MAAM,GAAG,MAAM,YAAY,EAAE,KAAK,IAAI,YAAY,EAAE,KAAK,CAAC;CAClE,KAAK,MAAM,EAAE,OAAO,gBAAgB,SAAS;EAC3C,MAAM,SAAS,MAAM,eAAe;EACpC,MAAM,SAAS,MAAM,eAAe;EACpC,IACE,KAAK,SAAS,OAAO,SAAS,OAAO,WACpC,CAAC,UAAU,KAAK,WAAW,MAAM,OACjC,CAAC,UAAU,KAAK,SAAS,MAAM,IAChC;GACA,MAAM,QAAQ,KAAK,MAAM,OAAO,QAAQ,KAAK,UAAU,OAAO,UAAU,EAAE;GAC1E,MAAM,KAAK,GAAG,UAAU;GACxB,MAAM,OAAO,OAAO,MAAM;GAC1B,OAAO,MAAM,aAAc;GAC3B,IAAI,UAAU,OAAO,OAAO,QAAQ,GAAG,OAAO,QAAQ,eAAe,GACnE,OAAO;GAGT,MAAM,UAAU,WAAW;GAC3B,IAAI,SAAS,KAAA,GACX,OAAO,MAAM,aAAc;QAE3B,OAAO,OAAO,MAAM;EAExB;CACF;CAGA,KAAK,MAAM,EAAE,OAAO,gBAAgB,WAClC,IACE,MAAM,gBAAgB,aACtB,MAAM,aACN,CAAC,MAAM,eACP,CAAC,MAAM,aACP;EACA,MAAM,KAAK,GAAG,UAAU;EACxB,MAAM,OAAO,OAAO,MAAM;EAC1B,OAAO,MAAM,aAAa;EAC1B,IAAI,UAAU,OAAO,OAAO,QAAQ,GAAG,OAAO,QAAQ,eAAe,GACnE,OAAO;EAGT,MAAM,UAAU,WAAW;EAC3B,IAAI,SAAS,KAAA,GACX,OAAO,MAAM,aAAa;OAE1B,OAAO,OAAO,MAAM;CAExB;CAMF,KAAK,MAAM,EAAE,OAAO,gBAAgB,WAClC,IACE,MAAM,gBAAgB,eACtB,MAAM,cACL,CAAC,mBAAmB,WAAW,KAAK,IACrC;EACA,MAAM,KAAK,GAAG,YAAY,KAAK;EAC/B,OAAO,MAAM,aAAa,MAAM,MAAM,KAAK;EAC3C,OAAO;CACT;CAIF,KAAK,MAAM,EAAE,OAAO,gBAAgB,WAClC,IACE,MAAM,gBAAgB,wBACtB,MAAM,cACL,CAAC,mBAAmB,WAAW,KAAK,IACrC;EACA,MAAM,KAAK,GAAG,YAAY,KAAK;EAC/B,OAAO,MAAM,aAAa,MAAM,MAAM,KAAK;EAC3C,OAAO;CACT;CAGF,MAAM,IAAI;CACV,OAAO;AACT"}
|