@pyreon/router 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/match.ts CHANGED
@@ -65,7 +65,335 @@ export function stringifyQuery(query: Record<string, string>): string {
65
65
  return parts.length ? `?${parts.join("&")}` : ""
66
66
  }
67
67
 
68
- // ─── Path matching ────────────────────────────────────────────────────────────
68
+ // ─── Compiled route structures ───────────────────────────────────────────────
69
+
70
+ /**
71
+ * Pre-compiled segment info — computed once per route, reused on every match.
72
+ * Avoids repeated split()/filter()/startsWith(":") on hot paths.
73
+ */
74
+ interface CompiledSegment {
75
+ /** Original segment string */
76
+ raw: string
77
+ /** true if this segment is a `:param` */
78
+ isParam: boolean
79
+ /** true if this segment is a `:param*` splat */
80
+ isSplat: boolean
81
+ /** true if this segment is a `:param?` optional */
82
+ isOptional: boolean
83
+ /** Param name (without leading `:` and trailing `*`/`?`) — empty for static segments */
84
+ paramName: string
85
+ }
86
+
87
+ interface CompiledRoute {
88
+ route: RouteRecord
89
+ /** true for wildcard patterns: `(.*)` or `*` */
90
+ isWildcard: boolean
91
+ /** Pre-split and classified segments */
92
+ segments: CompiledSegment[]
93
+ /** Number of segments */
94
+ segmentCount: number
95
+ /** true if route has no dynamic segments (pure static) */
96
+ isStatic: boolean
97
+ /** For static routes: the normalized path (e.g. "/about") for Map lookup */
98
+ staticPath: string | null
99
+ /** Compiled children (lazily populated) */
100
+ children: CompiledRoute[] | null
101
+ /** First static segment (for dispatch index), null if first segment is dynamic or route is wildcard */
102
+ firstSegment: string | null
103
+ }
104
+
105
+ /**
106
+ * A flattened route entry — pre-joins parent+child segments at compile time
107
+ * so nested routes can be matched in a single pass without recursion.
108
+ */
109
+ interface FlattenedRoute {
110
+ /** All segments from root to leaf, concatenated */
111
+ segments: CompiledSegment[]
112
+ segmentCount: number
113
+ /** The full matched chain from root to leaf (e.g. [adminLayout, usersPage]) */
114
+ matchedChain: RouteRecord[]
115
+ /** true if all segments are static */
116
+ isStatic: boolean
117
+ /** For static flattened routes: the full joined path */
118
+ staticPath: string | null
119
+ /** Pre-merged meta from all routes in the chain */
120
+ meta: RouteMeta
121
+ /** First static segment for dispatch index */
122
+ firstSegment: string | null
123
+ /** true if any segment is a splat */
124
+ hasSplat: boolean
125
+ /** true if this is a wildcard catch-all route (`*` or `(.*)`) */
126
+ isWildcard: boolean
127
+ /** true if any segment is optional (`:param?`) */
128
+ hasOptional: boolean
129
+ /** Minimum number of segments that must be present (excluding trailing optionals) */
130
+ minSegments: number
131
+ }
132
+
133
+ /** WeakMap cache: compile each RouteRecord[] once */
134
+ const _compiledCache = new WeakMap<RouteRecord[], CompiledRoute[]>()
135
+
136
+ function compileSegment(raw: string): CompiledSegment {
137
+ if (raw.endsWith("*") && raw.startsWith(":")) {
138
+ return { raw, isParam: true, isSplat: true, isOptional: false, paramName: raw.slice(1, -1) }
139
+ }
140
+ if (raw.endsWith("?") && raw.startsWith(":")) {
141
+ return { raw, isParam: true, isSplat: false, isOptional: true, paramName: raw.slice(1, -1) }
142
+ }
143
+ if (raw.startsWith(":")) {
144
+ return { raw, isParam: true, isSplat: false, isOptional: false, paramName: raw.slice(1) }
145
+ }
146
+ return { raw, isParam: false, isSplat: false, isOptional: false, paramName: "" }
147
+ }
148
+
149
+ function compileRoute(route: RouteRecord): CompiledRoute {
150
+ const pattern = route.path
151
+ const isWildcard = pattern === "(.*)" || pattern === "*"
152
+
153
+ if (isWildcard) {
154
+ return {
155
+ route,
156
+ isWildcard: true,
157
+ segments: [],
158
+ segmentCount: 0,
159
+ isStatic: false,
160
+ staticPath: null,
161
+ children: null,
162
+ firstSegment: null,
163
+ }
164
+ }
165
+
166
+ const segments = pattern.split("/").filter(Boolean).map(compileSegment)
167
+ const isStatic = segments.every((s) => !s.isParam)
168
+ const staticPath = isStatic ? `/${segments.map((s) => s.raw).join("/")}` : null
169
+ const first = segments.length > 0 ? segments[0] : undefined
170
+ const firstSegment = first && !first.isParam ? first.raw : null
171
+
172
+ return {
173
+ route,
174
+ isWildcard: false,
175
+ segments,
176
+ segmentCount: segments.length,
177
+ isStatic,
178
+ staticPath,
179
+ children: null,
180
+ firstSegment,
181
+ }
182
+ }
183
+
184
+ /** Expand alias paths into additional compiled entries sharing the original RouteRecord */
185
+ function expandAliases(r: RouteRecord, c: CompiledRoute): CompiledRoute[] {
186
+ if (!r.alias) return []
187
+ const aliases = Array.isArray(r.alias) ? r.alias : [r.alias]
188
+ return aliases.map((aliasPath) => {
189
+ const { alias: _, ...withoutAlias } = r
190
+ const ac = compileRoute({ ...withoutAlias, path: aliasPath })
191
+ ac.children = c.children
192
+ ac.route = r
193
+ return ac
194
+ })
195
+ }
196
+
197
+ function compileRoutes(routes: RouteRecord[]): CompiledRoute[] {
198
+ const cached = _compiledCache.get(routes)
199
+ if (cached) return cached
200
+
201
+ const compiled: CompiledRoute[] = []
202
+ for (const r of routes) {
203
+ const c = compileRoute(r)
204
+ if (r.children && r.children.length > 0) {
205
+ c.children = compileRoutes(r.children)
206
+ }
207
+ compiled.push(c)
208
+ compiled.push(...expandAliases(r, c))
209
+ }
210
+ _compiledCache.set(routes, compiled)
211
+ return compiled
212
+ }
213
+
214
+ // ─── Route flattening ────────────────────────────────────────────────────────
215
+
216
+ /** Extract first static segment from a segment list, or null if dynamic/empty */
217
+ function getFirstSegment(segments: CompiledSegment[]): string | null {
218
+ const first = segments[0]
219
+ if (first && !first.isParam) return first.raw
220
+ return null
221
+ }
222
+
223
+ /** Build a FlattenedRoute from segments + metadata */
224
+ function makeFlatEntry(
225
+ segments: CompiledSegment[],
226
+ chain: RouteRecord[],
227
+ meta: RouteMeta,
228
+ isWildcard: boolean,
229
+ ): FlattenedRoute {
230
+ const isStatic = !isWildcard && segments.every((s) => !s.isParam)
231
+ const hasOptional = segments.some((s) => s.isOptional)
232
+ // minSegments: count of segments up to and not including trailing optionals
233
+ let minSegs = segments.length
234
+ if (hasOptional) {
235
+ while (minSegs > 0 && segments[minSegs - 1]?.isOptional) minSegs--
236
+ }
237
+ return {
238
+ segments,
239
+ segmentCount: segments.length,
240
+ matchedChain: chain,
241
+ isStatic,
242
+ staticPath: isStatic ? `/${segments.map((s) => s.raw).join("/")}` : null,
243
+ meta,
244
+ firstSegment: getFirstSegment(segments),
245
+ hasSplat: segments.some((s) => s.isSplat),
246
+ isWildcard,
247
+ hasOptional,
248
+ minSegments: minSegs,
249
+ }
250
+ }
251
+
252
+ /**
253
+ * Flatten nested routes into leaf entries with pre-joined segments.
254
+ * This eliminates recursion during matching for the common case.
255
+ */
256
+ function flattenRoutes(compiled: CompiledRoute[]): FlattenedRoute[] {
257
+ const result: FlattenedRoute[] = []
258
+ flattenWalk(result, compiled, [], [], {})
259
+ return result
260
+ }
261
+
262
+ function flattenWalk(
263
+ result: FlattenedRoute[],
264
+ routes: CompiledRoute[],
265
+ parentSegments: CompiledSegment[],
266
+ parentChain: RouteRecord[],
267
+ parentMeta: RouteMeta,
268
+ ): void {
269
+ for (const c of routes) {
270
+ const chain = [...parentChain, c.route]
271
+ const meta = c.route.meta ? { ...parentMeta, ...c.route.meta } : { ...parentMeta }
272
+ flattenOne(result, c, parentSegments, chain, meta)
273
+ }
274
+ }
275
+
276
+ function flattenOne(
277
+ result: FlattenedRoute[],
278
+ c: CompiledRoute,
279
+ parentSegments: CompiledSegment[],
280
+ chain: RouteRecord[],
281
+ meta: RouteMeta,
282
+ ): void {
283
+ if (c.isWildcard) {
284
+ result.push(makeFlatEntry(parentSegments, chain, meta, true))
285
+ if (c.children && c.children.length > 0) {
286
+ flattenWalk(result, c.children, parentSegments, chain, meta)
287
+ }
288
+ return
289
+ }
290
+
291
+ const joined = [...parentSegments, ...c.segments]
292
+ if (c.children && c.children.length > 0) {
293
+ flattenWalk(result, c.children, joined, chain, meta)
294
+ }
295
+ result.push(makeFlatEntry(joined, chain, meta, false))
296
+ }
297
+
298
+ // ─── Combined index ─────────────────────────────────────────────────────────
299
+
300
+ interface RouteIndex {
301
+ /** O(1) lookup for fully static paths (including nested) */
302
+ staticMap: Map<string, FlattenedRoute>
303
+ /** First-segment dispatch: maps first path segment → candidate routes */
304
+ segmentMap: Map<string, FlattenedRoute[]>
305
+ /** Routes whose first segment is dynamic (fallback) */
306
+ dynamicFirst: FlattenedRoute[]
307
+ /** Wildcard/catch-all routes */
308
+ wildcards: FlattenedRoute[]
309
+ }
310
+
311
+ const _indexCache = new WeakMap<RouteRecord[], RouteIndex>()
312
+
313
+ /** Classify a single flattened route into the appropriate index bucket */
314
+ function indexFlatRoute(
315
+ f: FlattenedRoute,
316
+ staticMap: Map<string, FlattenedRoute>,
317
+ segmentMap: Map<string, FlattenedRoute[]>,
318
+ dynamicFirst: FlattenedRoute[],
319
+ wildcards: FlattenedRoute[],
320
+ ): void {
321
+ // Static map: first static entry wins (preserves definition order)
322
+ if (f.isStatic && f.staticPath && !staticMap.has(f.staticPath)) {
323
+ staticMap.set(f.staticPath, f)
324
+ }
325
+
326
+ if (f.isWildcard) {
327
+ wildcards.push(f)
328
+ return
329
+ }
330
+
331
+ // Root route "/" has 0 segments — already in static map
332
+ if (f.segmentCount === 0) return
333
+
334
+ // First-segment dispatch
335
+ if (f.firstSegment) {
336
+ let bucket = segmentMap.get(f.firstSegment)
337
+ if (!bucket) {
338
+ bucket = []
339
+ segmentMap.set(f.firstSegment, bucket)
340
+ }
341
+ bucket.push(f)
342
+ } else {
343
+ dynamicFirst.push(f)
344
+ }
345
+ }
346
+
347
+ function buildRouteIndex(routes: RouteRecord[], compiled: CompiledRoute[]): RouteIndex {
348
+ const cached = _indexCache.get(routes)
349
+ if (cached) return cached
350
+
351
+ const flattened = flattenRoutes(compiled)
352
+
353
+ const staticMap = new Map<string, FlattenedRoute>()
354
+ const segmentMap = new Map<string, FlattenedRoute[]>()
355
+ const dynamicFirst: FlattenedRoute[] = []
356
+ const wildcards: FlattenedRoute[] = []
357
+
358
+ for (const f of flattened) {
359
+ indexFlatRoute(f, staticMap, segmentMap, dynamicFirst, wildcards)
360
+ }
361
+
362
+ const index: RouteIndex = { staticMap, segmentMap, dynamicFirst, wildcards }
363
+ _indexCache.set(routes, index)
364
+ return index
365
+ }
366
+
367
+ // ─── Fast path splitting ─────────────────────────────────────────────────────
368
+
369
+ /** Split path into segments without allocating a filtered array */
370
+ function splitPath(path: string): string[] {
371
+ // Fast path for common cases
372
+ if (path === "/") return []
373
+ // Remove leading slash, split, no filter needed if path is clean
374
+ const start = path.charCodeAt(0) === 47 /* / */ ? 1 : 0
375
+ const end = path.length
376
+ if (start >= end) return []
377
+
378
+ const parts: string[] = []
379
+ let segStart = start
380
+ for (let i = start; i <= end; i++) {
381
+ if (i === end || path.charCodeAt(i) === 47 /* / */) {
382
+ if (i > segStart) {
383
+ parts.push(path.substring(segStart, i))
384
+ }
385
+ segStart = i + 1
386
+ }
387
+ }
388
+ return parts
389
+ }
390
+
391
+ /** Decode only if the segment contains a `%` character */
392
+ function decodeSafe(s: string): string {
393
+ return s.indexOf("%") >= 0 ? decodeURIComponent(s) : s
394
+ }
395
+
396
+ // ─── Path matching (compiled) ────────────────────────────────────────────────
69
397
 
