@pyreon/router 0.11.5 → 0.11.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/README.md +14 -12
- package/lib/index.js.map +1 -1
- package/lib/types/index.d.ts +9 -9
- package/package.json +13 -13
- package/src/components.tsx +23 -23
- package/src/index.ts +7 -7
- package/src/loader.ts +4 -4
- package/src/match.ts +41 -41
- package/src/router.ts +86 -86
- package/src/scroll.ts +12 -12
- package/src/tests/loader.test.ts +210 -210
- package/src/tests/match.test.ts +202 -202
- package/src/tests/router.test.ts +1483 -1422
- package/src/tests/setup.ts +1 -1
- package/src/types.ts +12 -12
package/src/router.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { createContext, onUnmount, useContext } from
|
|
2
|
-
import { computed, signal } from
|
|
3
|
-
import { buildNameIndex, buildPath, resolveRoute, stringifyQuery } from
|
|
4
|
-
import { ScrollManager } from
|
|
1
|
+
import { createContext, onUnmount, useContext } from '@pyreon/core'
|
|
2
|
+
import { computed, signal } from '@pyreon/reactivity'
|
|
3
|
+
import { buildNameIndex, buildPath, resolveRoute, stringifyQuery } from './match'
|
|
4
|
+
import { ScrollManager } from './scroll'
|
|
5
5
|
import {
|
|
6
6
|
type AfterEachHook,
|
|
7
7
|
type Blocker,
|
|
@@ -16,13 +16,13 @@ import {
|
|
|
16
16
|
type Router,
|
|
17
17
|
type RouterInstance,
|
|
18
18
|
type RouterOptions,
|
|
19
|
-
} from
|
|
19
|
+
} from './types'
|
|
20
20
|
|
|
21
21
|
// Evaluated once at module load — collapses to `true` in browser / happy-dom,
|
|
22
22
|
// `false` on the server. Using a constant avoids per-call `typeof` branches
|
|
23
23
|
// that are uncoverable in test environments.
|
|
24
|
-
const _isBrowser = typeof window !==
|
|
25
|
-
const __DEV__ = typeof process !==
|
|
24
|
+
const _isBrowser = typeof window !== 'undefined'
|
|
25
|
+
const __DEV__ = typeof process !== 'undefined' && process.env.NODE_ENV !== 'production'
|
|
26
26
|
|
|
27
27
|
// ─── Router context ───────────────────────────────────────────────────────────
|
|
28
28
|
// Context-based access: isolated per request in SSR (ALS-backed via
|
|
@@ -51,19 +51,19 @@ export function useRouter(): Router {
|
|
|
51
51
|
const router = useContext(RouterContext) ?? _activeRouter
|
|
52
52
|
if (!router)
|
|
53
53
|
throw new Error(
|
|
54
|
-
|
|
54
|
+
'[pyreon-router] No router installed. Wrap your app in <RouterProvider router={router}>.',
|
|
55
55
|
)
|
|
56
56
|
return router
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
export function useRoute<TPath extends string = string>(): () => ResolvedRoute<
|
|
60
|
-
import(
|
|
60
|
+
import('./types').ExtractParams<TPath> & Record<string, string>,
|
|
61
61
|
Record<string, string>
|
|
62
62
|
> {
|
|
63
63
|
const router = useContext(RouterContext) ?? _activeRouter
|
|
64
64
|
if (!router)
|
|
65
65
|
throw new Error(
|
|
66
|
-
|
|
66
|
+
'[pyreon-router] No router installed. Wrap your app in <RouterProvider router={router}>.',
|
|
67
67
|
)
|
|
68
68
|
return router.currentRoute as never
|
|
69
69
|
}
|
|
@@ -82,7 +82,7 @@ export function onBeforeRouteLeave(guard: NavigationGuard): () => void {
|
|
|
82
82
|
const router = (useContext(RouterContext) ?? _activeRouter) as RouterInstance | null
|
|
83
83
|
if (!router)
|
|
84
84
|
throw new Error(
|
|
85
|
-
|
|
85
|
+
'[pyreon-router] No router installed. Wrap your app in <RouterProvider router={router}>.',
|
|
86
86
|
)
|
|
87
87
|
// Register as a global guard that only fires when leaving the current route
|
|
88
88
|
const currentMatched = router.currentRoute().matched
|
|
@@ -111,7 +111,7 @@ export function onBeforeRouteUpdate(guard: NavigationGuard): () => void {
|
|
|
111
111
|
const router = (useContext(RouterContext) ?? _activeRouter) as RouterInstance | null
|
|
112
112
|
if (!router)
|
|
113
113
|
throw new Error(
|
|
114
|
-
|
|
114
|
+
'[pyreon-router] No router installed. Wrap your app in <RouterProvider router={router}>.',
|
|
115
115
|
)
|
|
116
116
|
const currentMatched = router.currentRoute().matched
|
|
117
117
|
const wrappedGuard: NavigationGuard = (to, from) => {
|
|
@@ -143,7 +143,7 @@ export function useBlocker(fn: BlockerFn): Blocker {
|
|
|
143
143
|
const router = (useContext(RouterContext) ?? _activeRouter) as RouterInstance | null
|
|
144
144
|
if (!router)
|
|
145
145
|
throw new Error(
|
|
146
|
-
|
|
146
|
+
'[pyreon-router] No router installed. Wrap your app in <RouterProvider router={router}>.',
|
|
147
147
|
)
|
|
148
148
|
router._blockers.add(fn)
|
|
149
149
|
|
|
@@ -154,13 +154,13 @@ export function useBlocker(fn: BlockerFn): Blocker {
|
|
|
154
154
|
}
|
|
155
155
|
: null
|
|
156
156
|
if (beforeUnloadHandler) {
|
|
157
|
-
window.addEventListener(
|
|
157
|
+
window.addEventListener('beforeunload', beforeUnloadHandler)
|
|
158
158
|
}
|
|
159
159
|
|
|
160
160
|
const remove = () => {
|
|
161
161
|
router._blockers.delete(fn)
|
|
162
162
|
if (beforeUnloadHandler) {
|
|
163
|
-
window.removeEventListener(
|
|
163
|
+
window.removeEventListener('beforeunload', beforeUnloadHandler)
|
|
164
164
|
}
|
|
165
165
|
}
|
|
166
166
|
|
|
@@ -202,14 +202,14 @@ export function useIsActive(path: string, exact = false): () => boolean {
|
|
|
202
202
|
const router = (useContext(RouterContext) ?? _activeRouter) as RouterInstance | null
|
|
203
203
|
if (!router)
|
|
204
204
|
throw new Error(
|
|
205
|
-
|
|
205
|
+
'[pyreon-router] No router installed. Wrap your app in <RouterProvider router={router}>.',
|
|
206
206
|
)
|
|
207
207
|
return () => {
|
|
208
208
|
const current = router.currentRoute().path
|
|
209
209
|
if (exact) {
|
|
210
210
|
return matchSegments(current, path, true)
|
|
211
211
|
}
|
|
212
|
-
if (path ===
|
|
212
|
+
if (path === '/') return current === '/'
|
|
213
213
|
// Segment-aware prefix: /admin matches /admin/users but NOT /admin-panel
|
|
214
214
|
return matchSegments(current, path, false)
|
|
215
215
|
}
|
|
@@ -217,14 +217,14 @@ export function useIsActive(path: string, exact = false): () => boolean {
|
|
|
217
217
|
|
|
218
218
|
/** Match current path segments against a pattern that may contain `:param` segments. */
|
|
219
219
|
function matchSegments(current: string, pattern: string, exact: boolean): boolean {
|
|
220
|
-
const cs = current.split(
|
|
221
|
-
const ps = pattern.split(
|
|
220
|
+
const cs = current.split('/').filter(Boolean)
|
|
221
|
+
const ps = pattern.split('/').filter(Boolean)
|
|
222
222
|
if (exact) {
|
|
223
223
|
if (cs.length !== ps.length) return false
|
|
224
|
-
return ps.every((seg, i) => seg.startsWith(
|
|
224
|
+
return ps.every((seg, i) => seg.startsWith(':') || seg === cs[i])
|
|
225
225
|
}
|
|
226
226
|
if (ps.length > cs.length) return false
|
|
227
|
-
return ps.every((seg, i) => seg.startsWith(
|
|
227
|
+
return ps.every((seg, i) => seg.startsWith(':') || seg === cs[i])
|
|
228
228
|
}
|
|
229
229
|
|
|
230
230
|
export function useSearchParams<T extends Record<string, string>>(
|
|
@@ -233,7 +233,7 @@ export function useSearchParams<T extends Record<string, string>>(
|
|
|
233
233
|
const router = (useContext(RouterContext) ?? _activeRouter) as RouterInstance | null
|
|
234
234
|
if (!router)
|
|
235
235
|
throw new Error(
|
|
236
|
-
|
|
236
|
+
'[pyreon-router] No router installed. Wrap your app in <RouterProvider router={router}>.',
|
|
237
237
|
)
|
|
238
238
|
const get = (): T => {
|
|
239
239
|
const query = router.currentRoute().query
|
|
@@ -254,15 +254,15 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
|
|
|
254
254
|
const opts: RouterOptions = Array.isArray(options) ? { routes: options } : options
|
|
255
255
|
const {
|
|
256
256
|
routes,
|
|
257
|
-
mode =
|
|
257
|
+
mode = 'hash',
|
|
258
258
|
scrollBehavior,
|
|
259
259
|
onError,
|
|
260
260
|
maxCacheSize = 100,
|
|
261
|
-
trailingSlash =
|
|
261
|
+
trailingSlash = 'strip',
|
|
262
262
|
} = opts
|
|
263
263
|
|
|
264
264
|
// Base path only applies to history mode — hash-based routing already namespaces via #
|
|
265
|
-
const base = mode ===
|
|
265
|
+
const base = mode === 'history' ? normalizeBase(opts.base ?? '') : ''
|
|
266
266
|
|
|
267
267
|
// Pre-built O(1) name → record index. Computed once at startup.
|
|
268
268
|
const nameIndex = buildNameIndex(routes)
|
|
@@ -280,21 +280,21 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
|
|
|
280
280
|
const getInitialLocation = (): string => {
|
|
281
281
|
// SSR: use explicitly provided url (strip base if present)
|
|
282
282
|
if (opts.url) return stripBase(opts.url, base)
|
|
283
|
-
if (!_isBrowser) return
|
|
284
|
-
if (mode ===
|
|
283
|
+
if (!_isBrowser) return '/'
|
|
284
|
+
if (mode === 'history') {
|
|
285
285
|
return stripBase(window.location.pathname, base) + window.location.search
|
|
286
286
|
}
|
|
287
287
|
const hash = window.location.hash
|
|
288
|
-
return hash.startsWith(
|
|
288
|
+
return hash.startsWith('#') ? hash.slice(1) || '/' : '/'
|
|
289
289
|
}
|
|
290
290
|
|
|
291
291
|
const getCurrentLocation = (): string => {
|
|
292
292
|
if (!_isBrowser) return currentPath()
|
|
293
|
-
if (mode ===
|
|
293
|
+
if (mode === 'history') {
|
|
294
294
|
return stripBase(window.location.pathname, base) + window.location.search
|
|
295
295
|
}
|
|
296
296
|
const hash = window.location.hash
|
|
297
|
-
return hash.startsWith(
|
|
297
|
+
return hash.startsWith('#') ? hash.slice(1) || '/' : '/'
|
|
298
298
|
}
|
|
299
299
|
|
|
300
300
|
// ── Signals ───────────────────────────────────────────────────────────────
|
|
@@ -307,12 +307,12 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
|
|
|
307
307
|
let _hashchangeHandler: (() => void) | null = null
|
|
308
308
|
|
|
309
309
|
if (_isBrowser) {
|
|
310
|
-
if (mode ===
|
|
310
|
+
if (mode === 'history') {
|
|
311
311
|
_popstateHandler = () => currentPath.set(getCurrentLocation())
|
|
312
|
-
window.addEventListener(
|
|
312
|
+
window.addEventListener('popstate', _popstateHandler)
|
|
313
313
|
} else {
|
|
314
314
|
_hashchangeHandler = () => currentPath.set(getCurrentLocation())
|
|
315
|
-
window.addEventListener(
|
|
315
|
+
window.addEventListener('hashchange', _hashchangeHandler)
|
|
316
316
|
}
|
|
317
317
|
}
|
|
318
318
|
|
|
@@ -322,9 +322,9 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
|
|
|
322
322
|
// ── Navigation ────────────────────────────────────────────────────────────
|
|
323
323
|
|
|
324
324
|
type GuardOutcome =
|
|
325
|
-
| { action:
|
|
326
|
-
| { action:
|
|
327
|
-
| { action:
|
|
325
|
+
| { action: 'continue' }
|
|
326
|
+
| { action: 'cancel' }
|
|
327
|
+
| { action: 'redirect'; target: string }
|
|
328
328
|
|
|
329
329
|
async function evaluateGuard(
|
|
330
330
|
guard: NavigationGuard,
|
|
@@ -333,15 +333,15 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
|
|
|
333
333
|
gen: number,
|
|
334
334
|
): Promise<GuardOutcome> {
|
|
335
335
|
const result = await runGuard(guard, to, from)
|
|
336
|
-
if (gen !== _navGen) return { action:
|
|
337
|
-
if (result === false) return { action:
|
|
338
|
-
if (typeof result ===
|
|
339
|
-
return { action:
|
|
336
|
+
if (gen !== _navGen) return { action: 'cancel' }
|
|
337
|
+
if (result === false) return { action: 'cancel' }
|
|
338
|
+
if (typeof result === 'string') return { action: 'redirect', target: result }
|
|
339
|
+
return { action: 'continue' }
|
|
340
340
|
}
|
|
341
341
|
|
|
342
342
|
async function runRouteGuards(
|
|
343
343
|
records: RouteRecord[],
|
|
344
|
-
guardKey:
|
|
344
|
+
guardKey: 'beforeLeave' | 'beforeEnter',
|
|
345
345
|
to: ResolvedRoute,
|
|
346
346
|
from: ResolvedRoute,
|
|
347
347
|
gen: number,
|
|
@@ -352,10 +352,10 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
|
|
|
352
352
|
const routeGuards = Array.isArray(raw) ? raw : [raw]
|
|
353
353
|
for (const guard of routeGuards) {
|
|
354
354
|
const outcome = await evaluateGuard(guard, to, from, gen)
|
|
355
|
-
if (outcome.action !==
|
|
355
|
+
if (outcome.action !== 'continue') return outcome
|
|
356
356
|
}
|
|
357
357
|
}
|
|
358
|
-
return { action:
|
|
358
|
+
return { action: 'continue' }
|
|
359
359
|
}
|
|
360
360
|
|
|
361
361
|
async function runGlobalGuards(
|
|
@@ -366,9 +366,9 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
|
|
|
366
366
|
): Promise<GuardOutcome> {
|
|
367
367
|
for (const guard of globalGuards) {
|
|
368
368
|
const outcome = await evaluateGuard(guard, to, from, gen)
|
|
369
|
-
if (outcome.action !==
|
|
369
|
+
if (outcome.action !== 'continue') return outcome
|
|
370
370
|
}
|
|
371
|
-
return { action:
|
|
371
|
+
return { action: 'continue' }
|
|
372
372
|
}
|
|
373
373
|
|
|
374
374
|
function processLoaderResult(
|
|
@@ -377,7 +377,7 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
|
|
|
377
377
|
ac: AbortController,
|
|
378
378
|
to: ResolvedRoute,
|
|
379
379
|
): boolean {
|
|
380
|
-
if (result.status ===
|
|
380
|
+
if (result.status === 'fulfilled') {
|
|
381
381
|
router._loaderData.set(record, result.value)
|
|
382
382
|
return true
|
|
383
383
|
}
|
|
@@ -392,18 +392,18 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
|
|
|
392
392
|
|
|
393
393
|
function syncBrowserUrl(path: string, replace: boolean): void {
|
|
394
394
|
if (!_isBrowser) return
|
|
395
|
-
const url = mode ===
|
|
395
|
+
const url = mode === 'history' ? `${base}${path}` : `#${path}`
|
|
396
396
|
if (replace) {
|
|
397
|
-
window.history.replaceState(null,
|
|
397
|
+
window.history.replaceState(null, '', url)
|
|
398
398
|
} else {
|
|
399
|
-
window.history.pushState(null,
|
|
399
|
+
window.history.pushState(null, '', url)
|
|
400
400
|
}
|
|
401
401
|
}
|
|
402
402
|
|
|
403
403
|
function resolveRedirect(to: ResolvedRoute): string | null {
|
|
404
404
|
const leaf = to.matched[to.matched.length - 1]
|
|
405
405
|
if (!leaf?.redirect) return null
|
|
406
|
-
return sanitizePath(typeof leaf.redirect ===
|
|
406
|
+
return sanitizePath(typeof leaf.redirect === 'function' ? leaf.redirect(to) : leaf.redirect)
|
|
407
407
|
}
|
|
408
408
|
|
|
409
409
|
async function runAllGuards(
|
|
@@ -411,11 +411,11 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
|
|
|
411
411
|
from: ResolvedRoute,
|
|
412
412
|
gen: number,
|
|
413
413
|
): Promise<GuardOutcome> {
|
|
414
|
-
const leaveOutcome = await runRouteGuards(from.matched,
|
|
415
|
-
if (leaveOutcome.action !==
|
|
414
|
+
const leaveOutcome = await runRouteGuards(from.matched, 'beforeLeave', to, from, gen)
|
|
415
|
+
if (leaveOutcome.action !== 'continue') return leaveOutcome
|
|
416
416
|
|
|
417
|
-
const enterOutcome = await runRouteGuards(to.matched,
|
|
418
|
-
if (enterOutcome.action !==
|
|
417
|
+
const enterOutcome = await runRouteGuards(to.matched, 'beforeEnter', to, from, gen)
|
|
418
|
+
if (enterOutcome.action !== 'continue') return enterOutcome
|
|
419
419
|
|
|
420
420
|
return runGlobalGuards(guards, to, from, gen)
|
|
421
421
|
}
|
|
@@ -521,12 +521,12 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
|
|
|
521
521
|
to: ResolvedRoute,
|
|
522
522
|
from: ResolvedRoute,
|
|
523
523
|
gen: number,
|
|
524
|
-
): Promise<
|
|
524
|
+
): Promise<'continue' | 'cancel'> {
|
|
525
525
|
for (const blocker of router._blockers) {
|
|
526
526
|
const blocked = await blocker(to, from)
|
|
527
|
-
if (gen !== _navGen || blocked) return
|
|
527
|
+
if (gen !== _navGen || blocked) return 'cancel'
|
|
528
528
|
}
|
|
529
|
-
return
|
|
529
|
+
return 'continue'
|
|
530
530
|
}
|
|
531
531
|
|
|
532
532
|
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: navigation is inherently multi-step
|
|
@@ -536,7 +536,7 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
|
|
|
536
536
|
// biome-ignore lint/suspicious/noConsole: dev-only warning
|
|
537
537
|
console.warn(
|
|
538
538
|
`[Pyreon] Navigation to "${rawPath}" aborted: redirect depth exceeded 10 levels. ` +
|
|
539
|
-
|
|
539
|
+
'This likely indicates a redirect loop in your route configuration.',
|
|
540
540
|
)
|
|
541
541
|
}
|
|
542
542
|
return
|
|
@@ -556,15 +556,15 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
|
|
|
556
556
|
}
|
|
557
557
|
|
|
558
558
|
const blockerResult = await checkBlockers(to, from, gen)
|
|
559
|
-
if (blockerResult !==
|
|
559
|
+
if (blockerResult !== 'continue') {
|
|
560
560
|
loadingSignal.update((n) => n - 1)
|
|
561
561
|
return
|
|
562
562
|
}
|
|
563
563
|
|
|
564
564
|
const guardOutcome = await runAllGuards(to, from, gen)
|
|
565
|
-
if (guardOutcome.action !==
|
|
565
|
+
if (guardOutcome.action !== 'continue') {
|
|
566
566
|
loadingSignal.update((n) => n - 1)
|
|
567
|
-
if (guardOutcome.action ===
|
|
567
|
+
if (guardOutcome.action === 'redirect') {
|
|
568
568
|
return navigate(sanitizePath(guardOutcome.target), replace, redirectDepth + 1)
|
|
569
569
|
}
|
|
570
570
|
return
|
|
@@ -620,7 +620,7 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
|
|
|
620
620
|
| string
|
|
621
621
|
| { name: string; params?: Record<string, string>; query?: Record<string, string> },
|
|
622
622
|
) {
|
|
623
|
-
if (typeof location ===
|
|
623
|
+
if (typeof location === 'string') {
|
|
624
624
|
const resolved = resolveRelativePath(location, currentPath())
|
|
625
625
|
return navigate(sanitizePath(resolved), false)
|
|
626
626
|
}
|
|
@@ -638,7 +638,7 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
|
|
|
638
638
|
| string
|
|
639
639
|
| { name: string; params?: Record<string, string>; query?: Record<string, string> },
|
|
640
640
|
) {
|
|
641
|
-
if (typeof location ===
|
|
641
|
+
if (typeof location === 'string') {
|
|
642
642
|
const resolved = resolveRelativePath(location, currentPath())
|
|
643
643
|
return navigate(sanitizePath(resolved), true)
|
|
644
644
|
}
|
|
@@ -687,11 +687,11 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
|
|
|
687
687
|
|
|
688
688
|
destroy() {
|
|
689
689
|
if (_popstateHandler) {
|
|
690
|
-
window.removeEventListener(
|
|
690
|
+
window.removeEventListener('popstate', _popstateHandler)
|
|
691
691
|
_popstateHandler = null
|
|
692
692
|
}
|
|
693
693
|
if (_hashchangeHandler) {
|
|
694
|
-
window.removeEventListener(
|
|
694
|
+
window.removeEventListener('hashchange', _hashchangeHandler)
|
|
695
695
|
_hashchangeHandler = null
|
|
696
696
|
}
|
|
697
697
|
guards.length = 0
|
|
@@ -743,47 +743,47 @@ function resolveNamedPath(
|
|
|
743
743
|
): string {
|
|
744
744
|
const record = index.get(name)
|
|
745
745
|
if (!record) {
|
|
746
|
-
return
|
|
746
|
+
return '/'
|
|
747
747
|
}
|
|
748
748
|
let path = buildPath(record.path, params)
|
|
749
749
|
const qs = Object.entries(query)
|
|
750
750
|
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
|
|
751
|
-
.join(
|
|
751
|
+
.join('&')
|
|
752
752
|
if (qs) path += `?${qs}`
|
|
753
753
|
return path
|
|
754
754
|
}
|
|
755
755
|
|
|
756
756
|
/** Normalize a base path: ensure leading `/`, strip trailing `/`. */
|
|
757
757
|
function normalizeBase(raw: string): string {
|
|
758
|
-
if (!raw) return
|
|
758
|
+
if (!raw) return ''
|
|
759
759
|
let b = raw
|
|
760
|
-
if (!b.startsWith(
|
|
761
|
-
if (b.endsWith(
|
|
760
|
+
if (!b.startsWith('/')) b = `/${b}`
|
|
761
|
+
if (b.endsWith('/')) b = b.slice(0, -1)
|
|
762
762
|
return b
|
|
763
763
|
}
|
|
764
764
|
|
|
765
765
|
/** Strip the base prefix from a full URL path. Returns the app-relative path. */
|
|
766
766
|
function stripBase(path: string, base: string): string {
|
|
767
767
|
if (!base) return path
|
|
768
|
-
if (path === base || path === `${base}/`) return
|
|
768
|
+
if (path === base || path === `${base}/`) return '/'
|
|
769
769
|
if (path.startsWith(`${base}/`)) return path.slice(base.length)
|
|
770
770
|
return path
|
|
771
771
|
}
|
|
772
772
|
|
|
773
773
|
/** Normalize trailing slash on a path according to the configured strategy. */
|
|
774
|
-
function normalizeTrailingSlash(path: string, strategy:
|
|
775
|
-
if (strategy ===
|
|
774
|
+
function normalizeTrailingSlash(path: string, strategy: 'strip' | 'add' | 'ignore'): string {
|
|
775
|
+
if (strategy === 'ignore' || path === '/') return path
|
|
776
776
|
// Split off query string + hash so we only touch the path portion
|
|
777
|
-
const qIdx = path.indexOf(
|
|
778
|
-
const hIdx = path.indexOf(
|
|
777
|
+
const qIdx = path.indexOf('?')
|
|
778
|
+
const hIdx = path.indexOf('#')
|
|
779
779
|
const endIdx = qIdx >= 0 ? qIdx : hIdx >= 0 ? hIdx : path.length
|
|
780
780
|
const pathPart = path.slice(0, endIdx)
|
|
781
781
|
const suffix = path.slice(endIdx)
|
|
782
|
-
if (strategy ===
|
|
783
|
-
return pathPart.length > 1 && pathPart.endsWith(
|
|
782
|
+
if (strategy === 'strip') {
|
|
783
|
+
return pathPart.length > 1 && pathPart.endsWith('/') ? pathPart.slice(0, -1) + suffix : path
|
|
784
784
|
}
|
|
785
785
|
// strategy === "add"
|
|
786
|
-
return !pathPart.endsWith(
|
|
786
|
+
return !pathPart.endsWith('/') ? `${pathPart}/${suffix}` : path
|
|
787
787
|
}
|
|
788
788
|
|
|
789
789
|
/**
|
|
@@ -791,32 +791,32 @@ function normalizeTrailingSlash(path: string, strategy: "strip" | "add" | "ignor
|
|
|
791
791
|
* Non-relative paths are returned as-is.
|
|
792
792
|
*/
|
|
793
793
|
function resolveRelativePath(to: string, from: string): string {
|
|
794
|
-
if (!to.startsWith(
|
|
794
|
+
if (!to.startsWith('./') && !to.startsWith('../') && to !== '.' && to !== '..') return to
|
|
795
795
|
|
|
796
796
|
// Split current path into segments, drop the last segment (file-like resolution)
|
|
797
|
-
const fromSegments = from.split(
|
|
797
|
+
const fromSegments = from.split('/').filter(Boolean)
|
|
798
798
|
fromSegments.pop()
|
|
799
799
|
|
|
800
|
-
const toSegments = to.split(
|
|
800
|
+
const toSegments = to.split('/').filter(Boolean)
|
|
801
801
|
for (const seg of toSegments) {
|
|
802
|
-
if (seg ===
|
|
802
|
+
if (seg === '..') {
|
|
803
803
|
fromSegments.pop()
|
|
804
|
-
} else if (seg !==
|
|
804
|
+
} else if (seg !== '.') {
|
|
805
805
|
fromSegments.push(seg)
|
|
806
806
|
}
|
|
807
807
|
}
|
|
808
|
-
return `/${fromSegments.join(
|
|
808
|
+
return `/${fromSegments.join('/')}`
|
|
809
809
|
}
|
|
810
810
|
|
|
811
811
|
/** Block unsafe navigation targets: javascript/data/vbscript URIs and absolute URLs. */
|
|
812
812
|
function sanitizePath(path: string): string {
|
|
813
813
|
const trimmed = path.trim()
|
|
814
814
|
if (/^(?:javascript|data|vbscript):/i.test(trimmed)) {
|
|
815
|
-
return
|
|
815
|
+
return '/'
|
|
816
816
|
}
|
|
817
817
|
// Block absolute URLs and protocol-relative URLs — router only handles same-origin paths
|
|
818
818
|
if (/^\/\/|^https?:/i.test(trimmed)) {
|
|
819
|
-
return
|
|
819
|
+
return '/'
|
|
820
820
|
}
|
|
821
821
|
return path
|
|
822
822
|
}
|
package/src/scroll.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ResolvedRoute, RouterOptions } from
|
|
1
|
+
import type { ResolvedRoute, RouterOptions } from './types'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Scroll restoration manager.
|
|
@@ -8,9 +8,9 @@ import type { ResolvedRoute, RouterOptions } from "./types"
|
|
|
8
8
|
*/
|
|
9
9
|
export class ScrollManager {
|
|
10
10
|
private readonly _positions = new Map<string, number>()
|
|
11
|
-
private readonly _behavior: RouterOptions[
|
|
11
|
+
private readonly _behavior: RouterOptions['scrollBehavior']
|
|
12
12
|
|
|
13
|
-
constructor(behavior: RouterOptions[
|
|
13
|
+
constructor(behavior: RouterOptions['scrollBehavior'] = 'top') {
|
|
14
14
|
this._behavior = behavior
|
|
15
15
|
}
|
|
16
16
|
|
|
@@ -22,9 +22,9 @@ export class ScrollManager {
|
|
|
22
22
|
|
|
23
23
|
/** Call after navigation is committed — applies scroll behavior */
|
|
24
24
|
restore(to: ResolvedRoute, from: ResolvedRoute): void {
|
|
25
|
-
const behavior = (to.meta.scrollBehavior as typeof this._behavior) ?? this._behavior ??
|
|
25
|
+
const behavior = (to.meta.scrollBehavior as typeof this._behavior) ?? this._behavior ?? 'top'
|
|
26
26
|
|
|
27
|
-
if (typeof behavior ===
|
|
27
|
+
if (typeof behavior === 'function') {
|
|
28
28
|
const saved = this._positions.get(to.path) ?? null
|
|
29
29
|
const result = behavior(to, from, saved)
|
|
30
30
|
this._applyResult(result, to.path)
|
|
@@ -34,19 +34,19 @@ export class ScrollManager {
|
|
|
34
34
|
this._applyResult(behavior, to.path)
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
private _applyResult(result:
|
|
38
|
-
if (result ===
|
|
39
|
-
if (result ===
|
|
40
|
-
window.scrollTo({ top: 0, behavior:
|
|
37
|
+
private _applyResult(result: 'top' | 'restore' | 'none' | number, toPath: string): void {
|
|
38
|
+
if (result === 'none') return
|
|
39
|
+
if (result === 'top' || result === undefined) {
|
|
40
|
+
window.scrollTo({ top: 0, behavior: 'instant' as ScrollBehavior })
|
|
41
41
|
return
|
|
42
42
|
}
|
|
43
|
-
if (result ===
|
|
43
|
+
if (result === 'restore') {
|
|
44
44
|
const saved = this._positions.get(toPath) ?? 0
|
|
45
|
-
window.scrollTo({ top: saved, behavior:
|
|
45
|
+
window.scrollTo({ top: saved, behavior: 'instant' as ScrollBehavior })
|
|
46
46
|
return
|
|
47
47
|
}
|
|
48
48
|
// At this point result must be a number (all string cases handled above)
|
|
49
|
-
window.scrollTo({ top: result as number, behavior:
|
|
49
|
+
window.scrollTo({ top: result as number, behavior: 'instant' as ScrollBehavior })
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
getSavedPosition(path: string): number | null {
|