@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/lib/types/index.d.ts
CHANGED
|
@@ -35,7 +35,7 @@ interface RouteMeta {
|
|
|
35
35
|
/** If true, guards can redirect to login */
|
|
36
36
|
requiresAuth?: boolean;
|
|
37
37
|
/** Scroll behavior for this route */
|
|
38
|
-
scrollBehavior?:
|
|
38
|
+
scrollBehavior?: 'top' | 'restore' | 'none';
|
|
39
39
|
}
|
|
40
40
|
interface ResolvedRoute<P extends Record<string, string | undefined> = Record<string, string>, Q extends Record<string, string> = Record<string, string>> {
|
|
41
41
|
path: string;
|
|
@@ -126,11 +126,11 @@ interface RouteRecord<TPath extends string = string> {
|
|
|
126
126
|
/** Component rendered when this route's loader throws an error */
|
|
127
127
|
errorComponent?: ComponentFn$1;
|
|
128
128
|
}
|
|
129
|
-
type ScrollBehaviorFn = (to: ResolvedRoute, from: ResolvedRoute, savedPosition: number | null) =>
|
|
129
|
+
type ScrollBehaviorFn = (to: ResolvedRoute, from: ResolvedRoute, savedPosition: number | null) => 'top' | 'restore' | 'none' | number;
|
|
130
130
|
interface RouterOptions {
|
|
131
131
|
routes: RouteRecord[];
|
|
132
132
|
/** "hash" (default) uses location.hash; "history" uses pushState */
|
|
133
|
-
mode?:
|
|
133
|
+
mode?: 'hash' | 'history';
|
|
134
134
|
/**
|
|
135
135
|
* Base path for the application. Used when deploying to a sub-path
|
|
136
136
|
* (e.g. `"/app"` for `https://example.com/app/`).
|
|
@@ -142,7 +142,7 @@ interface RouterOptions {
|
|
|
142
142
|
* Global scroll behavior. Per-route meta.scrollBehavior takes precedence.
|
|
143
143
|
* Default: "top"
|
|
144
144
|
*/
|
|
145
|
-
scrollBehavior?: ScrollBehaviorFn |
|
|
145
|
+
scrollBehavior?: ScrollBehaviorFn | 'top' | 'restore' | 'none';
|
|
146
146
|
/**
|
|
147
147
|
* Initial URL for SSR. On the server, window.location is unavailable;
|
|
148
148
|
* pass the request URL here so the router resolves the correct route.
|
|
@@ -170,7 +170,7 @@ interface RouterOptions {
|
|
|
170
170
|
* - `"add"` — ensures paths always end with `/`
|
|
171
171
|
* - `"ignore"` — no normalization
|
|
172
172
|
*/
|
|
173
|
-
trailingSlash?:
|
|
173
|
+
trailingSlash?: 'strip' | 'add' | 'ignore';
|
|
174
174
|
}
|
|
175
175
|
interface Router {
|
|
176
176
|
/** Navigate to a path */
|
|
@@ -213,7 +213,7 @@ interface Router {
|
|
|
213
213
|
}
|
|
214
214
|
interface RouterInstance extends Router {
|
|
215
215
|
routes: RouteRecord[];
|
|
216
|
-
mode:
|
|
216
|
+
mode: 'hash' | 'history';
|
|
217
217
|
/** Normalized base path (e.g. "/app"), empty string if none */
|
|
218
218
|
_base: string;
|
|
219
219
|
_currentPath: Signal<string>;
|
|
@@ -222,8 +222,8 @@ interface RouterInstance extends Router {
|
|
|
222
222
|
_loadingSignal: Signal<number>;
|
|
223
223
|
_resolve(rawPath: string): ResolvedRoute;
|
|
224
224
|
_scrollPositions: Map<string, number>;
|
|
225
|
-
_scrollBehavior: RouterOptions[
|
|
226
|
-
_onError: RouterOptions[
|
|
225
|
+
_scrollBehavior: RouterOptions['scrollBehavior'];
|
|
226
|
+
_onError: RouterOptions['onError'];
|
|
227
227
|
_maxCacheSize: number;
|
|
228
228
|
/**
|
|
229
229
|
* Current RouterView nesting depth. Incremented by each RouterView as it
|
|
@@ -295,7 +295,7 @@ interface RouterLinkProps extends Props {
|
|
|
295
295
|
* - "viewport" — prefetch when the link scrolls into the viewport
|
|
296
296
|
* - "none" — no prefetching
|
|
297
297
|
*/
|
|
298
|
-
prefetch?:
|
|
298
|
+
prefetch?: 'hover' | 'viewport' | 'none';
|
|
299
299
|
children?: VNodeChild | null;
|
|
300
300
|
}
|
|
301
301
|
declare const RouterLink: ComponentFn<RouterLinkProps>;
|
package/package.json
CHANGED
|
@@ -1,25 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/router",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.6",
|
|
4
4
|
"description": "Official router for Pyreon",
|
|
5
|
+
"homepage": "https://github.com/pyreon/pyreon/tree/main/packages/router#readme",
|
|
6
|
+
"bugs": {
|
|
7
|
+
"url": "https://github.com/pyreon/pyreon/issues"
|
|
8
|
+
},
|
|
5
9
|
"license": "MIT",
|
|
6
10
|
"repository": {
|
|
7
11
|
"type": "git",
|
|
8
12
|
"url": "https://github.com/pyreon/pyreon.git",
|
|
9
13
|
"directory": "packages/core/router"
|
|
10
14
|
},
|
|
11
|
-
"homepage": "https://github.com/pyreon/pyreon/tree/main/packages/router#readme",
|
|
12
|
-
"bugs": {
|
|
13
|
-
"url": "https://github.com/pyreon/pyreon/issues"
|
|
14
|
-
},
|
|
15
15
|
"files": [
|
|
16
16
|
"lib",
|
|
17
17
|
"src",
|
|
18
18
|
"README.md",
|
|
19
19
|
"LICENSE"
|
|
20
20
|
],
|
|
21
|
-
"sideEffects": false,
|
|
22
21
|
"type": "module",
|
|
22
|
+
"sideEffects": false,
|
|
23
23
|
"main": "./lib/index.js",
|
|
24
24
|
"module": "./lib/index.js",
|
|
25
25
|
"types": "./lib/types/index.d.ts",
|
|
@@ -30,24 +30,24 @@
|
|
|
30
30
|
"types": "./lib/types/index.d.ts"
|
|
31
31
|
}
|
|
32
32
|
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
33
36
|
"scripts": {
|
|
34
37
|
"build": "vl_rolldown_build",
|
|
35
38
|
"dev": "vl_rolldown_build-watch",
|
|
36
39
|
"test": "vitest run",
|
|
37
40
|
"typecheck": "tsc --noEmit",
|
|
38
|
-
"lint": "
|
|
41
|
+
"lint": "oxlint .",
|
|
39
42
|
"prepublishOnly": "bun run build"
|
|
40
43
|
},
|
|
41
44
|
"dependencies": {
|
|
42
|
-
"@pyreon/core": "^0.11.
|
|
43
|
-
"@pyreon/reactivity": "^0.11.
|
|
44
|
-
"@pyreon/runtime-dom": "^0.11.
|
|
45
|
+
"@pyreon/core": "^0.11.6",
|
|
46
|
+
"@pyreon/reactivity": "^0.11.6",
|
|
47
|
+
"@pyreon/runtime-dom": "^0.11.6"
|
|
45
48
|
},
|
|
46
49
|
"devDependencies": {
|
|
47
50
|
"@happy-dom/global-registrator": "^20.8.3",
|
|
48
51
|
"happy-dom": "^20.8.3"
|
|
49
|
-
},
|
|
50
|
-
"publishConfig": {
|
|
51
|
-
"access": "public"
|
|
52
52
|
}
|
|
53
53
|
}
|
package/src/components.tsx
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import type { ComponentFn, Props, VNodeChild } from
|
|
2
|
-
import { createRef, h, onUnmount, provide, useContext } from
|
|
3
|
-
import { LoaderDataContext, prefetchLoaderData } from
|
|
4
|
-
import { isLazy, RouterContext, setActiveRouter } from
|
|
5
|
-
import type { LazyComponent, ResolvedRoute, RouteRecord, Router, RouterInstance } from
|
|
1
|
+
import type { ComponentFn, Props, VNodeChild } from '@pyreon/core'
|
|
2
|
+
import { createRef, h, onUnmount, provide, useContext } from '@pyreon/core'
|
|
3
|
+
import { LoaderDataContext, prefetchLoaderData } from './loader'
|
|
4
|
+
import { isLazy, RouterContext, setActiveRouter } from './router'
|
|
5
|
+
import type { LazyComponent, ResolvedRoute, RouteRecord, Router, RouterInstance } from './types'
|
|
6
6
|
|
|
7
7
|
// Track prefetched paths per router to avoid duplicate fetches
|
|
8
8
|
const _prefetched = new WeakMap<RouterInstance, Set<string>>()
|
|
@@ -100,7 +100,7 @@ export const RouterView: ComponentFn<RouterViewProps> = (props) => {
|
|
|
100
100
|
return renderLazyRoute(router, record, raw)
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
-
return h(
|
|
103
|
+
return h('div', { 'data-pyreon-router-view': true }, child as unknown as VNodeChild)
|
|
104
104
|
}
|
|
105
105
|
|
|
106
106
|
// ─── RouterLink ───────────────────────────────────────────────────────────────
|
|
@@ -121,13 +121,13 @@ export interface RouterLinkProps extends Props {
|
|
|
121
121
|
* - "viewport" — prefetch when the link scrolls into the viewport
|
|
122
122
|
* - "none" — no prefetching
|
|
123
123
|
*/
|
|
124
|
-
prefetch?:
|
|
124
|
+
prefetch?: 'hover' | 'viewport' | 'none'
|
|
125
125
|
children?: VNodeChild | null
|
|
126
126
|
}
|
|
127
127
|
|
|
128
128
|
export const RouterLink: ComponentFn<RouterLinkProps> = (props) => {
|
|
129
129
|
const router = useContext(RouterContext)
|
|
130
|
-
const prefetchMode = props.prefetch ??
|
|
130
|
+
const prefetchMode = props.prefetch ?? 'hover'
|
|
131
131
|
|
|
132
132
|
const handleClick = (e: MouseEvent) => {
|
|
133
133
|
e.preventDefault()
|
|
@@ -140,29 +140,29 @@ export const RouterLink: ComponentFn<RouterLinkProps> = (props) => {
|
|
|
140
140
|
}
|
|
141
141
|
|
|
142
142
|
const handleMouseEnter = () => {
|
|
143
|
-
if (prefetchMode !==
|
|
143
|
+
if (prefetchMode !== 'hover' || !router) return
|
|
144
144
|
prefetchRoute(router as RouterInstance, props.to)
|
|
145
145
|
}
|
|
146
146
|
|
|
147
147
|
const inst = router as RouterInstance | null
|
|
148
|
-
const href = inst?.mode ===
|
|
148
|
+
const href = inst?.mode === 'history' ? `${inst._base}${props.to}` : `#${props.to}`
|
|
149
149
|
|
|
150
150
|
const activeClass = (): string => {
|
|
151
|
-
if (!router) return
|
|
151
|
+
if (!router) return ''
|
|
152
152
|
const current = router.currentRoute().path
|
|
153
153
|
const target = props.to
|
|
154
154
|
const isExact = current === target
|
|
155
155
|
const isActive = isExact || (!props.exact && isSegmentPrefix(current, target))
|
|
156
156
|
|
|
157
157
|
const classes: string[] = []
|
|
158
|
-
if (isActive) classes.push(props.activeClass ??
|
|
159
|
-
if (isExact) classes.push(props.exactActiveClass ??
|
|
160
|
-
return classes.join(
|
|
158
|
+
if (isActive) classes.push(props.activeClass ?? 'router-link-active')
|
|
159
|
+
if (isExact) classes.push(props.exactActiveClass ?? 'router-link-exact-active')
|
|
160
|
+
return classes.join(' ').trim()
|
|
161
161
|
}
|
|
162
162
|
|
|
163
163
|
// Viewport prefetching — observe link visibility with IntersectionObserver
|
|
164
164
|
const ref = createRef<Element>()
|
|
165
|
-
if (prefetchMode ===
|
|
165
|
+
if (prefetchMode === 'viewport' && router && typeof IntersectionObserver !== 'undefined') {
|
|
166
166
|
const observer = new IntersectionObserver((entries) => {
|
|
167
167
|
for (const entry of entries) {
|
|
168
168
|
if (entry.isIntersecting) {
|
|
@@ -180,7 +180,7 @@ export const RouterLink: ComponentFn<RouterLinkProps> = (props) => {
|
|
|
180
180
|
}
|
|
181
181
|
|
|
182
182
|
return h(
|
|
183
|
-
|
|
183
|
+
'a',
|
|
184
184
|
{ ref, href, class: activeClass, onClick: handleClick, onMouseEnter: handleMouseEnter },
|
|
185
185
|
props.children ?? props.to,
|
|
186
186
|
)
|
|
@@ -214,7 +214,7 @@ function renderLazyRoute(
|
|
|
214
214
|
raw
|
|
215
215
|
.loader()
|
|
216
216
|
.then((mod) => {
|
|
217
|
-
const resolved = typeof mod ===
|
|
217
|
+
const resolved = typeof mod === 'function' ? mod : mod.default
|
|
218
218
|
cacheSet(router, record, resolved)
|
|
219
219
|
router._loadingSignal.update((n) => n + 1)
|
|
220
220
|
})
|
|
@@ -224,7 +224,7 @@ function renderLazyRoute(
|
|
|
224
224
|
tryLoad(attempt + 1),
|
|
225
225
|
)
|
|
226
226
|
}
|
|
227
|
-
if (typeof window !==
|
|
227
|
+
if (typeof window !== 'undefined' && isStaleChunk(err)) {
|
|
228
228
|
window.location.reload()
|
|
229
229
|
return
|
|
230
230
|
}
|
|
@@ -247,7 +247,7 @@ function renderWithLoader(
|
|
|
247
247
|
router: RouterInstance,
|
|
248
248
|
record: RouteRecord,
|
|
249
249
|
Comp: ComponentFn,
|
|
250
|
-
route: Pick<ResolvedRoute,
|
|
250
|
+
route: Pick<ResolvedRoute, 'params' | 'query' | 'meta'>,
|
|
251
251
|
): VNodeChild {
|
|
252
252
|
const routeProps = { params: route.params, query: route.query, meta: route.meta }
|
|
253
253
|
if (!record.loader) {
|
|
@@ -285,9 +285,9 @@ function cacheSet(router: RouterInstance, record: RouteRecord, comp: ComponentFn
|
|
|
285
285
|
* `/admin` is a prefix of `/admin/users` but NOT of `/admin-panel`.
|
|
286
286
|
*/
|
|
287
287
|
function isSegmentPrefix(current: string, target: string): boolean {
|
|
288
|
-
if (target ===
|
|
289
|
-
const cs = current.split(
|
|
290
|
-
const ts = target.split(
|
|
288
|
+
if (target === '/') return false
|
|
289
|
+
const cs = current.split('/').filter(Boolean)
|
|
290
|
+
const ts = target.split('/').filter(Boolean)
|
|
291
291
|
if (ts.length > cs.length) return false
|
|
292
292
|
return ts.every((seg, i) => seg === cs[i])
|
|
293
293
|
}
|
|
@@ -298,7 +298,7 @@ function isSegmentPrefix(current: string, target: string): boolean {
|
|
|
298
298
|
* so the user gets the new bundle instead of a broken loading state.
|
|
299
299
|
*/
|
|
300
300
|
function isStaleChunk(err: unknown): boolean {
|
|
301
|
-
if (err instanceof TypeError && String(err.message).includes(
|
|
301
|
+
if (err instanceof TypeError && String(err.message).includes('Failed to fetch')) return true
|
|
302
302
|
if (err instanceof SyntaxError) return true
|
|
303
303
|
return false
|
|
304
304
|
}
|
package/src/index.ts
CHANGED
|
@@ -41,10 +41,10 @@
|
|
|
41
41
|
* router.push({ name: "user", params: { id: "42" } })
|
|
42
42
|
*/
|
|
43
43
|
|
|
44
|
-
export type { RouterLinkProps, RouterProviderProps, RouterViewProps } from
|
|
44
|
+
export type { RouterLinkProps, RouterProviderProps, RouterViewProps } from './components'
|
|
45
45
|
// Components
|
|
46
|
-
export { RouterLink, RouterProvider, RouterView } from
|
|
47
|
-
export { hydrateLoaderData, prefetchLoaderData, serializeLoaderData, useLoaderData } from
|
|
46
|
+
export { RouterLink, RouterProvider, RouterView } from './components'
|
|
47
|
+
export { hydrateLoaderData, prefetchLoaderData, serializeLoaderData, useLoaderData } from './loader'
|
|
48
48
|
// Match utilities (useful for SSR route pre-fetching)
|
|
49
49
|
export {
|
|
50
50
|
buildPath,
|
|
@@ -53,7 +53,7 @@ export {
|
|
|
53
53
|
parseQueryMulti,
|
|
54
54
|
resolveRoute,
|
|
55
55
|
stringifyQuery,
|
|
56
|
-
} from
|
|
56
|
+
} from './match'
|
|
57
57
|
// Router factory + hooks
|
|
58
58
|
export {
|
|
59
59
|
createRouter,
|
|
@@ -65,7 +65,7 @@ export {
|
|
|
65
65
|
useRoute,
|
|
66
66
|
useRouter,
|
|
67
67
|
useSearchParams,
|
|
68
|
-
} from
|
|
68
|
+
} from './router'
|
|
69
69
|
// Types
|
|
70
70
|
// Data loaders
|
|
71
71
|
export type {
|
|
@@ -85,6 +85,6 @@ export type {
|
|
|
85
85
|
Router,
|
|
86
86
|
RouterOptions,
|
|
87
87
|
ScrollBehaviorFn,
|
|
88
|
-
} from
|
|
88
|
+
} from './types'
|
|
89
89
|
// Lazy helper
|
|
90
|
-
export { lazy } from
|
|
90
|
+
export { lazy } from './types'
|
package/src/loader.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type { Context } from
|
|
2
|
-
import { createContext, useContext } from
|
|
3
|
-
import type { RouterInstance } from
|
|
1
|
+
import type { Context } from '@pyreon/core'
|
|
2
|
+
import { createContext, useContext } from '@pyreon/core'
|
|
3
|
+
import type { RouterInstance } from './types'
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Context frame that holds the loader data for the currently rendered route record.
|
|
@@ -87,7 +87,7 @@ export function hydrateLoaderData(
|
|
|
87
87
|
router: RouterInstance,
|
|
88
88
|
serialized: Record<string, unknown>,
|
|
89
89
|
): void {
|
|
90
|
-
if (!serialized || typeof serialized !==
|
|
90
|
+
if (!serialized || typeof serialized !== 'object') return
|
|
91
91
|
const route = router._resolve(router.currentRoute().path)
|
|
92
92
|
for (const record of route.matched) {
|
|
93
93
|
if (Object.hasOwn(serialized, record.path)) {
|
package/src/match.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ResolvedRoute, RouteMeta, RouteRecord } from
|
|
1
|
+
import type { ResolvedRoute, RouteMeta, RouteRecord } from './types'
|
|
2
2
|
|
|
3
3
|
// ─── Query string ─────────────────────────────────────────────────────────────
|
|
4
4
|
|
|
@@ -9,11 +9,11 @@ import type { ResolvedRoute, RouteMeta, RouteRecord } from "./types"
|
|
|
9
9
|
export function parseQuery(qs: string): Record<string, string> {
|
|
10
10
|
if (!qs) return {}
|
|
11
11
|
const result: Record<string, string> = {}
|
|
12
|
-
for (const part of qs.split(
|
|
13
|
-
const eqIdx = part.indexOf(
|
|
12
|
+
for (const part of qs.split('&')) {
|
|
13
|
+
const eqIdx = part.indexOf('=')
|
|
14
14
|
if (eqIdx < 0) {
|
|
15
15
|
const key = decodeURIComponent(part)
|
|
16
|
-
if (key) result[key] =
|
|
16
|
+
if (key) result[key] = ''
|
|
17
17
|
} else {
|
|
18
18
|
const key = decodeURIComponent(part.slice(0, eqIdx))
|
|
19
19
|
const val = decodeURIComponent(part.slice(eqIdx + 1))
|
|
@@ -33,13 +33,13 @@ export function parseQuery(qs: string): Record<string, string> {
|
|
|
33
33
|
export function parseQueryMulti(qs: string): Record<string, string | string[]> {
|
|
34
34
|
if (!qs) return {}
|
|
35
35
|
const result: Record<string, string | string[]> = {}
|
|
36
|
-
for (const part of qs.split(
|
|
37
|
-
const eqIdx = part.indexOf(
|
|
36
|
+
for (const part of qs.split('&')) {
|
|
37
|
+
const eqIdx = part.indexOf('=')
|
|
38
38
|
let key: string
|
|
39
39
|
let val: string
|
|
40
40
|
if (eqIdx < 0) {
|
|
41
41
|
key = decodeURIComponent(part)
|
|
42
|
-
val =
|
|
42
|
+
val = ''
|
|
43
43
|
} else {
|
|
44
44
|
key = decodeURIComponent(part.slice(0, eqIdx))
|
|
45
45
|
val = decodeURIComponent(part.slice(eqIdx + 1))
|
|
@@ -62,7 +62,7 @@ export function stringifyQuery(query: Record<string, string>): string {
|
|
|
62
62
|
for (const [k, v] of Object.entries(query)) {
|
|
63
63
|
parts.push(v ? `${encodeURIComponent(k)}=${encodeURIComponent(v)}` : encodeURIComponent(k))
|
|
64
64
|
}
|
|
65
|
-
return parts.length ? `?${parts.join(
|
|
65
|
+
return parts.length ? `?${parts.join('&')}` : ''
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
// ─── Compiled route structures ───────────────────────────────────────────────
|
|
@@ -134,21 +134,21 @@ interface FlattenedRoute {
|
|
|
134
134
|
const _compiledCache = new WeakMap<RouteRecord[], CompiledRoute[]>()
|
|
135
135
|
|
|
136
136
|
function compileSegment(raw: string): CompiledSegment {
|
|
137
|
-
if (raw.endsWith(
|
|
137
|
+
if (raw.endsWith('*') && raw.startsWith(':')) {
|
|
138
138
|
return { raw, isParam: true, isSplat: true, isOptional: false, paramName: raw.slice(1, -1) }
|
|
139
139
|
}
|
|
140
|
-
if (raw.endsWith(
|
|
140
|
+
if (raw.endsWith('?') && raw.startsWith(':')) {
|
|
141
141
|
return { raw, isParam: true, isSplat: false, isOptional: true, paramName: raw.slice(1, -1) }
|
|
142
142
|
}
|
|
143
|
-
if (raw.startsWith(
|
|
143
|
+
if (raw.startsWith(':')) {
|
|
144
144
|
return { raw, isParam: true, isSplat: false, isOptional: false, paramName: raw.slice(1) }
|
|
145
145
|
}
|
|
146
|
-
return { raw, isParam: false, isSplat: false, isOptional: false, paramName:
|
|
146
|
+
return { raw, isParam: false, isSplat: false, isOptional: false, paramName: '' }
|
|
147
147
|
}
|
|
148
148
|
|
|
149
149
|
function compileRoute(route: RouteRecord): CompiledRoute {
|
|
150
150
|
const pattern = route.path
|
|
151
|
-
const isWildcard = pattern ===
|
|
151
|
+
const isWildcard = pattern === '(.*)' || pattern === '*'
|
|
152
152
|
|
|
153
153
|
if (isWildcard) {
|
|
154
154
|
return {
|
|
@@ -163,9 +163,9 @@ function compileRoute(route: RouteRecord): CompiledRoute {
|
|
|
163
163
|
}
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
-
const segments = pattern.split(
|
|
166
|
+
const segments = pattern.split('/').filter(Boolean).map(compileSegment)
|
|
167
167
|
const isStatic = segments.every((s) => !s.isParam)
|
|
168
|
-
const staticPath = isStatic ? `/${segments.map((s) => s.raw).join(
|
|
168
|
+
const staticPath = isStatic ? `/${segments.map((s) => s.raw).join('/')}` : null
|
|
169
169
|
const first = segments.length > 0 ? segments[0] : undefined
|
|
170
170
|
const firstSegment = first && !first.isParam ? first.raw : null
|
|
171
171
|
|
|
@@ -239,7 +239,7 @@ function makeFlatEntry(
|
|
|
239
239
|
segmentCount: segments.length,
|
|
240
240
|
matchedChain: chain,
|
|
241
241
|
isStatic,
|
|
242
|
-
staticPath: isStatic ? `/${segments.map((s) => s.raw).join(
|
|
242
|
+
staticPath: isStatic ? `/${segments.map((s) => s.raw).join('/')}` : null,
|
|
243
243
|
meta,
|
|
244
244
|
firstSegment: getFirstSegment(segments),
|
|
245
245
|
hasSplat: segments.some((s) => s.isSplat),
|
|
@@ -369,7 +369,7 @@ function buildRouteIndex(routes: RouteRecord[], compiled: CompiledRoute[]): Rout
|
|
|
369
369
|
/** Split path into segments without allocating a filtered array */
|
|
370
370
|
function splitPath(path: string): string[] {
|
|
371
371
|
// Fast path for common cases
|
|
372
|
-
if (path ===
|
|
372
|
+
if (path === '/') return []
|
|
373
373
|
// Remove leading slash, split, no filter needed if path is clean
|
|
374
374
|
const start = path.charCodeAt(0) === 47 /* / */ ? 1 : 0
|
|
375
375
|
const end = path.length
|
|
@@ -390,7 +390,7 @@ function splitPath(path: string): string[] {
|
|
|
390
390
|
|
|
391
391
|
/** Decode only if the segment contains a `%` character */
|
|
392
392
|
function decodeSafe(s: string): string {
|
|
393
|
-
return s.indexOf(
|
|
393
|
+
return s.indexOf('%') >= 0 ? decodeURIComponent(s) : s
|
|
394
394
|
}
|
|
395
395
|
|
|
396
396
|
// ─── Path matching (compiled) ────────────────────────────────────────────────
|
|
@@ -411,28 +411,28 @@ function matchPatternSegment(
|
|
|
411
411
|
params: Record<string, string>,
|
|
412
412
|
pathParts: string[],
|
|
413
413
|
i: number,
|
|
414
|
-
):
|
|
415
|
-
if (pp.endsWith(
|
|
416
|
-
params[pp.slice(1, -1)] = pathParts.slice(i).map(decodeURIComponent).join(
|
|
417
|
-
return
|
|
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
418
|
}
|
|
419
|
-
if (pp.endsWith(
|
|
419
|
+
if (pp.endsWith('?') && pp.startsWith(':')) {
|
|
420
420
|
if (pt !== undefined) params[pp.slice(1, -1)] = decodeURIComponent(pt)
|
|
421
|
-
return
|
|
421
|
+
return 'continue'
|
|
422
422
|
}
|
|
423
|
-
if (pt === undefined) return
|
|
424
|
-
if (pp.startsWith(
|
|
423
|
+
if (pt === undefined) return 'fail'
|
|
424
|
+
if (pp.startsWith(':')) {
|
|
425
425
|
params[pp.slice(1)] = decodeURIComponent(pt)
|
|
426
|
-
return
|
|
426
|
+
return 'continue'
|
|
427
427
|
}
|
|
428
|
-
return pp === pt ?
|
|
428
|
+
return pp === pt ? 'continue' : 'fail'
|
|
429
429
|
}
|
|
430
430
|
|
|
431
431
|
export function matchPath(pattern: string, path: string): Record<string, string> | null {
|
|
432
|
-
if (pattern ===
|
|
432
|
+
if (pattern === '(.*)' || pattern === '*') return {}
|
|
433
433
|
|
|
434
|
-
const patternParts = pattern.split(
|
|
435
|
-
const pathParts = path.split(
|
|
434
|
+
const patternParts = pattern.split('/').filter(Boolean)
|
|
435
|
+
const pathParts = path.split('/').filter(Boolean)
|
|
436
436
|
|
|
437
437
|
const params: Record<string, string> = {}
|
|
438
438
|
for (let i = 0; i < patternParts.length; i++) {
|
|
@@ -443,8 +443,8 @@ export function matchPath(pattern: string, path: string): Record<string, string>
|
|
|
443
443
|
pathParts,
|
|
444
444
|
i,
|
|
445
445
|
)
|
|
446
|
-
if (result ===
|
|
447
|
-
if (result ===
|
|
446
|
+
if (result === 'splat') return params
|
|
447
|
+
if (result === 'fail') return null
|
|
448
448
|
}
|
|
449
449
|
|
|
450
450
|
if (pathParts.length > patternParts.length) return null
|
|
@@ -460,7 +460,7 @@ function captureSplat(pathParts: string[], from: number, pathLen: number): strin
|
|
|
460
460
|
const p = pathParts[j]
|
|
461
461
|
if (p !== undefined) remaining.push(decodeSafe(p))
|
|
462
462
|
}
|
|
463
|
-
return remaining.join(
|
|
463
|
+
return remaining.join('/')
|
|
464
464
|
}
|
|
465
465
|
|
|
466
466
|
// ─── Flattened route matching ─────────────────────────────────────────────────
|
|
@@ -534,13 +534,13 @@ interface MatchResult {
|
|
|
534
534
|
* Uses flattened index for O(1) static lookup and first-segment dispatch.
|
|
535
535
|
*/
|
|
536
536
|
export function resolveRoute(rawPath: string, routes: RouteRecord[]): ResolvedRoute {
|
|
537
|
-
const qIdx = rawPath.indexOf(
|
|
537
|
+
const qIdx = rawPath.indexOf('?')
|
|
538
538
|
const pathAndHash = qIdx >= 0 ? rawPath.slice(0, qIdx) : rawPath
|
|
539
|
-
const queryPart = qIdx >= 0 ? rawPath.slice(qIdx + 1) :
|
|
539
|
+
const queryPart = qIdx >= 0 ? rawPath.slice(qIdx + 1) : ''
|
|
540
540
|
|
|
541
|
-
const hIdx = pathAndHash.indexOf(
|
|
541
|
+
const hIdx = pathAndHash.indexOf('#')
|
|
542
542
|
const cleanPath = hIdx >= 0 ? pathAndHash.slice(0, hIdx) : pathAndHash
|
|
543
|
-
const hash = hIdx >= 0 ? pathAndHash.slice(hIdx + 1) :
|
|
543
|
+
const hash = hIdx >= 0 ? pathAndHash.slice(hIdx + 1) : ''
|
|
544
544
|
|
|
545
545
|
const query = parseQuery(queryPart)
|
|
546
546
|
|
|
@@ -627,13 +627,13 @@ export function buildPath(pattern: string, params: Record<string, string>): stri
|
|
|
627
627
|
const built = pattern.replace(/\/:([^/]+)\?/g, (_match, key) => {
|
|
628
628
|
const val = params[key]
|
|
629
629
|
// Optional param — omit the entire segment if no value provided
|
|
630
|
-
if (!val) return
|
|
630
|
+
if (!val) return ''
|
|
631
631
|
return `/${encodeURIComponent(val)}`
|
|
632
632
|
})
|
|
633
633
|
return built.replace(/:([^/]+)\*?/g, (match, key) => {
|
|
634
|
-
const val = params[key] ??
|
|
634
|
+
const val = params[key] ?? ''
|
|
635
635
|
// Splat params contain slashes — don't encode them
|
|
636
|
-
if (match.endsWith(
|
|
636
|
+
if (match.endsWith('*')) return val.split('/').map(encodeURIComponent).join('/')
|
|
637
637
|
return encodeURIComponent(val)
|
|
638
638
|
})
|
|
639
639
|
}
|