70
398
  /**
71
399
  * Match a single route pattern against a path segment.
@@ -76,8 +404,31 @@ export function stringifyQuery(query: Record<string, string>): string {
76
404
  * - Param segments: "/user/:id"
77
405
  * - Wildcard: "(.*)" matches everything
78
406
  */
407
+ /** Match a single pattern segment against a path segment, extracting params. Returns false on mismatch. */
408
+ function matchPatternSegment(
409
+ pp: string,
410
+ pt: string | undefined,
411
+ params: Record<string, string>,
412
+ pathParts: string[],
413
+ i: number,
414
+ ): "splat" | "continue" | "fail" {
415
+ if (pp.endsWith("*") && pp.startsWith(":")) {
416
+ params[pp.slice(1, -1)] = pathParts.slice(i).map(decodeURIComponent).join("/")
417
+ return "splat"
418
+ }
419
+ if (pp.endsWith("?") && pp.startsWith(":")) {
420
+ if (pt !== undefined) params[pp.slice(1, -1)] = decodeURIComponent(pt)
421
+ return "continue"
422
+ }
423
+ if (pt === undefined) return "fail"
424
+ if (pp.startsWith(":")) {
425
+ params[pp.slice(1)] = decodeURIComponent(pt)
426
+ return "continue"
427
+ }
428
+ return pp === pt ? "continue" : "fail"
429
+ }
430
+
79
431
  export function matchPath(pattern: string, path: string): Record<string, string> | null {
80
- // Wildcard pattern
81
432
  if (pattern === "(.*)" || pattern === "*") return {}
82
433
 
83
434
  const patternParts = pattern.split("/").filter(Boolean)
@@ -85,66 +436,102 @@ export function matchPath(pattern: string, path: string): Record<string, string>
85
436
 
86
437
  const params: Record<string, string> = {}
87
438
  for (let i = 0; i < patternParts.length; i++) {
88
- const pp = patternParts[i] as string
89
- const pt = pathParts[i] as string
90
- // Splat param — captures the rest of the path (e.g. ":path*")
91
- if (pp.endsWith("*") && pp.startsWith(":")) {
92
- const paramName = pp.slice(1, -1)
93
- params[paramName] = pathParts.slice(i).map(decodeURIComponent).join("/")
94
- return params
95
- }
96
- if (pp.startsWith(":")) {
97
- params[pp.slice(1)] = decodeURIComponent(pt)
98
- } else if (pp !== pt) {
99
- return null
100
- }
439
+ const result = matchPatternSegment(
440
+ patternParts[i] as string,
441
+ pathParts[i],
442
+ params,
443
+ pathParts,
444
+ i,
445
+ )
446
+ if (result === "splat") return params
447
+ if (result === "fail") return null
101
448
  }
102
449
 
103
- if (patternParts.length !== pathParts.length) return null
450
+ if (pathParts.length > patternParts.length) return null
104
451
  return params
105
452
  }
