@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.
@@ -0,0 +1,3 @@
1
+ import { GlobalRegistrator } from "@happy-dom/global-registrator"
2
+
3
+ GlobalRegistrator.register()
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
+ }