@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/lib/analysis/index.js.html +1 -1
- package/lib/index.js +510 -115
- package/lib/index.js.map +1 -1
- package/lib/types/index.d.ts +512 -112
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index2.d.ts +119 -6
- package/lib/types/index2.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/components.tsx +2 -1
- package/src/index.ts +12 -1
- package/src/match.ts +490 -87
- package/src/router.ts +336 -25
- package/src/tests/router.test.ts +629 -2
- package/src/types.ts +79 -7
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
|
-
// ───
|
|
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
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
if (
|
|
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 (
|
|
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
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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 (
|
|
133
|
-
params[
|
|
134
|
-
} else if (
|
|
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
|
-
|
|
140
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
161
|
-
|
|
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:
|
|
556
|
+
params: {},
|
|
165
557
|
query,
|
|
166
558
|
hash,
|
|
167
|
-
matched:
|
|
168
|
-
meta:
|
|
559
|
+
matched: staticMatch.matchedChain,
|
|
560
|
+
meta: staticMatch.meta,
|
|
169
561
|
}
|
|
170
562
|
}
|
|
171
563
|
|
|
172
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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("/")
|