@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/package.json +4 -6
- package/src/components.tsx +0 -650
- package/src/env.d.ts +0 -6
- package/src/index.ts +0 -106
- package/src/loader.ts +0 -200
- package/src/manifest.ts +0 -399
- package/src/match.ts +0 -921
- package/src/not-found.ts +0 -75
- package/src/redirect.ts +0 -63
- package/src/router.ts +0 -1424
- package/src/scroll.ts +0 -93
- package/src/tests/integration.test.tsx +0 -298
- package/src/tests/loader.test.ts +0 -1024
- package/src/tests/manifest-snapshot.test.ts +0 -101
- package/src/tests/match.test.ts +0 -782
- package/src/tests/native-markers.test.ts +0 -18
- package/src/tests/redirect.test.ts +0 -96
- package/src/tests/router.browser.test.tsx +0 -509
- package/src/tests/router.test.ts +0 -5498
- package/src/tests/routerlink-reactive-to.browser.test.tsx +0 -158
- package/src/tests/scroll.test.ts +0 -31
- package/src/tests/setup.ts +0 -3
- package/src/types.ts +0 -517
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
|
-
}
|