@fictjs/router 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/utils.ts ADDED
@@ -0,0 +1,570 @@
1
+ /**
2
+ * @fileoverview Path matching and utility functions for @fictjs/router
3
+ *
4
+ * This module provides path parsing, matching, and scoring utilities.
5
+ * Based on patterns from Solid Router with optimizations for Fict.
6
+ */
7
+
8
+ import type {
9
+ Location,
10
+ To,
11
+ Params,
12
+ RouteMatch,
13
+ RouteDefinition,
14
+ CompiledRoute,
15
+ RouteBranch,
16
+ MatchFilter,
17
+ } from './types'
18
+
19
+ // ============================================================================
20
+ // Path Normalization
21
+ // ============================================================================
22
+
23
+ /**
24
+ * Normalize a path by removing trailing slashes and ensuring leading slash
25
+ */
26
+ export function normalizePath(path: string): string {
27
+ // Handle empty or root path
28
+ if (!path || path === '/') return '/'
29
+
30
+ // Ensure leading slash
31
+ let normalized = path.startsWith('/') ? path : '/' + path
32
+
33
+ // Remove trailing slash (except for root)
34
+ if (normalized.length > 1 && normalized.endsWith('/')) {
35
+ normalized = normalized.slice(0, -1)
36
+ }
37
+
38
+ return normalized
39
+ }
40
+
41
+ /**
42
+ * Join path segments together
43
+ */
44
+ export function joinPaths(...paths: (string | undefined)[]): string {
45
+ return normalizePath(
46
+ paths
47
+ .filter((p): p is string => p != null && p !== '')
48
+ .join('/')
49
+ .replace(/\/+/g, '/'),
50
+ )
51
+ }
52
+
53
+ /**
54
+ * Resolve a relative path against a base path
55
+ */
56
+ export function resolvePath(base: string, to: To): string {
57
+ const toPath = typeof to === 'string' ? to : to.pathname || ''
58
+
59
+ // Absolute path
60
+ if (toPath.startsWith('/')) {
61
+ return normalizePath(toPath)
62
+ }
63
+
64
+ // Relative path resolution
65
+ const baseSegments = base.split('/').filter(Boolean)
66
+
67
+ // Handle special relative segments
68
+ const toSegments = toPath.split('/').filter(Boolean)
69
+
70
+ for (const segment of toSegments) {
71
+ if (segment === '..') {
72
+ baseSegments.pop()
73
+ } else if (segment !== '.') {
74
+ baseSegments.push(segment)
75
+ }
76
+ }
77
+
78
+ return '/' + baseSegments.join('/')
79
+ }
80
+
81
+ // ============================================================================
82
+ // Location Utilities
83
+ // ============================================================================
84
+
85
+ /**
86
+ * Create a Location object from a To value
87
+ */
88
+ export function createLocation(to: To, state?: unknown, key?: string): Location {
89
+ if (typeof to === 'string') {
90
+ const url = parseURL(to)
91
+ return {
92
+ pathname: url.pathname,
93
+ search: url.search,
94
+ hash: url.hash,
95
+ state: state ?? null,
96
+ key: key ?? createKey(),
97
+ }
98
+ }
99
+
100
+ return {
101
+ pathname: to.pathname || '/',
102
+ search: to.search || '',
103
+ hash: to.hash || '',
104
+ state: state ?? to.state ?? null,
105
+ key: key ?? to.key ?? createKey(),
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Parse a URL string into its components
111
+ */
112
+ export function parseURL(url: string): { pathname: string; search: string; hash: string } {
113
+ // Handle hash first
114
+ const hashIndex = url.indexOf('#')
115
+ let hash = ''
116
+ if (hashIndex >= 0) {
117
+ hash = url.slice(hashIndex)
118
+ url = url.slice(0, hashIndex)
119
+ }
120
+
121
+ // Handle search
122
+ const searchIndex = url.indexOf('?')
123
+ let search = ''
124
+ if (searchIndex >= 0) {
125
+ search = url.slice(searchIndex)
126
+ url = url.slice(0, searchIndex)
127
+ }
128
+
129
+ return {
130
+ pathname: normalizePath(url || '/'),
131
+ search,
132
+ hash,
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Create a URL string from a Location object
138
+ */
139
+ export function createURL(location: Partial<Location>): string {
140
+ const pathname = location.pathname || '/'
141
+ const search = location.search || ''
142
+ const hash = location.hash || ''
143
+ return pathname + search + hash
144
+ }
145
+
146
+ /**
147
+ * Generate a unique key for location entries
148
+ */
149
+ let keyIndex = 0
150
+ export function createKey(): string {
151
+ return String(++keyIndex)
152
+ }
153
+
154
+ // ============================================================================
155
+ // Path Matching
156
+ // ============================================================================
157
+
158
+ /**
159
+ * Segment types for pattern matching
160
+ */
161
+ type SegmentType = 'static' | 'dynamic' | 'optional' | 'splat'
162
+
163
+ interface PathSegment {
164
+ type: SegmentType
165
+ value: string
166
+ paramName?: string
167
+ }
168
+
169
+ /**
170
+ * Parse a path pattern into segments
171
+ */
172
+ export function parsePathPattern(pattern: string): PathSegment[] {
173
+ const segments: PathSegment[] = []
174
+ const parts = pattern.split('/').filter(Boolean)
175
+
176
+ for (const part of parts) {
177
+ if (part === '*' || part.startsWith('*')) {
178
+ // Splat/catch-all segment
179
+ const paramName = part.length > 1 ? part.slice(1) : '*'
180
+ segments.push({ type: 'splat', value: part, paramName })
181
+ } else if (part.startsWith(':')) {
182
+ // Dynamic or optional segment
183
+ const isOptional = part.endsWith('?')
184
+ const paramName = isOptional ? part.slice(1, -1) : part.slice(1)
185
+ segments.push({
186
+ type: isOptional ? 'optional' : 'dynamic',
187
+ value: part,
188
+ paramName,
189
+ })
190
+ } else {
191
+ // Static segment
192
+ segments.push({ type: 'static', value: part.toLowerCase() })
193
+ }
194
+ }
195
+
196
+ return segments
197
+ }
198
+
199
+ /**
200
+ * Calculate the score for a route pattern.
201
+ * Higher score = more specific route.
202
+ *
203
+ * Scoring:
204
+ * - Static segment: 3 points
205
+ * - Dynamic segment: 2 points
206
+ * - Optional segment: 1 point
207
+ * - Splat segment: 0.5 points
208
+ * - Index route bonus: 0.5 points
209
+ */
210
+ export function scoreRoute(pattern: string, isIndex = false): number {
211
+ const segments = parsePathPattern(pattern)
212
+ let score = 0
213
+
214
+ for (const segment of segments) {
215
+ switch (segment.type) {
216
+ case 'static':
217
+ score += 3
218
+ break
219
+ case 'dynamic':
220
+ score += 2
221
+ break
222
+ case 'optional':
223
+ score += 1
224
+ break
225
+ case 'splat':
226
+ score += 0.5
227
+ break
228
+ }
229
+ }
230
+
231
+ // Index route gets a small bonus
232
+ if (isIndex) {
233
+ score += 0.5
234
+ }
235
+
236
+ return score
237
+ }
238
+
239
+ /**
240
+ * Create a matcher function for a path pattern
241
+ */
242
+ export function createMatcher(
243
+ pattern: string,
244
+ matchFilters?: Record<string, MatchFilter>,
245
+ ): (pathname: string) => RouteMatch | null {
246
+ const segments = parsePathPattern(pattern)
247
+ const normalizedPattern = normalizePath(pattern)
248
+
249
+ return (pathname: string): RouteMatch | null => {
250
+ const pathSegments = pathname.split('/').filter(Boolean)
251
+ const params: Record<string, string> = {}
252
+ let matchedPath = ''
253
+ let pathIndex = 0
254
+
255
+ for (let i = 0; i < segments.length; i++) {
256
+ const segment = segments[i]!
257
+ const pathSegment = pathSegments[pathIndex]
258
+
259
+ switch (segment.type) {
260
+ case 'static':
261
+ // Must match exactly (case-insensitive)
262
+ if (!pathSegment || pathSegment.toLowerCase() !== segment.value) {
263
+ return null
264
+ }
265
+ matchedPath += '/' + pathSegment
266
+ pathIndex++
267
+ break
268
+
269
+ case 'dynamic':
270
+ // Must have a value
271
+ if (!pathSegment) {
272
+ return null
273
+ }
274
+ // Validate with filter if provided
275
+ if (matchFilters && segment.paramName && matchFilters[segment.paramName]) {
276
+ if (!validateParam(pathSegment, matchFilters[segment.paramName]!)) {
277
+ return null
278
+ }
279
+ }
280
+ params[segment.paramName!] = decodeURIComponent(pathSegment)
281
+ matchedPath += '/' + pathSegment
282
+ pathIndex++
283
+ break
284
+
285
+ case 'optional': {
286
+ // May or may not have a value
287
+ if (pathSegment) {
288
+ // Look ahead: if next pattern segment is static and matches current path segment,
289
+ // skip this optional to allow the static to match
290
+ const nextSegment = segments[i + 1]
291
+ if (
292
+ nextSegment &&
293
+ nextSegment.type === 'static' &&
294
+ pathSegment.toLowerCase() === nextSegment.value
295
+ ) {
296
+ // Skip this optional - don't consume the path segment
297
+ // so the next iteration can match it as static
298
+ break
299
+ }
300
+
301
+ // Validate with filter if provided
302
+ if (matchFilters && segment.paramName && matchFilters[segment.paramName]) {
303
+ if (!validateParam(pathSegment, matchFilters[segment.paramName]!)) {
304
+ // Optional segment doesn't match filter, treat as not provided
305
+ break
306
+ }
307
+ }
308
+ params[segment.paramName!] = decodeURIComponent(pathSegment)
309
+ matchedPath += '/' + pathSegment
310
+ pathIndex++
311
+ }
312
+ break
313
+ }
314
+
315
+ case 'splat': {
316
+ // Capture remaining path
317
+ // Decode each segment individually to handle encoded slashes correctly
318
+ const remainingSegments = pathSegments.slice(pathIndex)
319
+ const decodedSegments = remainingSegments.map(seg => {
320
+ try {
321
+ return decodeURIComponent(seg)
322
+ } catch {
323
+ // If decoding fails (malformed URI), use the original segment
324
+ return seg
325
+ }
326
+ })
327
+ params[segment.paramName!] = decodedSegments.join('/')
328
+ matchedPath += remainingSegments.length > 0 ? '/' + remainingSegments.join('/') : ''
329
+ pathIndex = pathSegments.length
330
+ break
331
+ }
332
+ }
333
+ }
334
+
335
+ // If we haven't consumed all path segments, this is not a match
336
+ // (unless the last segment was a splat)
337
+ if (pathIndex < pathSegments.length) {
338
+ return null
339
+ }
340
+
341
+ return {
342
+ route: {} as RouteDefinition, // Will be filled in by caller
343
+ pathname: matchedPath || '/',
344
+ params: params as Params,
345
+ pattern: normalizedPattern,
346
+ }
347
+ }
348
+ }
349
+
350
+ /**
351
+ * Validate a parameter value against a filter
352
+ */
353
+ function validateParam(value: string, filter: MatchFilter): boolean {
354
+ if (filter instanceof RegExp) {
355
+ return filter.test(value)
356
+ }
357
+ if (Array.isArray(filter)) {
358
+ return filter.includes(value)
359
+ }
360
+ if (typeof filter === 'function') {
361
+ return filter(value)
362
+ }
363
+ return true
364
+ }
365
+
366
+ // ============================================================================
367
+ // Route Compilation
368
+ // ============================================================================
369
+
370
+ let routeKeyCounter = 0
371
+
372
+ /**
373
+ * Compile a route definition into a CompiledRoute
374
+ */
375
+ export function compileRoute(route: RouteDefinition, parentPattern = ''): CompiledRoute {
376
+ const pattern = normalizePath(
377
+ joinPaths(parentPattern, route.path || (route.index ? '' : undefined)),
378
+ )
379
+
380
+ const compiled: CompiledRoute = {
381
+ route,
382
+ pattern,
383
+ matcher: createMatcher(pattern, route.matchFilters as Record<string, MatchFilter>),
384
+ score: scoreRoute(pattern, route.index),
385
+ key: route.key || `route-${++routeKeyCounter}`,
386
+ }
387
+
388
+ if (route.children && route.children.length > 0) {
389
+ compiled.children = route.children.map(child =>
390
+ compileRoute(child, route.index ? parentPattern : pattern),
391
+ )
392
+ }
393
+
394
+ return compiled
395
+ }
396
+
397
+ /**
398
+ * Create branches from compiled routes for efficient matching.
399
+ * A branch represents a complete path from root to leaf.
400
+ */
401
+ export function createBranches(routes: CompiledRoute[]): RouteBranch[] {
402
+ const branches: RouteBranch[] = []
403
+
404
+ function buildBranches(route: CompiledRoute, parentRoutes: CompiledRoute[] = []): void {
405
+ const currentRoutes = [...parentRoutes, route]
406
+
407
+ if (route.children && route.children.length > 0) {
408
+ for (const child of route.children) {
409
+ buildBranches(child, currentRoutes)
410
+ }
411
+ } else {
412
+ // Leaf route - create a branch
413
+ const score = currentRoutes.reduce((sum, r) => sum + r.score, 0)
414
+
415
+ const branchMatcher = (pathname: string): RouteMatch[] | null => {
416
+ const matches: RouteMatch[] = []
417
+ let remainingPath = pathname
418
+ let accumulatedParams: Record<string, string | undefined> = {}
419
+
420
+ for (const compiledRoute of currentRoutes) {
421
+ const match = compiledRoute.matcher(remainingPath)
422
+ if (!match) {
423
+ return null
424
+ }
425
+
426
+ // Accumulate params
427
+ accumulatedParams = { ...accumulatedParams, ...match.params }
428
+
429
+ matches.push({
430
+ ...match,
431
+ route: compiledRoute.route,
432
+ params: { ...accumulatedParams } as Params,
433
+ })
434
+
435
+ // For nested routes, the remaining path should be after the matched portion
436
+ // But only if this isn't the leaf route
437
+ if (compiledRoute !== currentRoutes[currentRoutes.length - 1]) {
438
+ if (match.pathname !== '/') {
439
+ remainingPath = remainingPath.slice(match.pathname.length) || '/'
440
+ }
441
+ }
442
+ }
443
+
444
+ return matches
445
+ }
446
+
447
+ branches.push({
448
+ routes: currentRoutes,
449
+ score,
450
+ matcher: branchMatcher,
451
+ })
452
+ }
453
+ }
454
+
455
+ for (const route of routes) {
456
+ buildBranches(route)
457
+ }
458
+
459
+ // Sort branches by score (highest first)
460
+ branches.sort((a, b) => b.score - a.score)
461
+
462
+ return branches
463
+ }
464
+
465
+ /**
466
+ * Match a pathname against route branches
467
+ */
468
+ export function matchRoutes(branches: RouteBranch[], pathname: string): RouteMatch[] | null {
469
+ const normalizedPath = normalizePath(pathname)
470
+
471
+ for (const branch of branches) {
472
+ const matches = branch.matcher(normalizedPath)
473
+ if (matches) {
474
+ return matches
475
+ }
476
+ }
477
+
478
+ return null
479
+ }
480
+
481
+ // ============================================================================
482
+ // Search Params Utilities
483
+ // ============================================================================
484
+
485
+ /**
486
+ * Parse search params from a search string
487
+ */
488
+ export function parseSearchParams(search: string): URLSearchParams {
489
+ return new URLSearchParams(search)
490
+ }
491
+
492
+ /**
493
+ * Stringify search params to a search string
494
+ */
495
+ export function stringifySearchParams(params: URLSearchParams | Record<string, string>): string {
496
+ const searchParams = params instanceof URLSearchParams ? params : new URLSearchParams(params)
497
+
498
+ const str = searchParams.toString()
499
+ return str ? '?' + str : ''
500
+ }
501
+
502
+ // ============================================================================
503
+ // Misc Utilities
504
+ // ============================================================================
505
+
506
+ /**
507
+ * Check if two locations are equal
508
+ */
509
+ export function locationsAreEqual(a: Location, b: Location): boolean {
510
+ return a.pathname === b.pathname && a.search === b.search && a.hash === b.hash
511
+ }
512
+
513
+ /**
514
+ * Strip the base path from a pathname
515
+ */
516
+ export function stripBasePath(pathname: string, basePath: string): string {
517
+ if (basePath === '/' || basePath === '') {
518
+ return pathname
519
+ }
520
+
521
+ const normalizedBase = normalizePath(basePath)
522
+ if (pathname.startsWith(normalizedBase)) {
523
+ const stripped = pathname.slice(normalizedBase.length)
524
+ return stripped || '/'
525
+ }
526
+
527
+ return pathname
528
+ }
529
+
530
+ /**
531
+ * Prepend the base path to a pathname
532
+ */
533
+ export function prependBasePath(pathname: string, basePath: string): string {
534
+ if (basePath === '/' || basePath === '') {
535
+ return pathname
536
+ }
537
+
538
+ return joinPaths(basePath, pathname)
539
+ }
540
+
541
+ /**
542
+ * Generate a stable hash for route params (for caching)
543
+ */
544
+ export function hashParams(params: Record<string, unknown>): string {
545
+ const entries = Object.entries(params).sort(([a], [b]) => a.localeCompare(b))
546
+ return JSON.stringify(entries)
547
+ }
548
+
549
+ /**
550
+ * Scroll to top of the page
551
+ */
552
+ export function scrollToTop(): void {
553
+ if (typeof window !== 'undefined') {
554
+ window.scrollTo(0, 0)
555
+ }
556
+ }
557
+
558
+ /**
559
+ * Check if code is running on the server
560
+ */
561
+ export function isServer(): boolean {
562
+ return typeof window === 'undefined'
563
+ }
564
+
565
+ /**
566
+ * Check if code is running in the browser
567
+ */
568
+ export function isBrowser(): boolean {
569
+ return typeof window !== 'undefined'
570
+ }