106
453
 
107
- /**
108
- * Check if a path starts with a route's prefix (for nested route matching).
109
- * Returns the remaining path suffix, or null if no match.
110
- */
111
- function matchPrefix(
112
- pattern: string,
113
- path: string,
114
- ): { params: Record<string, string>; rest: string } | null {
115
- if (pattern === "(.*)" || pattern === "*") return { params: {}, rest: path }
454
+ // ─── Compiled matching helpers ────────────────────────────────────────────────
116
455
 
117
- const patternParts = pattern.split("/").filter(Boolean)
118
- const pathParts = path.split("/").filter(Boolean)
456
+ /** Collect remaining path segments as a decoded splat value */
457
+ function captureSplat(pathParts: string[], from: number, pathLen: number): string {
458
+ const remaining: string[] = []
459
+ for (let j = from; j < pathLen; j++) {
460
+ const p = pathParts[j]
461
+ if (p !== undefined) remaining.push(decodeSafe(p))
462
+ }
463
+ return remaining.join("/")
464
+ }
465
+
466
+ // ─── Flattened route matching ─────────────────────────────────────────────────
119
467
 
120
- if (pathParts.length < patternParts.length) return null
468
+ /** Check whether a flattened route's segment count is compatible with the path length */
469
+ function isSegmentCountCompatible(f: FlattenedRoute, pathLen: number): boolean {
470
+ if (f.segmentCount === pathLen) return true
471
+ if (f.hasSplat && pathLen >= f.segmentCount) return true
472
+ if (f.hasOptional && pathLen >= f.minSegments && pathLen <= f.segmentCount) return true
473
+ return false
474
+ }
475
+
476
+ /** Try to match a flattened route against path parts */
477
+ function matchFlattened(
478
+ f: FlattenedRoute,
479
+ pathParts: string[],
480
+ pathLen: number,
481
+ ): Record<string, string> | null {
482
+ if (!isSegmentCountCompatible(f, pathLen)) return null
121
483
 
122
484
  const params: Record<string, string> = {}
123
- for (let i = 0; i < patternParts.length; i++) {
124
- const pp = patternParts[i] as string
125
- const pt = pathParts[i] as string
126
- // Splat param in prefix — captures the rest
127
- if (pp.endsWith("*") && pp.startsWith(":")) {
128
- const paramName = pp.slice(1, -1)
129
- params[paramName] = pathParts.slice(i).map(decodeURIComponent).join("/")
130
- return { params, rest: "/" }
485
+ const segments = f.segments
486
+ const count = f.segmentCount
487
+ for (let i = 0; i < count; i++) {
488
+ const seg = segments[i]
489
+ const pt = pathParts[i]
490
+ if (!seg) return null
491
+ if (seg.isSplat) {
492
+ params[seg.paramName] = captureSplat(pathParts, i, pathLen)
493
+ return params
494
+ }
495
+ if (pt === undefined) {
496
+ if (!seg.isOptional) return null
497
+ continue
131
498
  }
132
- if (pp.startsWith(":")) {
133
- params[pp.slice(1)] = decodeURIComponent(pt)
134
- } else if (pp !== pt) {
499
+ if (seg.isParam) {
500
+ params[seg.paramName] = decodeSafe(pt)
501
+ } else if (seg.raw !== pt) {
135
502
  return null
136
503
  }
137
504
  }
505
+ return params
506
+ }
138
507
 
139
- const rest = `/${pathParts.slice(patternParts.length).join("/")}`
140
- return { params, rest }
508
+ /** Search a list of flattened candidates for a match */
509
+ function searchCandidates(
510
+ candidates: FlattenedRoute[],
511
+ pathParts: string[],
512
+ pathLen: number,
513
+ ): MatchResult | null {
514
+ for (let i = 0; i < candidates.length; i++) {
515
+ const f = candidates[i]
516
+ if (!f) continue
517
+ const params = matchFlattened(f, pathParts, pathLen)
518
+ if (params) {
519
+ return { params, matched: f.matchedChain }
520
+ }
521
+ }
522
+ return null
141
523
  }
142
524
 
143
525
  // ─── Route resolution ─────────────────────────────────────────────────────────
144
526
 
527
+ interface MatchResult {
528
+ params: Record<string, string>
529
+ matched: RouteRecord[]
530
+ }
531
+
145
532
  /**
146
533
  * Resolve a raw path (including query string and hash) against the route tree.
147
- * Handles nested routes recursively.
534
+ * Uses flattened index for O(1) static lookup and first-segment dispatch.
148
535
  */
149
536
  export function resolveRoute(rawPath: string, routes: RouteRecord[]): ResolvedRoute {
150
537
  const qIdx = rawPath.indexOf("?")
@@ -157,63 +544,73 @@ export function resolveRoute(rawPath: string, routes: RouteRecord[]): ResolvedRo
157
544
 
158
545
  const query = parseQuery(queryPart)
159
546
 
160
- const match = matchRoutes(cleanPath, routes, [])
161
- if (match) {
547
+ // Build index (cached after first call)
548
+ const compiled = compileRoutes(routes)
549
+ const index = buildRouteIndex(routes, compiled)
550
+
551
+ // Fast path 1: O(1) static Map lookup (covers nested static too)
552
+ const staticMatch = index.staticMap.get(cleanPath)
553
+ if (staticMatch) {
162
554
  return {
163
555
  path: cleanPath,
164
- params: match.params,
556
+ params: {},
165
557
  query,
166
558
  hash,
167
- matched: match.matched,
168
- meta: mergeMeta(match.matched),
559
+ matched: staticMatch.matchedChain,
560
+ meta: staticMatch.meta,
169
561
  }
170
562
  }
171
563
 
172
- return { path: cleanPath, params: {}, query, hash, matched: [], meta: {} }
173
- }
174
-
175
- interface MatchResult {
176
- params: Record<string, string>
177
- matched: RouteRecord[]
178
- }
564
+ // Split path for segment-based matching
565
+ const pathParts = splitPath(cleanPath)
566
+ const pathLen = pathParts.length
179
567
 
180
- function matchRoutes(
181
- path: string,
182
- routes: RouteRecord[],
183
- parentMatched: RouteRecord[],
184
- parentParams: Record<string, string> = {},
185
- ): MatchResult | null {
186
- for (const route of routes) {
187
- const result = matchSingleRoute(path, route, parentMatched, parentParams)
188
- if (result) return result
568
+ // Fast path 2: first-segment dispatch (O(1) bucket lookup + small scan)
569
+ if (pathLen > 0) {
570
+ const first = pathParts[0] as string
571
+ const bucket = index.segmentMap.get(first)
572
+ if (bucket) {
573
+ const match = searchCandidates(bucket, pathParts, pathLen)
574
+ if (match) {
575
+ return {
576
+ path: cleanPath,
577
+ params: match.params,
578
+ query,
579
+ hash,
580
+ matched: match.matched,
581
+ meta: mergeMeta(match.matched),
582
+ }
583
+ }
584
+ }
189
585
  }
190
- return null
191
- }
192
586
 
193
- function matchSingleRoute(
194
- path: string,
195
- route: RouteRecord,
196
- parentMatched: RouteRecord[],
197
- parentParams: Record<string, string>,
198
- ): MatchResult | null {
199
- if (!route.children || route.children.length === 0) {
200
- const params = matchPath(route.path, path)
201
- if (params === null) return null
202
- return { params: { ...parentParams, ...params }, matched: [...parentMatched, route] }
587
+ // Fallback: dynamic-first-segment routes
588
+ const dynMatch = searchCandidates(index.dynamicFirst, pathParts, pathLen)
589
+ if (dynMatch) {
590
+ return {
591
+ path: cleanPath,
592
+ params: dynMatch.params,
593
+ query,
594
+ hash,
595
+ matched: dynMatch.matched,
596
+ meta: mergeMeta(dynMatch.matched),
597
+ }
203
598
  }
204
599
 
205
- const prefix = matchPrefix(route.path, path)
206
- if (prefix === null) return null
207
-
208
- const allParams = { ...parentParams, ...prefix.params }
209
- const matched = [...parentMatched, route]
210
-
211
- const childMatch = matchRoutes(prefix.rest, route.children, matched, allParams)
212
- if (childMatch) return childMatch
600
+ // Fallback: wildcard/catch-all routes
601
+ const w = index.wildcards[0]
602
+ if (w) {
603
+ return {
604
+ path: cleanPath,
605
+ params: {},
606
+ query,
607
+ hash,
608
+ matched: w.matchedChain,
609
+ meta: w.meta,
610
+ }
611
+ }
213
612
 
214
- const exactParams = matchPath(route.path, path)
215
- if (exactParams === null) return null
216
- return { params: { ...parentParams, ...exactParams }, matched }
613
+ return { path: cleanPath, params: {}, query, hash, matched: [], meta: {} }
217
614
  }
218
615
 
219
616
  /** Merge meta from matched routes (leaf takes precedence) */
@@ -227,7 +624,13 @@ function mergeMeta(matched: RouteRecord[]): RouteMeta {
227
624
 
228
625
  /** Build a path string from a named route's pattern and params */
229
626
  export function buildPath(pattern: string, params: Record<string, string>): string {
230
- return pattern.replace(/:([^/]+)\*?/g, (match, key) => {
627
+ const built = pattern.replace(/\/:([^/]+)\?/g, (_match, key) => {
628
+ const val = params[key]
629
+ // Optional param — omit the entire segment if no value provided
630
+ if (!val) return ""
631
+ return `/${encodeURIComponent(val)}`
632
+ })
633
+ return built.replace(/:([^/]+)\*?/g, (match, key) => {
231
634
  const val = params[key] ?? ""
232
635
  // Splat params contain slashes — don't encode them
233
636
  if (match.endsWith("*")) return val.split("/").map(encodeURIComponent).join("/")