@pyreon/router 0.24.4 → 0.24.6

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 DELETED
@@ -1,921 +0,0 @@
1
- import type { ResolvedRoute, RouteComponent, RouteMeta, RouteRecord } from './types'
2
-
3
- // ─── Default chrome layout registration ──────────────────────────────────────
4
- //
5
- // Late-bound registration for the synthetic layout used by the
6
- // layout-less-app 404 fallback in `findNotFoundFallback` below. The
7
- // component itself lives in `./components.tsx` (it needs JSX + the
8
- // `RouterView` it imports), but `match.ts` is below `components.tsx` in
9
- // the dependency graph (router.ts imports match.ts; components.tsx
10
- // imports router.ts) — directly importing `components.tsx` from here
11
- // would create a cycle. Instead, `components.tsx` calls
12
- // `_setDefaultChromeLayout(DefaultChromeLayout)` at module load. As
13
- // long as the consumer's app imports anything from `@pyreon/router`
14
- // that touches `components.tsx` (which every app does via
15
- // `RouterProvider` / `RouterView` / `RouterLink`), the registration
16
- // runs before any `resolveRoute()` call.
17
- //
18
- // When the setter hasn't been called (e.g. unit tests that exercise
19
- // `resolveRoute` in isolation without ever importing `components.tsx`),
20
- // `findNotFoundFallback` returns `null` for the layout-less case — the
21
- // standalone-render path in the SSG plugin / runtime handler picks up
22
- // from there. So the fix degrades gracefully.
23
- let _defaultChromeLayout: RouteComponent | null = null
24
-
25
- /**
26
- * Register the synthetic "default chrome" layout used when a page-level
27
- * `notFoundComponent` is the closest fallback (layout-less single-page-
28
- * app shape). Called once at module load from `./components.tsx`. Pyreon
29
- * apps shouldn't need to call this themselves.
30
- *
31
- * @internal
32
- */
33
- export function _setDefaultChromeLayout(component: RouteComponent): void {
34
- _defaultChromeLayout = component
35
- }
36
-
37
- // ─── Query string ─────────────────────────────────────────────────────────────
38
-
39
- /**
40
- * Parse a query string into key-value pairs. Duplicate keys are overwritten
41
- * (last value wins). Use `parseQueryMulti` to preserve duplicates as arrays.
42
- */
43
- /** Decode a query component: `+` → space (per application/x-www-form-urlencoded), then URI-decode. */
44
- function decodeQueryComponent(raw: string): string {
45
- return decodeURIComponent(raw.replace(/\+/g, ' '))
46
- }
47
-
48
- export function parseQuery(qs: string): Record<string, string> {
49
- if (!qs) return {}
50
- const result: Record<string, string> = {}
51
- for (const part of qs.split('&')) {
52
- const eqIdx = part.indexOf('=')
53
- if (eqIdx < 0) {
54
- const key = decodeQueryComponent(part)
55
- if (key) result[key] = ''
56
- } else {
57
- const key = decodeQueryComponent(part.slice(0, eqIdx))
58
- const val = decodeQueryComponent(part.slice(eqIdx + 1))
59
- if (key) result[key] = val
60
- }
61
- }
62
- return result
63
- }
64
-
65
- /**
66
- * Parse a query string preserving duplicate keys as arrays.
67
- *
68
- * @example
69
- * parseQueryMulti("color=red&color=blue&size=lg")
70
- * // → { color: ["red", "blue"], size: "lg" }
71
- */
72
- export function parseQueryMulti(qs: string): Record<string, string | string[]> {
73
- if (!qs) return {}
74
- const result: Record<string, string | string[]> = {}
75
- for (const part of qs.split('&')) {
76
- const eqIdx = part.indexOf('=')
77
- let key: string
78
- let val: string
79
- if (eqIdx < 0) {
80
- key = decodeQueryComponent(part)
81
- val = ''
82
- } else {
83
- key = decodeQueryComponent(part.slice(0, eqIdx))
84
- val = decodeQueryComponent(part.slice(eqIdx + 1))
85
- }
86
- if (!key) continue
87
- const existing = result[key]
88
- if (existing === undefined) {
89
- result[key] = val
90
- } else if (Array.isArray(existing)) {
91
- existing.push(val)
92
- } else {
93
- result[key] = [existing, val]
94
- }
95
- }
96
- return result
97
- }
98
-
99
- export function stringifyQuery(query: Record<string, string>): string {
100
- const parts: string[] = []
101
- for (const [k, v] of Object.entries(query)) {
102
- parts.push(v ? `${encodeURIComponent(k)}=${encodeURIComponent(v)}` : encodeURIComponent(k))
103
- }
104
- return parts.length ? `?${parts.join('&')}` : ''
105
- }
106
-
107
- // ─── Compiled route structures ───────────────────────────────────────────────
108
-
109
- /**
110
- * Pre-compiled segment info — computed once per route, reused on every match.
111
- * Avoids repeated split()/filter()/startsWith(":") on hot paths.
112
- */
113
- interface CompiledSegment {
114
- /** Original segment string */
115
- raw: string
116
- /** true if this segment is a `:param` */
117
- isParam: boolean
118
- /** true if this segment is a `:param*` splat */
119
- isSplat: boolean
120
- /** true if this segment is a `:param?` optional */
121
- isOptional: boolean
122
- /** Param name (without leading `:` and trailing `*`/`?`) — empty for static segments */
123
- paramName: string
124
- }
125
-
126
- interface CompiledRoute {
127
- route: RouteRecord
128
- /** true for wildcard patterns: `(.*)` or `*` */
129
- isWildcard: boolean
130
- /** Pre-split and classified segments */
131
- segments: CompiledSegment[]
132
- /** Number of segments */
133
- segmentCount: number
134
- /** true if route has no dynamic segments (pure static) */
135
- isStatic: boolean
136
- /** For static routes: the normalized path (e.g. "/about") for Map lookup */
137
- staticPath: string | null
138
- /** Compiled children (lazily populated) */
139
- children: CompiledRoute[] | null
140
- /** First static segment (for dispatch index), null if first segment is dynamic or route is wildcard */
141
- firstSegment: string | null
142
- }
143
-
144
- /**
145
- * A flattened route entry — pre-joins parent+child segments at compile time
146
- * so nested routes can be matched in a single pass without recursion.
147
- */
148
- interface FlattenedRoute {
149
- /** All segments from root to leaf, concatenated */
150
- segments: CompiledSegment[]
151
- segmentCount: number
152
- /** The full matched chain from root to leaf (e.g. [adminLayout, usersPage]) */
153
- matchedChain: RouteRecord[]
154
- /** true if all segments are static */
155
- isStatic: boolean
156
- /** For static flattened routes: the full joined path */
157
- staticPath: string | null
158
- /** Pre-merged meta from all routes in the chain */
159
- meta: RouteMeta
160
- /** First static segment for dispatch index */
161
- firstSegment: string | null
162
- /** true if any segment is a splat */
163
- hasSplat: boolean
164
- /** true if this is a wildcard catch-all route (`*` or `(.*)`) */
165
- isWildcard: boolean
166
- /** true if any segment is optional (`:param?`) */
167
- hasOptional: boolean
168
- /** Minimum number of segments that must be present (excluding trailing optionals) */
169
- minSegments: number
170
- }
171
-
172
- /** WeakMap cache: compile each RouteRecord[] once */
173
- const _compiledCache = new WeakMap<RouteRecord[], CompiledRoute[]>()
174
-
175
- function compileSegment(raw: string): CompiledSegment {
176
- if (raw.endsWith('*') && raw.startsWith(':')) {
177
- return { raw, isParam: true, isSplat: true, isOptional: false, paramName: raw.slice(1, -1) }
178
- }
179
- if (raw.endsWith('?') && raw.startsWith(':')) {
180
- return { raw, isParam: true, isSplat: false, isOptional: true, paramName: raw.slice(1, -1) }
181
- }
182
- if (raw.startsWith(':')) {
183
- return { raw, isParam: true, isSplat: false, isOptional: false, paramName: raw.slice(1) }
184
- }
185
- return { raw, isParam: false, isSplat: false, isOptional: false, paramName: '' }
186
- }
187
-
188
- function compileRoute(route: RouteRecord): CompiledRoute {
189
- const pattern = route.path
190
- const isWildcard = pattern === '(.*)' || pattern === '*'
191
-
192
- if (isWildcard) {
193
- return {
194
- route,
195
- isWildcard: true,
196
- segments: [],
197
- segmentCount: 0,
198
- isStatic: false,
199
- staticPath: null,
200
- children: null,
201
- firstSegment: null,
202
- }
203
- }
204
-
205
- const segments = pattern.split('/').filter(Boolean).map(compileSegment)
206
- const isStatic = segments.every((s) => !s.isParam)
207
- const staticPath = isStatic ? `/${segments.map((s) => s.raw).join('/')}` : null
208
- const first = segments.length > 0 ? segments[0] : undefined
209
- const firstSegment = first && !first.isParam ? first.raw : null
210
-
211
- return {
212
- route,
213
- isWildcard: false,
214
- segments,
215
- segmentCount: segments.length,
216
- isStatic,
217
- staticPath,
218
- children: null,
219
- firstSegment,
220
- }
221
- }
222
-
223
- /** Expand alias paths into additional compiled entries sharing the original RouteRecord */
224
- function expandAliases(r: RouteRecord, c: CompiledRoute): CompiledRoute[] {
225
- if (!r.alias) return []
226
- const aliases = Array.isArray(r.alias) ? r.alias : [r.alias]
227
- return aliases.map((aliasPath) => {
228
- const { alias: _, ...withoutAlias } = r
229
- const ac = compileRoute({ ...withoutAlias, path: aliasPath })
230
- ac.children = c.children
231
- ac.route = r
232
- return ac
233
- })
234
- }
235
-
236
- function compileRoutes(routes: RouteRecord[]): CompiledRoute[] {
237
- const cached = _compiledCache.get(routes)
238
- if (cached) return cached
239
-
240
- const compiled: CompiledRoute[] = []
241
- for (const r of routes) {
242
- const c = compileRoute(r)
243
- if (r.children && r.children.length > 0) {
244
- c.children = compileRoutes(r.children)
245
- }
246
- compiled.push(c)
247
- compiled.push(...expandAliases(r, c))
248
- }
249
- _compiledCache.set(routes, compiled)
250
- return compiled
251
- }
252
-
253
- // ─── Route flattening ────────────────────────────────────────────────────────
254
-
255
- /** Extract first static segment from a segment list, or null if dynamic/empty */
256
- function getFirstSegment(segments: CompiledSegment[]): string | null {
257
- const first = segments[0]
258
- if (first && !first.isParam) return first.raw
259
- return null
260
- }
261
-
262
- /** Build a FlattenedRoute from segments + metadata */
263
- function makeFlatEntry(
264
- segments: CompiledSegment[],
265
- chain: RouteRecord[],
266
- meta: RouteMeta,
267
- isWildcard: boolean,
268
- ): FlattenedRoute {
269
- const isStatic = !isWildcard && segments.every((s) => !s.isParam)
270
- const hasOptional = segments.some((s) => s.isOptional)
271
- // minSegments: count of segments up to and not including trailing optionals
272
- let minSegs = segments.length
273
- if (hasOptional) {
274
- while (minSegs > 0 && segments[minSegs - 1]?.isOptional) minSegs--
275
- }
276
- return {
277
- segments,
278
- segmentCount: segments.length,
279
- matchedChain: chain,
280
- isStatic,
281
- staticPath: isStatic ? `/${segments.map((s) => s.raw).join('/')}` : null,
282
- meta,
283
- firstSegment: getFirstSegment(segments),
284
- hasSplat: segments.some((s) => s.isSplat),
285
- isWildcard,
286
- hasOptional,
287
- minSegments: minSegs,
288
- }
289
- }
290
-
291
- /**
292
- * Flatten nested routes into leaf entries with pre-joined segments.
293
- * This eliminates recursion during matching for the common case.
294
- */
295
- function flattenRoutes(compiled: CompiledRoute[]): FlattenedRoute[] {
296
- const result: FlattenedRoute[] = []
297
- flattenWalk(result, compiled, [], [], {})
298
- return result
299
- }
300
-
301
- function flattenWalk(
302
- result: FlattenedRoute[],
303
- routes: CompiledRoute[],
304
- parentSegments: CompiledSegment[],
305
- parentChain: RouteRecord[],
306
- parentMeta: RouteMeta,
307
- ): void {
308
- for (const c of routes) {
309
- const chain = [...parentChain, c.route]
310
- const meta = c.route.meta ? { ...parentMeta, ...c.route.meta } : { ...parentMeta }
311
- flattenOne(result, c, parentSegments, chain, meta)
312
- }
313
- }
314
-
315
- function flattenOne(
316
- result: FlattenedRoute[],
317
- c: CompiledRoute,
318
- parentSegments: CompiledSegment[],
319
- chain: RouteRecord[],
320
- meta: RouteMeta,
321
- ): void {
322
- if (c.isWildcard) {
323
- result.push(makeFlatEntry(parentSegments, chain, meta, true))
324
- if (c.children && c.children.length > 0) {
325
- flattenWalk(result, c.children, parentSegments, chain, meta)
326
- }
327
- return
328
- }
329
-
330
- // fs-router emits absolute paths for nested children (e.g. parent
331
- // `/app` with child `/app/dashboard`, NOT child `dashboard`). Concating
332
- // parent segments with the child's already-absolute segments would
333
- // produce `/app/app/dashboard` — the staticMap then lookups the wrong
334
- // key and resolveRoute returns `matched: []` for any such request.
335
- // Detect "child path is absolute" (`path` starts with `/`) and skip the
336
- // parent-segment prefix in that case — the child's own segments ARE
337
- // the full intended path. Relative children (`dashboard`, `:id`)
338
- // continue to inherit the parent's segments via concatenation.
339
- const childPath = c.route.path
340
- const isAbsoluteChild = typeof childPath === 'string' && childPath.startsWith('/')
341
- const joined = isAbsoluteChild ? c.segments : [...parentSegments, ...c.segments]
342
- if (c.children && c.children.length > 0) {
343
- flattenWalk(result, c.children, joined, chain, meta)
344
- }
345
- result.push(makeFlatEntry(joined, chain, meta, false))
346
- }
347
-
348
- // ─── Combined index ─────────────────────────────────────────────────────────
349
-
350
- interface RouteIndex {
351
- /** O(1) lookup for fully static paths (including nested) */
352
- staticMap: Map<string, FlattenedRoute>
353
- /** First-segment dispatch: maps first path segment → candidate routes */
354
- segmentMap: Map<string, FlattenedRoute[]>
355
- /** Routes whose first segment is dynamic (fallback) */
356
- dynamicFirst: FlattenedRoute[]
357
- /** Wildcard/catch-all routes */
358
- wildcards: FlattenedRoute[]
359
- }
360
-
361
- const _indexCache = new WeakMap<RouteRecord[], RouteIndex>()
362
-
363
- /** Classify a single flattened route into the appropriate index bucket */
364
- function indexFlatRoute(
365
- f: FlattenedRoute,
366
- staticMap: Map<string, FlattenedRoute>,
367
- segmentMap: Map<string, FlattenedRoute[]>,
368
- dynamicFirst: FlattenedRoute[],
369
- wildcards: FlattenedRoute[],
370
- ): void {
371
- // Static map: first static entry wins (preserves definition order)
372
- if (f.isStatic && f.staticPath && !staticMap.has(f.staticPath)) {
373
- staticMap.set(f.staticPath, f)
374
- }
375
-
376
- if (f.isWildcard) {
377
- wildcards.push(f)
378
- return
379
- }
380
-
381
- // Root route "/" has 0 segments — already in static map
382
- if (f.segmentCount === 0) return
383
-
384
- // First-segment dispatch
385
- if (f.firstSegment) {
386
- let bucket = segmentMap.get(f.firstSegment)
387
- if (!bucket) {
388
- bucket = []
389
- segmentMap.set(f.firstSegment, bucket)
390
- }
391
- bucket.push(f)
392
- } else {
393
- dynamicFirst.push(f)
394
- }
395
- }
396
-
397
- function buildRouteIndex(routes: RouteRecord[], compiled: CompiledRoute[]): RouteIndex {
398
- const cached = _indexCache.get(routes)
399
- if (cached) return cached
400
-
401
- const flattened = flattenRoutes(compiled)
402
-
403
- const staticMap = new Map<string, FlattenedRoute>()
404
- const segmentMap = new Map<string, FlattenedRoute[]>()
405
- const dynamicFirst: FlattenedRoute[] = []
406
- const wildcards: FlattenedRoute[] = []
407
-
408
- for (const f of flattened) {
409
- indexFlatRoute(f, staticMap, segmentMap, dynamicFirst, wildcards)
410
- }
411
-
412
- const index: RouteIndex = { staticMap, segmentMap, dynamicFirst, wildcards }
413
- _indexCache.set(routes, index)
414
- return index
415
- }
416
-
417
- // ─── Fast path splitting ─────────────────────────────────────────────────────
418
-
419
- /** Split path into segments without allocating a filtered array */
420
- function splitPath(path: string): string[] {
421
- // Fast path for common cases
422
- if (path === '/') return []
423
- // Remove leading slash, split, no filter needed if path is clean
424
- const start = path.charCodeAt(0) === 47 /* / */ ? 1 : 0
425
- const end = path.length
426
- if (start >= end) return []
427
-
428
- const parts: string[] = []
429
- let segStart = start
430
- for (let i = start; i <= end; i++) {
431
- if (i === end || path.charCodeAt(i) === 47 /* / */) {
432
- if (i > segStart) {
433
- parts.push(path.substring(segStart, i))
434
- }
435
- segStart = i + 1
436
- }
437
- }
438
- return parts
439
- }
440
-
441
- /** Decode only if the segment contains a `%` character */
442
- function decodeSafe(s: string): string {
443
- return s.indexOf('%') >= 0 ? decodeURIComponent(s) : s
444
- }
445
-
446
- // ─── Path matching (compiled) ────────────────────────────────────────────────
447
-
448
- /**
449
- * Match a single route pattern against a path segment.
450
- * Returns extracted params or null if no match.
451
- *
452
- * Supports:
453
- * - Exact segments: "/about"
454
- * - Param segments: "/user/:id"
455
- * - Wildcard: "(.*)" matches everything
456
- */
457
- /** Match a single pattern segment against a path segment, extracting params. Returns false on mismatch. */
458
- function matchPatternSegment(
459
- pp: string,
460
- pt: string | undefined,
461
- params: Record<string, string>,
462
- pathParts: string[],
463
- i: number,
464
- ): 'splat' | 'continue' | 'fail' {
465
- if (pp.endsWith('*') && pp.startsWith(':')) {
466
- params[pp.slice(1, -1)] = pathParts.slice(i).map(decodeURIComponent).join('/')
467
- return 'splat'
468
- }
469
- if (pp.endsWith('?') && pp.startsWith(':')) {
470
- if (pt !== undefined) params[pp.slice(1, -1)] = decodeURIComponent(pt)
471
- return 'continue'
472
- }
473
- if (pt === undefined) return 'fail'
474
- if (pp.startsWith(':')) {
475
- params[pp.slice(1)] = decodeURIComponent(pt)
476
- return 'continue'
477
- }
478
- return pp === pt ? 'continue' : 'fail'
479
- }
480
-
481
- export function matchPath(pattern: string, path: string): Record<string, string> | null {
482
- if (pattern === '(.*)' || pattern === '*') return {}
483
-
484
- const patternParts = pattern.split('/').filter(Boolean)
485
- const pathParts = path.split('/').filter(Boolean)
486
-
487
- const params: Record<string, string> = {}
488
- for (let i = 0; i < patternParts.length; i++) {
489
- const result = matchPatternSegment(
490
- patternParts[i] as string,
491
- pathParts[i],
492
- params,
493
- pathParts,
494
- i,
495
- )
496
- if (result === 'splat') return params
497
- if (result === 'fail') return null
498
- }
499
-
500
- if (pathParts.length > patternParts.length) return null
501
- return params
502
- }
503
-
504
- // ─── Compiled matching helpers ────────────────────────────────────────────────
505
-
506
- /** Collect remaining path segments as a decoded splat value */
507
- function captureSplat(pathParts: string[], from: number, pathLen: number): string {
508
- const remaining: string[] = []
509
- for (let j = from; j < pathLen; j++) {
510
- const p = pathParts[j]
511
- if (p !== undefined) remaining.push(decodeSafe(p))
512
- }
513
- return remaining.join('/')
514
- }
515
-
516
- // ─── Flattened route matching ─────────────────────────────────────────────────
517
-
518
- /** Check whether a flattened route's segment count is compatible with the path length */
519
- function isSegmentCountCompatible(f: FlattenedRoute, pathLen: number): boolean {
520
- if (f.segmentCount === pathLen) return true
521
- if (f.hasSplat && pathLen >= f.segmentCount) return true
522
- if (f.hasOptional && pathLen >= f.minSegments && pathLen <= f.segmentCount) return true
523
- return false
524
- }
525
-
526
- /** Try to match a flattened route against path parts */
527
- function matchFlattened(
528
- f: FlattenedRoute,
529
- pathParts: string[],
530
- pathLen: number,
531
- ): Record<string, string> | null {
532
- if (!isSegmentCountCompatible(f, pathLen)) return null
533
-
534
- const params: Record<string, string> = {}
535
- const segments = f.segments
536
- const count = f.segmentCount
537
- for (let i = 0; i < count; i++) {
538
- const seg = segments[i]
539
- const pt = pathParts[i]
540
- if (!seg) return null
541
- if (seg.isSplat) {
542
- params[seg.paramName] = captureSplat(pathParts, i, pathLen)
543
- return params
544
- }
545
- if (pt === undefined) {
546
- if (!seg.isOptional) return null
547
- continue
548
- }
549
- if (seg.isParam) {
550
- params[seg.paramName] = decodeSafe(pt)
551
- } else if (seg.raw !== pt) {
552
- return null
553
- }
554
- }
555
- return params
556
- }
557
-
558
- /** Search a list of flattened candidates for a match */
559
- function searchCandidates(
560
- candidates: FlattenedRoute[],
561
- pathParts: string[],
562
- pathLen: number,
563
- ): MatchResult | null {
564
- for (let i = 0; i < candidates.length; i++) {
565
- const f = candidates[i]
566
- if (!f) continue
567
- const params = matchFlattened(f, pathParts, pathLen)
568
- if (params) {
569
- return { params, matched: f.matchedChain }
570
- }
571
- }
572
- return null
573
- }
574
-
575
- // ─── Route resolution ─────────────────────────────────────────────────────────
576
-
577
- interface MatchResult {
578
- params: Record<string, string>
579
- matched: RouteRecord[]
580
- }
581
-
582
- /**
583
- * Resolve a raw path (including query string and hash) against the route tree.
584
- * Uses flattened index for O(1) static lookup and first-segment dispatch.
585
- */
586
- export function resolveRoute(rawPath: string, routes: RouteRecord[]): ResolvedRoute {
587
- const qIdx = rawPath.indexOf('?')
588
- const pathAndHash = qIdx >= 0 ? rawPath.slice(0, qIdx) : rawPath
589
- const queryPart = qIdx >= 0 ? rawPath.slice(qIdx + 1) : ''
590
-
591
- const hIdx = pathAndHash.indexOf('#')
592
- const cleanPath = hIdx >= 0 ? pathAndHash.slice(0, hIdx) : pathAndHash
593
- const hash = hIdx >= 0 ? pathAndHash.slice(hIdx + 1) : ''
594
-
595
- const query = parseQuery(queryPart)
596
-
597
- // Build index (cached after first call)
598
- const compiled = compileRoutes(routes)
599
- const index = buildRouteIndex(routes, compiled)
600
-
601
- // Fast path 1: O(1) static Map lookup (covers nested static too)
602
- const staticMatch = index.staticMap.get(cleanPath)
603
- if (staticMatch) {
604
- return {
605
- path: cleanPath,
606
- params: {},
607
- query,
608
- hash,
609
- matched: staticMatch.matchedChain,
610
- meta: staticMatch.meta,
611
- search: runValidateSearch(staticMatch.matchedChain, query),
612
- }
613
- }
614
-
615
- // Split path for segment-based matching
616
- const pathParts = splitPath(cleanPath)
617
- const pathLen = pathParts.length
618
-
619
- // Fast path 2: first-segment dispatch (O(1) bucket lookup + small scan)
620
- if (pathLen > 0) {
621
- const first = pathParts[0] as string
622
- const bucket = index.segmentMap.get(first)
623
- if (bucket) {
624
- const match = searchCandidates(bucket, pathParts, pathLen)
625
- if (match) {
626
- return {
627
- path: cleanPath,
628
- params: match.params,
629
- query,
630
- hash,
631
- matched: match.matched,
632
- meta: mergeMeta(match.matched),
633
- search: runValidateSearch(match.matched, query),
634
- }
635
- }
636
- }
637
- }
638
-
639
- // Fallback: dynamic-first-segment routes
640
- const dynMatch = searchCandidates(index.dynamicFirst, pathParts, pathLen)
641
- if (dynMatch) {
642
- return {
643
- path: cleanPath,
644
- params: dynMatch.params,
645
- query,
646
- hash,
647
- matched: dynMatch.matched,
648
- meta: mergeMeta(dynMatch.matched),
649
- search: runValidateSearch(dynMatch.matched, query),
650
- }
651
- }
652
-
653
- // Fallback: wildcard/catch-all routes
654
- const w = index.wildcards[0]
655
- if (w) {
656
- return {
657
- path: cleanPath,
658
- params: {},
659
- query,
660
- hash,
661
- matched: w.matchedChain,
662
- meta: w.meta,
663
- search: runValidateSearch(w.matchedChain, query),
664
- }
665
- }
666
-
667
- // Fallback: notFoundComponent walk. When the URL doesn't match any
668
- // descendant route, look for the deepest parent `notFoundComponent`
669
- // whose path is a prefix of this URL. Build a synthetic chain that
670
- // renders the not-found component INSIDE its ancestor layouts so the
671
- // 404 page carries the same chrome (headers, footers, navigation) as
672
- // regular pages. Without this, SSG/SSR returns `matched: []` and the
673
- // caller has to render the not-found component standalone, losing
674
- // layout wrapping.
675
- const nfb = findNotFoundFallback(routes, cleanPath)
676
- if (nfb) {
677
- return {
678
- path: cleanPath,
679
- params: {},
680
- query,
681
- hash,
682
- matched: nfb,
683
- meta: mergeMeta(nfb),
684
- search: {},
685
- isNotFound: true,
686
- }
687
- }
688
-
689
- return { path: cleanPath, params: {}, query, hash, matched: [], meta: {}, search: {} }
690
- }
691
-
692
- // ─── notFoundComponent walking ───────────────────────────────────────────────
693
-
694
- /** Synthetic leaf RouteRecord used by the 404 fallback. Carries no real
695
- * path matching — the resolver inserts it at the end of the chain when
696
- * a parent `notFoundComponent` is the closest fallback for the URL. */
697
- const SYNTHETIC_NOT_FOUND_PATH = '__pyreon_not_found_leaf__'
698
-
699
- /**
700
- * Walk the route tree finding records with `notFoundComponent`. Return
701
- * the chain `[...ancestors, parentWithNotFound, syntheticLeaf]` for the
702
- * DEEPEST record whose URL path is a prefix of `urlPath`.
703
- *
704
- * The path-prefix check: a record at `'/de'` applies to `/de/unknown`
705
- * and `/de` itself but NOT to `/about` or `/encyclopedia` (full-segment
706
- * boundary required, not substring). A record at `'/'` (root layout)
707
- * applies to every URL. Deeper matches win — `/de` layout takes
708
- * precedence over root layout for URLs under `/de/...`.
709
- *
710
- * Returns `null` when no record has `notFoundComponent`.
711
- */
712
- function findNotFoundFallback(routes: RouteRecord[], urlPath: string): RouteRecord[] | null {
713
- let best: { chain: RouteRecord[]; record: RouteRecord; depth: number; specificity: number } | null = null
714
- // Second-pass fallback: collect the BEST page-level notFoundComponent
715
- // (no children) in case the layout pass finds nothing. Applies to the
716
- // layout-less single-page-app case where `_404.tsx` is emitted without
717
- // a parent `_layout.tsx`. The layout pass intentionally skips this
718
- // shape (page records have no `<RouterView />` to wrap the leaf); the
719
- // synthetic default-chrome layout fills that gap below.
720
- let pageBest: {
721
- record: RouteRecord
722
- depth: number
723
- specificity: number
724
- fullPath: string
725
- } | null = null
726
-
727
- function walk(records: RouteRecord[], parentChain: RouteRecord[], parentPath: string): void {
728
- for (const r of records) {
729
- const rawPath = typeof r.path === 'string' ? r.path : ''
730
- // fs-router emits absolute paths for nested routes (e.g. `/de/about`);
731
- // relative paths inherit parent's path via concat. Mirror flattenOne's
732
- // logic so synthesised paths track real URL prefixes.
733
- const fullPath = rawPath.startsWith('/')
734
- ? rawPath
735
- : `${parentPath}/${rawPath}`.replace(/\/+/g, '/')
736
- const chain = [...parentChain, r]
737
-
738
- // Filter to LAYOUT records (records with non-empty `children`).
739
- // fs-router attaches `notFoundComponent` to BOTH the parent layout
740
- // AND every page record under that layout. Page records have no
741
- // `<RouterView />` to render the synthetic leaf at the next depth,
742
- // so picking a page as the fallback parent produces a chain
743
- // `[Layout, Page, syntheticLeaf]` where `Page` swallows the leaf.
744
- // Filtering to records with children ensures the synthetic leaf
745
- // lands at a depth a `<RouterView />` will actually render.
746
- const isLayout = Array.isArray(r.children) && r.children.length > 0
747
-
748
- if (typeof r.notFoundComponent === 'function') {
749
- const applies = pathPrefixApplies(fullPath, urlPath)
750
- if (applies) {
751
- // Prefer (a) the deepest record (longest chain), then (b) the
752
- // most specific path-prefix when chains tie. Specificity =
753
- // number of path segments in `fullPath`. `/` has 0; `/de` has 1.
754
- const specificity = countSegments(fullPath)
755
- if (isLayout) {
756
- if (
757
- !best ||
758
- chain.length > best.depth ||
759
- (chain.length === best.depth && specificity > best.specificity)
760
- ) {
761
- best = { chain, record: r, depth: chain.length, specificity }
762
- }
763
- } else if (
764
- !pageBest ||
765
- chain.length > pageBest.depth ||
766
- (chain.length === pageBest.depth && specificity > pageBest.specificity)
767
- ) {
768
- pageBest = { record: r, depth: chain.length, specificity, fullPath }
769
- }
770
- }
771
- }
772
-
773
- if (Array.isArray(r.children)) {
774
- walk(r.children, chain, fullPath)
775
- }
776
- }
777
- }
778
-
779
- walk(routes, [], '')
780
-
781
- if (best) {
782
- // TypeScript widening: `best` is inferred as `null` inside the closure
783
- // when not narrowed, even though we asserted it's non-null above.
784
- const found: { chain: RouteRecord[]; record: RouteRecord; depth: number; specificity: number } =
785
- best
786
-
787
- const syntheticLeaf: RouteRecord = {
788
- path: SYNTHETIC_NOT_FOUND_PATH,
789
- component: found.record.notFoundComponent as RouteComponent,
790
- }
791
-
792
- return [...found.chain, syntheticLeaf]
793
- }
794
-
795
- // Layout-less fallback. The user has a page-level `notFoundComponent`
796
- // (e.g. `_404.tsx` at the route root with no `_layout.tsx`). Without
797
- // a parent layout to wrap the leaf, we synthesize ONE: a minimal
798
- // "default chrome" layout that renders `<main data-pyreon-default-chrome>
799
- // <RouterView /></main>`. This provides a semantic-HTML landmark for
800
- // accessibility + a hook for users to target the wrapper via CSS, while
801
- // routing the render through the normal `<RouterView />` pipeline (so
802
- // `isNotFound` propagation and runtime SSR status-404 still work).
803
- //
804
- // The DefaultChromeLayout component is registered by `components.tsx`
805
- // at module load time via `_setDefaultChromeLayout()` (setter pattern
806
- // to avoid the components.tsx → match.ts circular import). If the
807
- // setter hasn't been called yet (consumer never imported anything
808
- // from `@pyreon/router` that triggers components.tsx's side effects),
809
- // we fall back to returning null — the standalone-render path in the
810
- // SSG plugin / runtime handler picks up from there.
811
- if (pageBest && _defaultChromeLayout) {
812
- const found: {
813
- record: RouteRecord
814
- depth: number
815
- specificity: number
816
- fullPath: string
817
- } = pageBest
818
-
819
- const syntheticChromeLayout: RouteRecord = {
820
- path: found.fullPath,
821
- component: _defaultChromeLayout,
822
- }
823
- const syntheticLeaf: RouteRecord = {
824
- path: SYNTHETIC_NOT_FOUND_PATH,
825
- component: found.record.notFoundComponent as RouteComponent,
826
- }
827
- return [syntheticChromeLayout, syntheticLeaf]
828
- }
829
-
830
- return null
831
- }
832
-
833
- /** Check whether `prefixPath` is a path-prefix of `urlPath` at segment boundaries. */
834
- function pathPrefixApplies(prefixPath: string, urlPath: string): boolean {
835
- if (prefixPath === '/' || prefixPath === '') return true
836
- if (urlPath === prefixPath) return true
837
- // Require a `/` boundary after the prefix to avoid `/de` matching `/encyclopedia`.
838
- return urlPath.startsWith(`${prefixPath}/`)
839
- }
840
-
841
- /** Count `/`-separated path segments. `/` → 0; `/de` → 1; `/de/about` → 2. */
842
- function countSegments(path: string): number {
843
- let count = 0
844
- for (let i = 0; i < path.length; i++) {
845
- if (path.charCodeAt(i) === 47 /* / */ && i + 1 < path.length) count++
846
- }
847
- return count
848
- }
849
-
850
- /** Run validateSearch from the deepest matched route that has one. */
851
- function runValidateSearch(
852
- matched: RouteRecord[],
853
- query: Record<string, string>,
854
- ): Record<string, unknown> {
855
- // Walk from leaf to root — first validateSearch wins (most specific route)
856
- for (let i = matched.length - 1; i >= 0; i--) {
857
- const validate = matched[i]?.validateSearch
858
- if (validate) {
859
- try {
860
- return validate(query)
861
- } catch {
862
- // Validation failed — return raw query as-is
863
- return { ...query }
864
- }
865
- }
866
- }
867
- return {}
868
- }
869
-
870
- /** Merge meta from matched routes (leaf takes precedence) */
871
- function mergeMeta(matched: RouteRecord[]): RouteMeta {
872
- const meta: RouteMeta = {}
873
- for (const record of matched) {
874
- if (record.meta) Object.assign(meta, record.meta)
875
- }
876
- return meta
877
- }
878
-
879
- /** Build a path string from a named route's pattern and params */
880
- export function buildPath(pattern: string, params: Record<string, string>): string {
881
- const built = pattern.replace(/\/:([^/]+)\?/g, (_match, key) => {
882
- const val = params[key]
883
- // Optional param — omit the entire segment if no value provided
884
- if (!val) return ''
885
- return `/${encodeURIComponent(val)}`
886
- })
887
- return built.replace(/:([^/]+)\*?/g, (match, key) => {
888
- const val = params[key] ?? ''
889
- // Splat params contain slashes — don't encode them
890
- if (match.endsWith('*')) return val.split('/').map(encodeURIComponent).join('/')
891
- return encodeURIComponent(val)
892
- })
893
- }
894
-
895
- /** Find a route record by name (recursive, O(n)). Prefer buildNameIndex for repeated lookups. */
896
- export function findRouteByName(name: string, routes: RouteRecord[]): RouteRecord | null {
897
- for (const route of routes) {
898
- if (route.name === name) return route
899
- if (route.children) {
900
- const found = findRouteByName(name, route.children)
901
- if (found) return found
902
- }
903
- }
904
- return null
905
- }
906
-
907
- /**
908
- * Pre-build a name → RouteRecord index from a route tree for O(1) named navigation.
909
- * Called once at router creation time; avoids O(n) depth-first search per push({ name }).
910
- */
911
- export function buildNameIndex(routes: RouteRecord[]): Map<string, RouteRecord> {
912
- const index = new Map<string, RouteRecord>()
913
- function walk(list: RouteRecord[]): void {
914
- for (const route of list) {
915
- if (route.name) index.set(route.name, route)
916
- if (route.children) walk(route.children)
917
- }
918
- }
919
- walk(routes)
920
- return index
921
- }