@pyreon/router 0.1.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/match.ts ADDED
@@ -0,0 +1,264 @@
1
+ import type { ResolvedRoute, RouteMeta, RouteRecord } from "./types"
2
+
3
+ // ─── Query string ─────────────────────────────────────────────────────────────
4
+
5
+ /**
6
+ * Parse a query string into key-value pairs. Duplicate keys are overwritten
7
+ * (last value wins). Use `parseQueryMulti` to preserve duplicates as arrays.
8
+ */
9
+ export function parseQuery(qs: string): Record<string, string> {
10
+ if (!qs) return {}
11
+ const result: Record<string, string> = {}
12
+ for (const part of qs.split("&")) {
13
+ const eqIdx = part.indexOf("=")
14
+ if (eqIdx < 0) {
15
+ const key = decodeURIComponent(part)
16
+ if (key) result[key] = ""
17
+ } else {
18
+ const key = decodeURIComponent(part.slice(0, eqIdx))
19
+ const val = decodeURIComponent(part.slice(eqIdx + 1))
20
+ if (key) result[key] = val
21
+ }
22
+ }
23
+ return result
24
+ }
25
+
26
+ /**
27
+ * Parse a query string preserving duplicate keys as arrays.
28
+ *
29
+ * @example
30
+ * parseQueryMulti("color=red&color=blue&size=lg")
31
+ * // → { color: ["red", "blue"], size: "lg" }
32
+ */
33
+ export function parseQueryMulti(qs: string): Record<string, string | string[]> {
34
+ if (!qs) return {}
35
+ const result: Record<string, string | string[]> = {}
36
+ for (const part of qs.split("&")) {
37
+ const eqIdx = part.indexOf("=")
38
+ let key: string
39
+ let val: string
40
+ if (eqIdx < 0) {
41
+ key = decodeURIComponent(part)
42
+ val = ""
43
+ } else {
44
+ key = decodeURIComponent(part.slice(0, eqIdx))
45
+ val = decodeURIComponent(part.slice(eqIdx + 1))
46
+ }
47
+ if (!key) continue
48
+ const existing = result[key]
49
+ if (existing === undefined) {
50
+ result[key] = val
51
+ } else if (Array.isArray(existing)) {
52
+ existing.push(val)
53
+ } else {
54
+ result[key] = [existing, val]
55
+ }
56
+ }
57
+ return result
58
+ }
59
+
60
+ export function stringifyQuery(query: Record<string, string>): string {
61
+ const parts: string[] = []
62
+ for (const [k, v] of Object.entries(query)) {
63
+ parts.push(v ? `${encodeURIComponent(k)}=${encodeURIComponent(v)}` : encodeURIComponent(k))
64
+ }
65
+ return parts.length ? `?${parts.join("&")}` : ""
66
+ }
67
+
68
+ // ─── Path matching ────────────────────────────────────────────────────────────
69
+
70
+ /**
71
+ * Match a single route pattern against a path segment.
72
+ * Returns extracted params or null if no match.
73
+ *
74
+ * Supports:
75
+ * - Exact segments: "/about"
76
+ * - Param segments: "/user/:id"
77
+ * - Wildcard: "(.*)" matches everything
78
+ */
79
+ export function matchPath(pattern: string, path: string): Record<string, string> | null {
80
+ // Wildcard pattern
81
+ if (pattern === "(.*)" || pattern === "*") return {}
82
+
83
+ const patternParts = pattern.split("/").filter(Boolean)
84
+ const pathParts = path.split("/").filter(Boolean)
85
+
86
+ const params: Record<string, string> = {}
87
+ for (let i = 0; i < patternParts.length; i++) {
88
+ const pp = patternParts[i] as string
89
+ const pt = pathParts[i] as string
90
+ // Splat param — captures the rest of the path (e.g. ":path*")
91
+ if (pp.endsWith("*") && pp.startsWith(":")) {
92
+ const paramName = pp.slice(1, -1)
93
+ params[paramName] = pathParts.slice(i).map(decodeURIComponent).join("/")
94
+ return params
95
+ }
96
+ if (pp.startsWith(":")) {
97
+ params[pp.slice(1)] = decodeURIComponent(pt)
98
+ } else if (pp !== pt) {
99
+ return null
100
+ }
101
+ }
102
+
103
+ if (patternParts.length !== pathParts.length) return null
104
+ return params
105
+ }
106
+
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 }
116
+
117
+ const patternParts = pattern.split("/").filter(Boolean)
118
+ const pathParts = path.split("/").filter(Boolean)
119
+
120
+ if (pathParts.length < patternParts.length) return null
121
+
122
+ const params: Record<string, string> = {}
123
+ for (let i = 0; i < patternParts.length; i++) {
124
+ const pp = patternParts[i] as string
125
+ const pt = pathParts[i] as string
126
+ // Splat param in prefix — captures the rest
127
+ if (pp.endsWith("*") && pp.startsWith(":")) {
128
+ const paramName = pp.slice(1, -1)
129
+ params[paramName] = pathParts.slice(i).map(decodeURIComponent).join("/")
130
+ return { params, rest: "/" }
131
+ }
132
+ if (pp.startsWith(":")) {
133
+ params[pp.slice(1)] = decodeURIComponent(pt)
134
+ } else if (pp !== pt) {
135
+ return null
136
+ }
137
+ }
138
+
139
+ const rest = `/${pathParts.slice(patternParts.length).join("/")}`
140
+ return { params, rest }
141
+ }
142
+
143
+ // ─── Route resolution ─────────────────────────────────────────────────────────
144
+
145
+ /**
146
+ * Resolve a raw path (including query string and hash) against the route tree.
147
+ * Handles nested routes recursively.
148
+ */
149
+ export function resolveRoute(rawPath: string, routes: RouteRecord[]): ResolvedRoute {
150
+ const qIdx = rawPath.indexOf("?")
151
+ const pathAndHash = qIdx >= 0 ? rawPath.slice(0, qIdx) : rawPath
152
+ const queryPart = qIdx >= 0 ? rawPath.slice(qIdx + 1) : ""
153
+
154
+ const hIdx = pathAndHash.indexOf("#")
155
+ const cleanPath = hIdx >= 0 ? pathAndHash.slice(0, hIdx) : pathAndHash
156
+ const hash = hIdx >= 0 ? pathAndHash.slice(hIdx + 1) : ""
157
+
158
+ const query = parseQuery(queryPart)
159
+
160
+ const match = matchRoutes(cleanPath, routes, [])
161
+ if (match) {
162
+ return {
163
+ path: cleanPath,
164
+ params: match.params,
165
+ query,
166
+ hash,
167
+ matched: match.matched,
168
+ meta: mergeMeta(match.matched),
169
+ }
170
+ }
171
+
172
+ return { path: cleanPath, params: {}, query, hash, matched: [], meta: {} }
173
+ }
174
+
175
+ interface MatchResult {
176
+ params: Record<string, string>
177
+ matched: RouteRecord[]
178
+ }
179
+
180
+ function matchRoutes(
181
+ path: string,
182
+ routes: RouteRecord[],
183
+ parentMatched: RouteRecord[],
184
+ parentParams: Record<string, string> = {},
185
+ ): MatchResult | null {
186
+ for (const route of routes) {
187
+ const result = matchSingleRoute(path, route, parentMatched, parentParams)
188
+ if (result) return result
189
+ }
190
+ return null
191
+ }
192
+
193
+ function matchSingleRoute(
194
+ path: string,
195
+ route: RouteRecord,
196
+ parentMatched: RouteRecord[],
197
+ parentParams: Record<string, string>,
198
+ ): MatchResult | null {
199
+ if (!route.children || route.children.length === 0) {
200
+ const params = matchPath(route.path, path)
201
+ if (params === null) return null
202
+ return { params: { ...parentParams, ...params }, matched: [...parentMatched, route] }
203
+ }
204
+
205
+ const prefix = matchPrefix(route.path, path)
206
+ if (prefix === null) return null
207
+
208
+ const allParams = { ...parentParams, ...prefix.params }
209
+ const matched = [...parentMatched, route]
210
+
211
+ const childMatch = matchRoutes(prefix.rest, route.children, matched, allParams)
212
+ if (childMatch) return childMatch
213
+
214
+ const exactParams = matchPath(route.path, path)
215
+ if (exactParams === null) return null
216
+ return { params: { ...parentParams, ...exactParams }, matched }
217
+ }
218
+
219
+ /** Merge meta from matched routes (leaf takes precedence) */
220
+ function mergeMeta(matched: RouteRecord[]): RouteMeta {
221
+ const meta: RouteMeta = {}
222
+ for (const record of matched) {
223
+ if (record.meta) Object.assign(meta, record.meta)
224
+ }
225
+ return meta
226
+ }
227
+
228
+ /** Build a path string from a named route's pattern and params */
229
+ export function buildPath(pattern: string, params: Record<string, string>): string {
230
+ return pattern.replace(/:([^/]+)\*?/g, (match, key) => {
231
+ const val = params[key] ?? ""
232
+ // Splat params contain slashes — don't encode them
233
+ if (match.endsWith("*")) return val.split("/").map(encodeURIComponent).join("/")
234
+ return encodeURIComponent(val)
235
+ })
236
+ }
237
+
238
+ /** Find a route record by name (recursive, O(n)). Prefer buildNameIndex for repeated lookups. */
239
+ export function findRouteByName(name: string, routes: RouteRecord[]): RouteRecord | null {
240
+ for (const route of routes) {
241
+ if (route.name === name) return route
242
+ if (route.children) {
243
+ const found = findRouteByName(name, route.children)
244
+ if (found) return found
245
+ }
246
+ }
247
+ return null
248
+ }
249
+
250
+ /**
251
+ * Pre-build a name → RouteRecord index from a route tree for O(1) named navigation.
252
+ * Called once at router creation time; avoids O(n) depth-first search per push({ name }).
253
+ */
254
+ export function buildNameIndex(routes: RouteRecord[]): Map<string, RouteRecord> {
255
+ const index = new Map<string, RouteRecord>()
256
+ function walk(list: RouteRecord[]): void {
257
+ for (const route of list) {
258
+ if (route.name) index.set(route.name, route)
259
+ if (route.children) walk(route.children)
260
+ }
261
+ }
262
+ walk(routes)
263
+ return index
264
+ }
package/src/router.ts ADDED
@@ -0,0 +1,451 @@
1
+ import { createContext, useContext } from "@pyreon/core"
2
+ import { computed, signal } from "@pyreon/reactivity"
3
+ import { buildNameIndex, buildPath, resolveRoute } from "./match"
4
+ import { ScrollManager } from "./scroll"
5
+ import {
6
+ type AfterEachHook,
7
+ type ComponentFn,
8
+ isLazy,
9
+ type LoaderContext,
10
+ type NavigationGuard,
11
+ type NavigationGuardResult,
12
+ type ResolvedRoute,
13
+ type RouteRecord,
14
+ type Router,
15
+ type RouterInstance,
16
+ type RouterOptions,
17
+ } from "./types"
18
+
19
+ // Evaluated once at module load — collapses to `true` in browser / happy-dom,
20
+ // `false` on the server. Using a constant avoids per-call `typeof` branches
21
+ // that are uncoverable in test environments.
22
+ const _isBrowser = typeof window !== "undefined"
23
+
24
+ // ─── Router context ───────────────────────────────────────────────────────────
25
+ // Context-based access: isolated per request in SSR (ALS-backed via
26
+ // @pyreon/runtime-server), isolated per component tree in CSR.
27
+ // Falls back to the module-level singleton for code running outside a component
28
+ // tree (e.g. programmatic navigation from event handlers).
29
+
30
+ export const RouterContext = createContext<RouterInstance | null>(null)
31
+
32
+ // Module-level fallback — safe for CSR (single-threaded), not for concurrent SSR.
33
+ // RouterProvider also sets this so legacy useRouter() calls outside the tree work.
34
+ let _activeRouter: RouterInstance | null = null
35
+
36
+ export function getActiveRouter(): RouterInstance | null {
37
+ return useContext(RouterContext) ?? _activeRouter
38
+ }
39
+
40
+ export function setActiveRouter(router: RouterInstance | null): void {
41
+ if (router) router._viewDepth = 0
42
+ _activeRouter = router
43
+ }
44
+
45
+ // ─── Hooks ────────────────────────────────────────────────────────────────────
46
+
47
+ export function useRouter(): Router {
48
+ const router = useContext(RouterContext) ?? _activeRouter
49
+ if (!router)
50
+ throw new Error(
51
+ "[pyreon-router] No router installed. Wrap your app in <RouterProvider router={router}>.",
52
+ )
53
+ return router
54
+ }
55
+
56
+ export function useRoute<TPath extends string = string>(): () => ResolvedRoute<
57
+ import("./types").ExtractParams<TPath>,
58
+ Record<string, string>
59
+ > {
60
+ const router = useContext(RouterContext) ?? _activeRouter
61
+ if (!router)
62
+ throw new Error(
63
+ "[pyreon-router] No router installed. Wrap your app in <RouterProvider router={router}>.",
64
+ )
65
+ return router.currentRoute as never
66
+ }
67
+
68
+ // ─── Factory ──────────────────────────────────────────────────────────────────
69
+
70
+ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
71
+ const opts: RouterOptions = Array.isArray(options) ? { routes: options } : options
72
+ const { routes, mode = "hash", scrollBehavior, onError, maxCacheSize = 100 } = opts
73
+
74
+ // Pre-built O(1) name → record index. Computed once at startup.
75
+ const nameIndex = buildNameIndex(routes)
76
+
77
+ const guards: NavigationGuard[] = []
78
+ const afterHooks: AfterEachHook[] = []
79
+ const scrollManager = new ScrollManager(scrollBehavior)
80
+
81
+ // Navigation generation counter — cancels in-flight navigations when a newer
82
+ // one starts. Prevents out-of-order completion from stale async guards.
83
+ let _navGen = 0
84
+
85
+ // ── Initial location ──────────────────────────────────────────────────────
86
+
87
+ const getInitialLocation = (): string => {
88
+ // SSR: use explicitly provided url
89
+ if (opts.url) return opts.url
90
+ if (!_isBrowser) return "/"
91
+ if (mode === "history") {
92
+ return window.location.pathname + window.location.search
93
+ }
94
+ const hash = window.location.hash
95
+ return hash.startsWith("#") ? hash.slice(1) || "/" : "/"
96
+ }
97
+
98
+ const getCurrentLocation = (): string => {
99
+ if (!_isBrowser) return currentPath()
100
+ if (mode === "history") {
101
+ return window.location.pathname + window.location.search
102
+ }
103
+ const hash = window.location.hash
104
+ return hash.startsWith("#") ? hash.slice(1) || "/" : "/"
105
+ }
106
+
107
+ // ── Signals ───────────────────────────────────────────────────────────────
108
+
109
+ const currentPath = signal(getInitialLocation())
110
+ const currentRoute = computed<ResolvedRoute>(() => resolveRoute(currentPath(), routes))
111
+
112
+ // Browser event listeners — stored so destroy() can remove them
113
+ let _popstateHandler: (() => void) | null = null
114
+ let _hashchangeHandler: (() => void) | null = null
115
+
116
+ if (_isBrowser) {
117
+ if (mode === "history") {
118
+ _popstateHandler = () => currentPath.set(getCurrentLocation())
119
+ window.addEventListener("popstate", _popstateHandler)
120
+ } else {
121
+ _hashchangeHandler = () => currentPath.set(getCurrentLocation())
122
+ window.addEventListener("hashchange", _hashchangeHandler)
123
+ }
124
+ }
125
+
126
+ const componentCache = new Map<RouteRecord, ComponentFn>()
127
+ const loadingSignal = signal(0)
128
+
129
+ // ── Navigation ────────────────────────────────────────────────────────────
130
+
131
+ type GuardOutcome =
132
+ | { action: "continue" }
133
+ | { action: "cancel" }
134
+ | { action: "redirect"; target: string }
135
+
136
+ async function evaluateGuard(
137
+ guard: NavigationGuard,
138
+ to: ResolvedRoute,
139
+ from: ResolvedRoute,
140
+ gen: number,
141
+ ): Promise<GuardOutcome> {
142
+ const result = await runGuard(guard, to, from)
143
+ if (gen !== _navGen) return { action: "cancel" }
144
+ if (result === false) return { action: "cancel" }
145
+ if (typeof result === "string") return { action: "redirect", target: result }
146
+ return { action: "continue" }
147
+ }
148
+
149
+ async function runRouteGuards(
150
+ records: RouteRecord[],
151
+ guardKey: "beforeLeave" | "beforeEnter",
152
+ to: ResolvedRoute,
153
+ from: ResolvedRoute,
154
+ gen: number,
155
+ ): Promise<GuardOutcome> {
156
+ for (const record of records) {
157
+ const raw = record[guardKey]
158
+ if (!raw) continue
159
+ const routeGuards = Array.isArray(raw) ? raw : [raw]
160
+ for (const guard of routeGuards) {
161
+ const outcome = await evaluateGuard(guard, to, from, gen)
162
+ if (outcome.action !== "continue") return outcome
163
+ }
164
+ }
165
+ return { action: "continue" }
166
+ }
167
+
168
+ async function runGlobalGuards(
169
+ globalGuards: NavigationGuard[],
170
+ to: ResolvedRoute,
171
+ from: ResolvedRoute,
172
+ gen: number,
173
+ ): Promise<GuardOutcome> {
174
+ for (const guard of globalGuards) {
175
+ const outcome = await evaluateGuard(guard, to, from, gen)
176
+ if (outcome.action !== "continue") return outcome
177
+ }
178
+ return { action: "continue" }
179
+ }
180
+
181
+ function processLoaderResult(
182
+ result: PromiseSettledResult<unknown>,
183
+ record: RouteRecord,
184
+ ac: AbortController,
185
+ to: ResolvedRoute,
186
+ ): boolean {
187
+ if (result.status === "fulfilled") {
188
+ router._loaderData.set(record, result.value)
189
+ return true
190
+ }
191
+ if (ac.signal.aborted) return true
192
+ if (router._onError) {
193
+ const cancel = router._onError(result.reason, to)
194
+ if (cancel === false) return false
195
+ }
196
+ router._loaderData.set(record, undefined)
197
+ return true
198
+ }
199
+
200
+ function syncBrowserUrl(path: string, replace: boolean): void {
201
+ if (!_isBrowser) return
202
+ const url = mode === "history" ? path : `#${path}`
203
+ if (replace) {
204
+ window.history.replaceState(null, "", url)
205
+ } else {
206
+ window.history.pushState(null, "", url)
207
+ }
208
+ }
209
+
210
+ function resolveRedirect(to: ResolvedRoute): string | null {
211
+ const leaf = to.matched[to.matched.length - 1]
212
+ if (!leaf?.redirect) return null
213
+ return sanitizePath(typeof leaf.redirect === "function" ? leaf.redirect(to) : leaf.redirect)
214
+ }
215
+
216
+ async function runAllGuards(
217
+ to: ResolvedRoute,
218
+ from: ResolvedRoute,
219
+ gen: number,
220
+ ): Promise<GuardOutcome> {
221
+ const leaveOutcome = await runRouteGuards(from.matched, "beforeLeave", to, from, gen)
222
+ if (leaveOutcome.action !== "continue") return leaveOutcome
223
+
224
+ const enterOutcome = await runRouteGuards(to.matched, "beforeEnter", to, from, gen)
225
+ if (enterOutcome.action !== "continue") return enterOutcome
226
+
227
+ return runGlobalGuards(guards, to, from, gen)
228
+ }
229
+
230
+ async function runLoaders(to: ResolvedRoute, gen: number, ac: AbortController): Promise<boolean> {
231
+ const loadableRecords = to.matched.filter((r) => r.loader)
232
+ if (loadableRecords.length === 0) return true
233
+
234
+ const loaderCtx: LoaderContext = { params: to.params, query: to.query, signal: ac.signal }
235
+ const results = await Promise.allSettled(
236
+ loadableRecords.map((r) => {
237
+ if (!r.loader) return Promise.resolve(undefined)
238
+ return r.loader(loaderCtx)
239
+ }),
240
+ )
241
+ if (gen !== _navGen) return false
242
+
243
+ for (let i = 0; i < loadableRecords.length; i++) {
244
+ const result = results[i]
245
+ const record = loadableRecords[i]
246
+ if (!result || !record) continue
247
+ if (!processLoaderResult(result, record, ac, to)) return false
248
+ }
249
+ return true
250
+ }
251
+
252
+ function commitNavigation(
253
+ path: string,
254
+ replace: boolean,
255
+ to: ResolvedRoute,
256
+ from: ResolvedRoute,
257
+ ): void {
258
+ scrollManager.save(from.path)
259
+ currentPath.set(path)
260
+ syncBrowserUrl(path, replace)
261
+
262
+ if (_isBrowser && to.meta.title) {
263
+ document.title = to.meta.title
264
+ }
265
+
266
+ for (const record of router._loaderData.keys()) {
267
+ if (!to.matched.includes(record)) {
268
+ router._loaderData.delete(record)
269
+ }
270
+ }
271
+
272
+ for (const hook of afterHooks) {
273
+ try {
274
+ hook(to, from)
275
+ } catch (_err) {
276
+ /* hook errors silently ignored */
277
+ }
278
+ }
279
+
280
+ if (_isBrowser) {
281
+ queueMicrotask(() => scrollManager.restore(to, from))
282
+ }
283
+ }
284
+
285
+ async function navigate(path: string, replace: boolean, redirectDepth = 0): Promise<void> {
286
+ if (redirectDepth > 10) return
287
+
288
+ const gen = ++_navGen
289
+ loadingSignal.update((n) => n + 1)
290
+
291
+ const to = resolveRoute(path, routes)
292
+ const from = currentRoute()
293
+
294
+ const redirectTarget = resolveRedirect(to)
295
+ if (redirectTarget !== null) {
296
+ loadingSignal.update((n) => n - 1)
297
+ return navigate(redirectTarget, replace, redirectDepth + 1)
298
+ }
299
+
300
+ const guardOutcome = await runAllGuards(to, from, gen)
301
+ if (guardOutcome.action !== "continue") {
302
+ loadingSignal.update((n) => n - 1)
303
+ if (guardOutcome.action === "redirect") {
304
+ return navigate(sanitizePath(guardOutcome.target), replace, redirectDepth + 1)
305
+ }
306
+ return
307
+ }
308
+
309
+ router._abortController?.abort()
310
+ const ac = new AbortController()
311
+ router._abortController = ac
312
+
313
+ const loadersOk = await runLoaders(to, gen, ac)
314
+ if (!loadersOk) {
315
+ loadingSignal.update((n) => n - 1)
316
+ return
317
+ }
318
+
319
+ commitNavigation(path, replace, to, from)
320
+ loadingSignal.update((n) => n - 1)
321
+ }
322
+
323
+ // ── Public router object ──────────────────────────────────────────────────
324
+
325
+ const router: RouterInstance = {
326
+ routes,
327
+ mode,
328
+ currentRoute,
329
+ _currentPath: currentPath,
330
+ _currentRoute: currentRoute,
331
+ _componentCache: componentCache,
332
+ _loadingSignal: loadingSignal,
333
+ _scrollPositions: new Map(),
334
+ _scrollBehavior: scrollBehavior,
335
+ _viewDepth: 0,
336
+ _erroredChunks: new Set(),
337
+ _loaderData: new Map(),
338
+ _abortController: null,
339
+ _onError: onError,
340
+ _maxCacheSize: maxCacheSize,
341
+
342
+ async push(
343
+ location:
344
+ | string
345
+ | { name: string; params?: Record<string, string>; query?: Record<string, string> },
346
+ ) {
347
+ if (typeof location === "string") return navigate(sanitizePath(location), false)
348
+ const path = resolveNamedPath(
349
+ location.name,
350
+ location.params ?? {},
351
+ location.query ?? {},
352
+ nameIndex,
353
+ )
354
+ return navigate(path, false)
355
+ },
356
+
357
+ async replace(path: string) {
358
+ return navigate(sanitizePath(path), true)
359
+ },
360
+
361
+ back() {
362
+ if (_isBrowser) window.history.back()
363
+ },
364
+
365
+ beforeEach(guard: NavigationGuard) {
366
+ guards.push(guard)
367
+ return () => {
368
+ const idx = guards.indexOf(guard)
369
+ if (idx >= 0) guards.splice(idx, 1)
370
+ }
371
+ },
372
+
373
+ afterEach(hook: AfterEachHook) {
374
+ afterHooks.push(hook)
375
+ return () => {
376
+ const idx = afterHooks.indexOf(hook)
377
+ if (idx >= 0) afterHooks.splice(idx, 1)
378
+ }
379
+ },
380
+
381
+ loading: () => loadingSignal() > 0,
382
+
383
+ destroy() {
384
+ if (_popstateHandler) {
385
+ window.removeEventListener("popstate", _popstateHandler)
386
+ _popstateHandler = null
387
+ }
388
+ if (_hashchangeHandler) {
389
+ window.removeEventListener("hashchange", _hashchangeHandler)
390
+ _hashchangeHandler = null
391
+ }
392
+ guards.length = 0
393
+ afterHooks.length = 0
394
+ componentCache.clear()
395
+ router._loaderData.clear()
396
+ router._abortController?.abort()
397
+ router._abortController = null
398
+ },
399
+
400
+ _resolve: (rawPath: string) => resolveRoute(rawPath, routes),
401
+ }
402
+
403
+ return router
404
+ }
405
+
406
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
407
+
408
+ async function runGuard(
409
+ guard: NavigationGuard,
410
+ to: ResolvedRoute,
411
+ from: ResolvedRoute,
412
+ ): Promise<NavigationGuardResult> {
413
+ try {
414
+ return await guard(to, from)
415
+ } catch (_err) {
416
+ return false
417
+ }
418
+ }
419
+
420
+ function resolveNamedPath(
421
+ name: string,
422
+ params: Record<string, string>,
423
+ query: Record<string, string>,
424
+ index: Map<string, RouteRecord>,
425
+ ): string {
426
+ const record = index.get(name)
427
+ if (!record) {
428
+ return "/"
429
+ }
430
+ let path = buildPath(record.path, params)
431
+ const qs = Object.entries(query)
432
+ .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
433
+ .join("&")
434
+ if (qs) path += `?${qs}`
435
+ return path
436
+ }
437
+
438
+ /** Block unsafe navigation targets: javascript/data/vbscript URIs and absolute URLs. */
439
+ function sanitizePath(path: string): string {
440
+ const trimmed = path.trim()
441
+ if (/^(?:javascript|data|vbscript):/i.test(trimmed)) {
442
+ return "/"
443
+ }
444
+ // Block absolute URLs and protocol-relative URLs — router only handles same-origin paths
445
+ if (/^\/\/|^https?:/i.test(trimmed)) {
446
+ return "/"
447
+ }
448
+ return path
449
+ }
450
+
451
+ export { isLazy }