@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/dist/index.cjs +2373 -3
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +1136 -2
- package/dist/index.d.ts +1136 -2
- package/dist/index.js +2305 -3
- package/dist/index.js.map +1 -0
- package/package.json +33 -7
- package/src/components.tsx +926 -0
- package/src/context.ts +404 -0
- package/src/data.ts +545 -0
- package/src/history.ts +659 -0
- package/src/index.ts +217 -0
- package/src/lazy.tsx +242 -0
- package/src/link.tsx +601 -0
- package/src/scroll.ts +245 -0
- package/src/types.ts +447 -0
- package/src/utils.ts +570 -0
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
|
+
}
|