@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/LICENSE +21 -0
- package/README.md +90 -0
- package/lib/analysis/index.js.html +5406 -0
- package/lib/index.js +830 -0
- package/lib/index.js.map +1 -0
- package/lib/types/index.d.ts +690 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index2.d.ts +322 -0
- package/lib/types/index2.d.ts.map +1 -0
- package/package.json +49 -0
- package/src/components.tsx +307 -0
- package/src/index.ts +78 -0
- package/src/loader.ts +97 -0
- package/src/match.ts +264 -0
- package/src/router.ts +451 -0
- package/src/scroll.ts +55 -0
- package/src/tests/router.test.ts +3294 -0
- package/src/tests/setup.ts +3 -0
- package/src/types.ts +242 -0
package/src/types.ts
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import type { ComponentFn } from "@pyreon/core"
|
|
2
|
+
export type { ComponentFn }
|
|
3
|
+
|
|
4
|
+
// ─── Path param extraction ────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Extracts typed params from a path string at compile time.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ExtractParams<'/user/:id/posts/:postId'>
|
|
11
|
+
* // → { id: string; postId: string }
|
|
12
|
+
*/
|
|
13
|
+
export type ExtractParams<T extends string> = T extends `${string}:${infer Param}*/${infer Rest}`
|
|
14
|
+
? { [K in Param]: string } & ExtractParams<`/${Rest}`>
|
|
15
|
+
: T extends `${string}:${infer Param}*`
|
|
16
|
+
? { [K in Param]: string }
|
|
17
|
+
: T extends `${string}:${infer Param}/${infer Rest}`
|
|
18
|
+
? { [K in Param]: string } & ExtractParams<`/${Rest}`>
|
|
19
|
+
: T extends `${string}:${infer Param}`
|
|
20
|
+
? { [K in Param]: string }
|
|
21
|
+
: Record<never, never>
|
|
22
|
+
|
|
23
|
+
// ─── Route meta ───────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Route metadata interface. Extend it via module augmentation to add custom fields:
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* // globals.d.ts
|
|
30
|
+
* declare module "@pyreon/router" {
|
|
31
|
+
* interface RouteMeta {
|
|
32
|
+
* requiresRole?: "admin" | "user"
|
|
33
|
+
* pageTitle?: string
|
|
34
|
+
* }
|
|
35
|
+
* }
|
|
36
|
+
*/
|
|
37
|
+
export interface RouteMeta {
|
|
38
|
+
/** Sets document.title on navigation */
|
|
39
|
+
title?: string
|
|
40
|
+
/** Page description (for meta tags) */
|
|
41
|
+
description?: string
|
|
42
|
+
/** If true, guards can redirect to login */
|
|
43
|
+
requiresAuth?: boolean
|
|
44
|
+
/** Scroll behavior for this route */
|
|
45
|
+
scrollBehavior?: "top" | "restore" | "none"
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ─── Resolved route ───────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
export interface ResolvedRoute<
|
|
51
|
+
P extends Record<string, string> = Record<string, string>,
|
|
52
|
+
Q extends Record<string, string> = Record<string, string>,
|
|
53
|
+
> {
|
|
54
|
+
path: string
|
|
55
|
+
params: P
|
|
56
|
+
query: Q
|
|
57
|
+
hash: string
|
|
58
|
+
/** All matched records from root to leaf (one per nesting level) */
|
|
59
|
+
matched: RouteRecord[]
|
|
60
|
+
meta: RouteMeta
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ─── Lazy component ───────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
export const LAZY_SYMBOL = Symbol("pyreon.lazy")
|
|
66
|
+
|
|
67
|
+
export interface LazyComponent {
|
|
68
|
+
readonly [LAZY_SYMBOL]: true
|
|
69
|
+
readonly loader: () => Promise<ComponentFn | { default: ComponentFn }>
|
|
70
|
+
/** Optional component shown while the lazy chunk is loading */
|
|
71
|
+
readonly loadingComponent?: ComponentFn
|
|
72
|
+
/** Optional component shown after all retries have failed */
|
|
73
|
+
readonly errorComponent?: ComponentFn
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function lazy(
|
|
77
|
+
loader: () => Promise<ComponentFn | { default: ComponentFn }>,
|
|
78
|
+
options?: { loading?: ComponentFn; error?: ComponentFn },
|
|
79
|
+
): LazyComponent {
|
|
80
|
+
return {
|
|
81
|
+
[LAZY_SYMBOL]: true,
|
|
82
|
+
loader,
|
|
83
|
+
...(options?.loading ? { loadingComponent: options.loading } : {}),
|
|
84
|
+
...(options?.error ? { errorComponent: options.error } : {}),
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function isLazy(c: RouteComponent): c is LazyComponent {
|
|
89
|
+
return typeof c === "object" && c !== null && (c as LazyComponent)[LAZY_SYMBOL] === true
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export type RouteComponent = ComponentFn | LazyComponent
|
|
93
|
+
|
|
94
|
+
// ─── Navigation guard ─────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
export type NavigationGuardResult = boolean | string | undefined
|
|
97
|
+
export type NavigationGuard = (
|
|
98
|
+
to: ResolvedRoute,
|
|
99
|
+
from: ResolvedRoute,
|
|
100
|
+
) => NavigationGuardResult | Promise<NavigationGuardResult>
|
|
101
|
+
|
|
102
|
+
export type AfterEachHook = (to: ResolvedRoute, from: ResolvedRoute) => void
|
|
103
|
+
|
|
104
|
+
// ─── Route loaders ────────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
export interface LoaderContext {
|
|
107
|
+
params: Record<string, string>
|
|
108
|
+
query: Record<string, string>
|
|
109
|
+
/** Aborted when a newer navigation supersedes this one */
|
|
110
|
+
signal: AbortSignal
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export type RouteLoaderFn = (ctx: LoaderContext) => Promise<unknown>
|
|
114
|
+
|
|
115
|
+
// ─── Route record ─────────────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
export interface RouteRecord<TPath extends string = string> {
|
|
118
|
+
/** Path pattern — supports `:param` segments and `(.*)` wildcard */
|
|
119
|
+
path: TPath
|
|
120
|
+
component: RouteComponent
|
|
121
|
+
/** Optional route name for named navigation */
|
|
122
|
+
name?: string
|
|
123
|
+
/** Metadata attached to this route */
|
|
124
|
+
meta?: RouteMeta
|
|
125
|
+
/**
|
|
126
|
+
* Redirect target. Evaluated before guards.
|
|
127
|
+
* String: redirect to that path.
|
|
128
|
+
* Function: called with the resolved route, return path string.
|
|
129
|
+
*/
|
|
130
|
+
redirect?: string | ((to: ResolvedRoute) => string)
|
|
131
|
+
/** Guard(s) run only for this route, before global beforeEach guards */
|
|
132
|
+
beforeEnter?: NavigationGuard | NavigationGuard[]
|
|
133
|
+
/** Guard(s) run before leaving this route. Return false to cancel. */
|
|
134
|
+
beforeLeave?: NavigationGuard | NavigationGuard[]
|
|
135
|
+
/** Child routes rendered inside this route's component via <RouterView /> */
|
|
136
|
+
children?: RouteRecord[]
|
|
137
|
+
/**
|
|
138
|
+
* Data loader — runs before navigation commits, in parallel with sibling loaders.
|
|
139
|
+
* The result is accessible via `useLoaderData()` inside the route component.
|
|
140
|
+
* Receives an AbortSignal that fires if a newer navigation supersedes this one.
|
|
141
|
+
*/
|
|
142
|
+
loader?: RouteLoaderFn
|
|
143
|
+
/** Component rendered when this route's loader throws an error */
|
|
144
|
+
errorComponent?: ComponentFn
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ─── Router options ───────────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
export type ScrollBehaviorFn = (
|
|
150
|
+
to: ResolvedRoute,
|
|
151
|
+
from: ResolvedRoute,
|
|
152
|
+
savedPosition: number | null,
|
|
153
|
+
) => "top" | "restore" | "none" | number
|
|
154
|
+
|
|
155
|
+
export interface RouterOptions {
|
|
156
|
+
routes: RouteRecord[]
|
|
157
|
+
/** "hash" (default) uses location.hash; "history" uses pushState */
|
|
158
|
+
mode?: "hash" | "history"
|
|
159
|
+
/**
|
|
160
|
+
* Global scroll behavior. Per-route meta.scrollBehavior takes precedence.
|
|
161
|
+
* Default: "top"
|
|
162
|
+
*/
|
|
163
|
+
scrollBehavior?: ScrollBehaviorFn | "top" | "restore" | "none"
|
|
164
|
+
/**
|
|
165
|
+
* Initial URL for SSR. On the server, window.location is unavailable;
|
|
166
|
+
* pass the request URL here so the router resolves the correct route.
|
|
167
|
+
*
|
|
168
|
+
* @example
|
|
169
|
+
* // In your SSR handler:
|
|
170
|
+
* const router = createRouter({ routes, url: req.url })
|
|
171
|
+
*/
|
|
172
|
+
url?: string
|
|
173
|
+
/**
|
|
174
|
+
* Called when a route loader throws. If not provided, errors are logged
|
|
175
|
+
* and the navigation continues with `undefined` data for the failed loader.
|
|
176
|
+
* Return `false` to cancel the navigation.
|
|
177
|
+
*/
|
|
178
|
+
onError?: (err: unknown, route: ResolvedRoute) => undefined | false
|
|
179
|
+
/**
|
|
180
|
+
* Maximum number of resolved lazy components to cache.
|
|
181
|
+
* When exceeded, the oldest entry is evicted.
|
|
182
|
+
* Default: 100.
|
|
183
|
+
*/
|
|
184
|
+
maxCacheSize?: number
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ─── Router interface ─────────────────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
export interface Router {
|
|
190
|
+
/** Navigate to a path */
|
|
191
|
+
push(path: string): Promise<void>
|
|
192
|
+
/** Navigate to a path by name */
|
|
193
|
+
push(location: {
|
|
194
|
+
name: string
|
|
195
|
+
params?: Record<string, string>
|
|
196
|
+
query?: Record<string, string>
|
|
197
|
+
}): Promise<void>
|
|
198
|
+
/** Replace current history entry */
|
|
199
|
+
replace(path: string): Promise<void>
|
|
200
|
+
/** Go back */
|
|
201
|
+
back(): void
|
|
202
|
+
/** Register a global before-navigation guard. Returns an unregister function. */
|
|
203
|
+
beforeEach(guard: NavigationGuard): () => void
|
|
204
|
+
/** Register a global after-navigation hook. Returns an unregister function. */
|
|
205
|
+
afterEach(hook: AfterEachHook): () => void
|
|
206
|
+
/** Current resolved route (reactive signal) */
|
|
207
|
+
readonly currentRoute: () => ResolvedRoute
|
|
208
|
+
/** True while a navigation (guards + loaders) is in flight */
|
|
209
|
+
readonly loading: () => boolean
|
|
210
|
+
/** Remove all event listeners, clear caches, and abort in-flight navigations. */
|
|
211
|
+
destroy(): void
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ─── Internal router instance ─────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
import type { Computed, Signal } from "@pyreon/reactivity"
|
|
217
|
+
|
|
218
|
+
export interface RouterInstance extends Router {
|
|
219
|
+
routes: RouteRecord[]
|
|
220
|
+
mode: "hash" | "history"
|
|
221
|
+
_currentPath: Signal<string>
|
|
222
|
+
_currentRoute: Computed<ResolvedRoute>
|
|
223
|
+
_componentCache: Map<RouteRecord, ComponentFn>
|
|
224
|
+
_loadingSignal: Signal<number>
|
|
225
|
+
_resolve(rawPath: string): ResolvedRoute
|
|
226
|
+
_scrollPositions: Map<string, number>
|
|
227
|
+
_scrollBehavior: RouterOptions["scrollBehavior"]
|
|
228
|
+
_onError: RouterOptions["onError"]
|
|
229
|
+
_maxCacheSize: number
|
|
230
|
+
/**
|
|
231
|
+
* Current RouterView nesting depth. Incremented by each RouterView as it
|
|
232
|
+
* mounts (in tree order = depth-first), so each view knows which level of
|
|
233
|
+
* `matched[]` to render. Reset to 0 by RouterProvider.
|
|
234
|
+
*/
|
|
235
|
+
_viewDepth: number
|
|
236
|
+
/** Route records whose lazy chunk permanently failed (all retries exhausted) */
|
|
237
|
+
_erroredChunks: Set<RouteRecord>
|
|
238
|
+
/** Loader data keyed by route record — populated before each navigation commits */
|
|
239
|
+
_loaderData: Map<RouteRecord, unknown>
|
|
240
|
+
/** AbortController for the in-flight loader batch — aborted when a newer navigation starts */
|
|
241
|
+
_abortController: AbortController | null
|
|
242
|
+
}
|