@pyreon/router 0.5.6 → 0.5.7

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.
@@ -1,1090 +1,435 @@
1
- import { createContext, createRef, h, onUnmount, popContext, pushContext, useContext } from "@pyreon/core";
2
- import { computed, signal } from "@pyreon/reactivity";
1
+ import * as _pyreon_core0 from "@pyreon/core";
2
+ import { ComponentFn, ComponentFn as ComponentFn$1, Props, VNode, VNodeChild } from "@pyreon/core";
3
+ import { Computed, Signal } from "@pyreon/reactivity";
3
4
 
4
- //#region src/loader.ts
5
+ //#region src/types.d.ts
5
6
  /**
6
- * Context frame that holds the loader data for the currently rendered route record.
7
- * Pushed by RouterView's withLoaderData wrapper before invoking the route component.
8
- */
9
-
10
- /**
11
- * Returns the data resolved by the current route's `loader` function.
12
- * Must be called inside a route component rendered by <RouterView />.
13
- *
14
- * @example
15
- * const routes = [{ path: "/users", component: Users, loader: fetchUsers }]
16
- *
17
- * function Users() {
18
- * const users = useLoaderData<User[]>()
19
- * return h("ul", null, users.map(u => h("li", null, u.name)))
20
- * }
21
- */
22
- function useLoaderData() {
23
- return useContext(LoaderDataContext);
24
- }
25
- /**
26
- * SSR helper: pre-run all loaders for the given path before rendering.
27
- * Call this before `renderToString` so route components can read data via `useLoaderData()`.
28
- *
29
- * @example
30
- * const router = createRouter({ routes, url: req.url })
31
- * await prefetchLoaderData(router, req.url)
32
- * const html = await renderToString(h(App, { router }))
33
- */
34
- async function prefetchLoaderData(router, path) {
35
- const route = router._resolve(path);
36
- const ac = new AbortController();
37
- router._abortController = ac;
38
- await Promise.all(route.matched.filter(r => r.loader).map(async r => {
39
- const data = await r.loader?.({
40
- params: route.params,
41
- query: route.query,
42
- signal: ac.signal
43
- });
44
- router._loaderData.set(r, data);
45
- }));
46
- }
7
+ * Extracts typed params from a path string at compile time.
8
+ * Supports optional params via `:param?` their type is `string | undefined`.
9
+ *
10
+ * @example
11
+ * ExtractParams<'/user/:id/posts/:postId'>
12
+ * // { id: string; postId: string }
13
+ *
14
+ * ExtractParams<'/user/:id?'>
15
+ * // → { id?: string | undefined }
16
+ */
17
+ type ExtractParams<T extends string> = T extends `${string}:${infer Param}*/${infer Rest}` ? { [K in Param]: string } & ExtractParams<`/${Rest}`> : T extends `${string}:${infer Param}*` ? { [K in Param]: string } : T extends `${string}:${infer Param}?/${infer Rest}` ? { [K in Param]?: string | undefined } & ExtractParams<`/${Rest}`> : T extends `${string}:${infer Param}?` ? { [K in Param]?: string | undefined } : T extends `${string}:${infer Param}/${infer Rest}` ? { [K in Param]: string } & ExtractParams<`/${Rest}`> : T extends `${string}:${infer Param}` ? { [K in Param]: string } : Record<never, never>;
47
18
  /**
48
- * Serialize loader data to a JSON-safe plain object for embedding in SSR HTML.
49
- * Keys are route path patterns (stable across server and client).
50
- *
51
- * @example — SSR handler:
52
- * await prefetchLoaderData(router, req.url)
53
- * const { html, head } = await renderWithHead(h(App, null))
54
- * const page = `...${head}
55
- * <script>window.__PYREON_LOADER_DATA__=${JSON.stringify(serializeLoaderData(router))}<\/script>
56
- * ...${html}...`
57
- */
58
- function serializeLoaderData(router) {
59
- const result = {};
60
- for (const [record, data] of router._loaderData) result[record.path] = data;
61
- return result;
62
- }
19
+ * Route metadata interface. Extend it via module augmentation to add custom fields:
20
+ *
21
+ * @example
22
+ * // globals.d.ts
23
+ * declare module "@pyreon/router" {
24
+ * interface RouteMeta {
25
+ * requiresRole?: "admin" | "user"
26
+ * pageTitle?: string
27
+ * }
28
+ * }
29
+ */
30
+ interface RouteMeta {
31
+ /** Sets document.title on navigation */
32
+ title?: string;
33
+ /** Page description (for meta tags) */
34
+ description?: string;
35
+ /** If true, guards can redirect to login */
36
+ requiresAuth?: boolean;
37
+ /** Scroll behavior for this route */
38
+ scrollBehavior?: "top" | "restore" | "none";
39
+ }
40
+ interface ResolvedRoute<P extends Record<string, string | undefined> = Record<string, string>, Q extends Record<string, string> = Record<string, string>> {
41
+ path: string;
42
+ params: P;
43
+ query: Q;
44
+ hash: string;
45
+ /** All matched records from root to leaf (one per nesting level) */
46
+ matched: RouteRecord[];
47
+ meta: RouteMeta;
48
+ }
49
+ declare const LAZY_SYMBOL: unique symbol;
50
+ interface LazyComponent {
51
+ readonly [LAZY_SYMBOL]: true;
52
+ readonly loader: () => Promise<ComponentFn$1 | {
53
+ default: ComponentFn$1;
54
+ }>;
55
+ /** Optional component shown while the lazy chunk is loading */
56
+ readonly loadingComponent?: ComponentFn$1;
57
+ /** Optional component shown after all retries have failed */
58
+ readonly errorComponent?: ComponentFn$1;
59
+ }
60
+ declare function lazy(loader: () => Promise<ComponentFn$1 | {
61
+ default: ComponentFn$1;
62
+ }>, options?: {
63
+ loading?: ComponentFn$1;
64
+ error?: ComponentFn$1;
65
+ }): LazyComponent;
66
+ type RouteComponent = ComponentFn$1 | LazyComponent;
67
+ type NavigationGuardResult = boolean | string | undefined;
68
+ type NavigationGuard = (to: ResolvedRoute, from: ResolvedRoute) => NavigationGuardResult | Promise<NavigationGuardResult>;
69
+ type AfterEachHook = (to: ResolvedRoute, from: ResolvedRoute) => void;
63
70
  /**
64
- * Hydrate loader data from a serialized object (e.g. `window.__PYREON_LOADER_DATA__`).
65
- * Populates the router's internal `_loaderData` map so the initial render uses
66
- * server-fetched data without re-running loaders on the client.
67
- *
68
- * Call this before `mount()`, after `createRouter()`.
69
- *
70
- * @example — client entry:
71
- * import { hydrateLoaderData } from "@pyreon/router"
72
- * const router = createRouter({ routes })
73
- * hydrateLoaderData(router, window.__PYREON_LOADER_DATA__ ?? {})
74
- * mount(h(App, null), document.getElementById("app")!)
75
- */
76
- function hydrateLoaderData(router, serialized) {
77
- if (!serialized || typeof serialized !== "object") return;
78
- const route = router._resolve(router.currentRoute().path);
79
- for (const record of route.matched) if (Object.hasOwn(serialized, record.path)) router._loaderData.set(record, serialized[record.path]);
71
+ * Called before each navigation. Return `true` to block, `false` to allow.
72
+ * Async blockers are supported (e.g. to show a confirmation dialog).
73
+ */
74
+ type BlockerFn = (to: ResolvedRoute, from: ResolvedRoute) => boolean | Promise<boolean>;
75
+ interface Blocker {
76
+ /** Unregister this blocker so future navigations proceed freely. */
77
+ remove(): void;
78
+ }
79
+ interface LoaderContext {
80
+ params: Record<string, string>;
81
+ query: Record<string, string>;
82
+ /** Aborted when a newer navigation supersedes this one */
83
+ signal: AbortSignal;
84
+ }
85
+ type RouteLoaderFn = (ctx: LoaderContext) => Promise<unknown>;
86
+ interface RouteRecord<TPath extends string = string> {
87
+ /** Path pattern — supports `:param` segments and `(.*)` wildcard */
88
+ path: TPath;
89
+ component: RouteComponent;
90
+ /** Optional route name for named navigation */
91
+ name?: string;
92
+ /** Metadata attached to this route */
93
+ meta?: RouteMeta;
94
+ /**
95
+ * Redirect target. Evaluated before guards.
96
+ * String: redirect to that path.
97
+ * Function: called with the resolved route, return path string.
98
+ */
99
+ redirect?: string | ((to: ResolvedRoute) => string);
100
+ /** Guard(s) run only for this route, before global beforeEach guards */
101
+ beforeEnter?: NavigationGuard | NavigationGuard[];
102
+ /** Guard(s) run before leaving this route. Return false to cancel. */
103
+ beforeLeave?: NavigationGuard | NavigationGuard[];
104
+ /**
105
+ * Alternative path(s) for this route. Alias paths render the same component
106
+ * and share guards, loaders, and metadata with the primary path.
107
+ *
108
+ * @example
109
+ * { path: "/user/:id", alias: ["/profile/:id"], component: UserPage }
110
+ */
111
+ alias?: string | string[];
112
+ /** Child routes rendered inside this route's component via <RouterView /> */
113
+ children?: RouteRecord[];
114
+ /**
115
+ * Data loader — runs before navigation commits, in parallel with sibling loaders.
116
+ * The result is accessible via `useLoaderData()` inside the route component.
117
+ * Receives an AbortSignal that fires if a newer navigation supersedes this one.
118
+ */
119
+ loader?: RouteLoaderFn;
120
+ /**
121
+ * When true, the router shows cached loader data immediately (stale) and
122
+ * revalidates in the background. The component re-renders once fresh data arrives.
123
+ * Only applies when navigating to a route that already has cached loader data.
124
+ */
125
+ staleWhileRevalidate?: boolean;
126
+ /** Component rendered when this route's loader throws an error */
127
+ errorComponent?: ComponentFn$1;
128
+ }
129
+ type ScrollBehaviorFn = (to: ResolvedRoute, from: ResolvedRoute, savedPosition: number | null) => "top" | "restore" | "none" | number;
130
+ interface RouterOptions {
131
+ routes: RouteRecord[];
132
+ /** "hash" (default) uses location.hash; "history" uses pushState */
133
+ mode?: "hash" | "history";
134
+ /**
135
+ * Base path for the application. Used when deploying to a sub-path
136
+ * (e.g. `"/app"` for `https://example.com/app/`).
137
+ * Only applies in history mode. Must start with `/`.
138
+ * Default: `""` (no base path).
139
+ */
140
+ base?: string;
141
+ /**
142
+ * Global scroll behavior. Per-route meta.scrollBehavior takes precedence.
143
+ * Default: "top"
144
+ */
145
+ scrollBehavior?: ScrollBehaviorFn | "top" | "restore" | "none";
146
+ /**
147
+ * Initial URL for SSR. On the server, window.location is unavailable;
148
+ * pass the request URL here so the router resolves the correct route.
149
+ *
150
+ * @example
151
+ * // In your SSR handler:
152
+ * const router = createRouter({ routes, url: req.url })
153
+ */
154
+ url?: string;
155
+ /**
156
+ * Called when a route loader throws. If not provided, errors are logged
157
+ * and the navigation continues with `undefined` data for the failed loader.
158
+ * Return `false` to cancel the navigation.
159
+ */
160
+ onError?: (err: unknown, route: ResolvedRoute) => undefined | false;
161
+ /**
162
+ * Maximum number of resolved lazy components to cache.
163
+ * When exceeded, the oldest entry is evicted.
164
+ * Default: 100.
165
+ */
166
+ maxCacheSize?: number;
167
+ /**
168
+ * Trailing slash handling:
169
+ * - `"strip"` — removes trailing slashes before matching (default)
170
+ * - `"add"` — ensures paths always end with `/`
171
+ * - `"ignore"` — no normalization
172
+ */
173
+ trailingSlash?: "strip" | "add" | "ignore";
174
+ }
175
+ interface Router {
176
+ /** Navigate to a path */
177
+ push(path: string): Promise<void>;
178
+ /** Navigate to a path by name */
179
+ push(location: {
180
+ name: string;
181
+ params?: Record<string, string>;
182
+ query?: Record<string, string>;
183
+ }): Promise<void>;
184
+ /** Replace current history entry */
185
+ replace(path: string): Promise<void>;
186
+ /** Replace current history entry using a named route */
187
+ replace(location: {
188
+ name: string;
189
+ params?: Record<string, string>;
190
+ query?: Record<string, string>;
191
+ }): Promise<void>;
192
+ /** Go back one step in history */
193
+ back(): void;
194
+ /** Go forward one step in history */
195
+ forward(): void;
196
+ /** Navigate forward or backward by `delta` steps in the history stack */
197
+ go(delta: number): void;
198
+ /** Register a global before-navigation guard. Returns an unregister function. */
199
+ beforeEach(guard: NavigationGuard): () => void;
200
+ /** Register a global after-navigation hook. Returns an unregister function. */
201
+ afterEach(hook: AfterEachHook): () => void;
202
+ /** Current resolved route (reactive signal) */
203
+ readonly currentRoute: () => ResolvedRoute;
204
+ /** True while a navigation (guards + loaders) is in flight */
205
+ readonly loading: () => boolean;
206
+ /**
207
+ * Promise that resolves once the initial navigation is complete.
208
+ * Useful for SSR and for delaying rendering until the first route is resolved.
209
+ */
210
+ isReady(): Promise<void>;
211
+ /** Remove all event listeners, clear caches, and abort in-flight navigations. */
212
+ destroy(): void;
213
+ }
214
+ interface RouterInstance extends Router {
215
+ routes: RouteRecord[];
216
+ mode: "hash" | "history";
217
+ /** Normalized base path (e.g. "/app"), empty string if none */
218
+ _base: string;
219
+ _currentPath: Signal<string>;
220
+ _currentRoute: Computed<ResolvedRoute>;
221
+ _componentCache: Map<RouteRecord, ComponentFn$1>;
222
+ _loadingSignal: Signal<number>;
223
+ _resolve(rawPath: string): ResolvedRoute;
224
+ _scrollPositions: Map<string, number>;
225
+ _scrollBehavior: RouterOptions["scrollBehavior"];
226
+ _onError: RouterOptions["onError"];
227
+ _maxCacheSize: number;
228
+ /**
229
+ * Current RouterView nesting depth. Incremented by each RouterView as it
230
+ * mounts (in tree order = depth-first), so each view knows which level of
231
+ * `matched[]` to render. Reset to 0 by RouterProvider.
232
+ */
233
+ _viewDepth: number;
234
+ /** Route records whose lazy chunk permanently failed (all retries exhausted) */
235
+ _erroredChunks: Set<RouteRecord>;
236
+ /** Loader data keyed by route record — populated before each navigation commits */
237
+ _loaderData: Map<RouteRecord, unknown>;
238
+ /** AbortController for the in-flight loader batch — aborted when a newer navigation starts */
239
+ _abortController: AbortController | null;
240
+ /** Registered navigation blockers */
241
+ _blockers: Set<BlockerFn>;
242
+ /** Resolves the isReady() promise after initial navigation completes */
243
+ _readyResolve: (() => void) | null;
244
+ /** The isReady() promise instance */
245
+ _readyPromise: Promise<void>;
80
246
  }
81
-
82
247
  //#endregion
83
- //#region src/match.ts
84
- /**
85
- * Parse a query string into key-value pairs. Duplicate keys are overwritten
86
- * (last value wins). Use `parseQueryMulti` to preserve duplicates as arrays.
87
- */
88
- function parseQuery(qs) {
89
- if (!qs) return {};
90
- const result = {};
91
- for (const part of qs.split("&")) {
92
- const eqIdx = part.indexOf("=");
93
- if (eqIdx < 0) {
94
- const key = decodeURIComponent(part);
95
- if (key) result[key] = "";
96
- } else {
97
- const key = decodeURIComponent(part.slice(0, eqIdx));
98
- const val = decodeURIComponent(part.slice(eqIdx + 1));
99
- if (key) result[key] = val;
100
- }
101
- }
102
- return result;
103
- }
104
- /**
105
- * Parse a query string preserving duplicate keys as arrays.
106
- *
107
- * @example
108
- * parseQueryMulti("color=red&color=blue&size=lg")
109
- * // → { color: ["red", "blue"], size: "lg" }
110
- */
111
- function parseQueryMulti(qs) {
112
- if (!qs) return {};
113
- const result = {};
114
- for (const part of qs.split("&")) {
115
- const eqIdx = part.indexOf("=");
116
- let key;
117
- let val;
118
- if (eqIdx < 0) {
119
- key = decodeURIComponent(part);
120
- val = "";
121
- } else {
122
- key = decodeURIComponent(part.slice(0, eqIdx));
123
- val = decodeURIComponent(part.slice(eqIdx + 1));
124
- }
125
- if (!key) continue;
126
- const existing = result[key];
127
- if (existing === void 0) result[key] = val;else if (Array.isArray(existing)) existing.push(val);else result[key] = [existing, val];
128
- }
129
- return result;
248
+ //#region src/components.d.ts
249
+ interface RouterProviderProps extends Props {
250
+ router: Router;
251
+ children?: VNode | VNodeChild | null;
130
252
  }
131
- function stringifyQuery(query) {
132
- const parts = [];
133
- for (const [k, v] of Object.entries(query)) parts.push(v ? `${encodeURIComponent(k)}=${encodeURIComponent(v)}` : encodeURIComponent(k));
134
- return parts.length ? `?${parts.join("&")}` : "";
135
- }
136
- /** WeakMap cache: compile each RouteRecord[] once */
137
-
138
- function compileSegment(raw) {
139
- if (raw.endsWith("*") && raw.startsWith(":")) return {
140
- raw,
141
- isParam: true,
142
- isSplat: true,
143
- isOptional: false,
144
- paramName: raw.slice(1, -1)
145
- };
146
- if (raw.endsWith("?") && raw.startsWith(":")) return {
147
- raw,
148
- isParam: true,
149
- isSplat: false,
150
- isOptional: true,
151
- paramName: raw.slice(1, -1)
152
- };
153
- if (raw.startsWith(":")) return {
154
- raw,
155
- isParam: true,
156
- isSplat: false,
157
- isOptional: false,
158
- paramName: raw.slice(1)
159
- };
160
- return {
161
- raw,
162
- isParam: false,
163
- isSplat: false,
164
- isOptional: false,
165
- paramName: ""
166
- };
167
- }
168
- function compileRoute(route) {
169
- const pattern = route.path;
170
- if (pattern === "(.*)" || pattern === "*") return {
171
- route,
172
- isWildcard: true,
173
- segments: [],
174
- segmentCount: 0,
175
- isStatic: false,
176
- staticPath: null,
177
- children: null,
178
- firstSegment: null
179
- };
180
- const segments = pattern.split("/").filter(Boolean).map(compileSegment);
181
- const isStatic = segments.every(s => !s.isParam);
182
- const staticPath = isStatic ? `/${segments.map(s => s.raw).join("/")}` : null;
183
- const first = segments.length > 0 ? segments[0] : void 0;
184
- const firstSegment = first && !first.isParam ? first.raw : null;
185
- return {
186
- route,
187
- isWildcard: false,
188
- segments,
189
- segmentCount: segments.length,
190
- isStatic,
191
- staticPath,
192
- children: null,
193
- firstSegment
194
- };
195
- }
196
- /** Expand alias paths into additional compiled entries sharing the original RouteRecord */
197
- function expandAliases(r, c) {
198
- if (!r.alias) return [];
199
- return (Array.isArray(r.alias) ? r.alias : [r.alias]).map(aliasPath => {
200
- const {
201
- alias: _,
202
- ...withoutAlias
203
- } = r;
204
- const ac = compileRoute({
205
- ...withoutAlias,
206
- path: aliasPath
207
- });
208
- ac.children = c.children;
209
- ac.route = r;
210
- return ac;
211
- });
212
- }
213
- function compileRoutes(routes) {
214
- const cached = _compiledCache.get(routes);
215
- if (cached) return cached;
216
- const compiled = [];
217
- for (const r of routes) {
218
- const c = compileRoute(r);
219
- if (r.children && r.children.length > 0) c.children = compileRoutes(r.children);
220
- compiled.push(c);
221
- compiled.push(...expandAliases(r, c));
222
- }
223
- _compiledCache.set(routes, compiled);
224
- return compiled;
225
- }
226
- /** Extract first static segment from a segment list, or null if dynamic/empty */
227
- function getFirstSegment(segments) {
228
- const first = segments[0];
229
- if (first && !first.isParam) return first.raw;
230
- return null;
231
- }
232
- /** Build a FlattenedRoute from segments + metadata */
233
- function makeFlatEntry(segments, chain, meta, isWildcard) {
234
- const isStatic = !isWildcard && segments.every(s => !s.isParam);
235
- const hasOptional = segments.some(s => s.isOptional);
236
- let minSegs = segments.length;
237
- if (hasOptional) while (minSegs > 0 && segments[minSegs - 1]?.isOptional) minSegs--;
238
- return {
239
- segments,
240
- segmentCount: segments.length,
241
- matchedChain: chain,
242
- isStatic,
243
- staticPath: isStatic ? `/${segments.map(s => s.raw).join("/")}` : null,
244
- meta,
245
- firstSegment: getFirstSegment(segments),
246
- hasSplat: segments.some(s => s.isSplat),
247
- isWildcard,
248
- hasOptional,
249
- minSegments: minSegs
250
- };
253
+ declare const RouterProvider: ComponentFn<RouterProviderProps>;
254
+ interface RouterViewProps extends Props {
255
+ /** Explicitly pass a router (optional uses the active router by default) */
256
+ router?: Router;
251
257
  }
252
258
  /**
253
- * Flatten nested routes into leaf entries with pre-joined segments.
254
- * This eliminates recursion during matching for the common case.
255
- */
256
- function flattenRoutes(compiled) {
257
- const result = [];
258
- flattenWalk(result, compiled, [], [], {});
259
- return result;
260
- }
261
- function flattenWalk(result, routes, parentSegments, parentChain, parentMeta) {
262
- for (const c of routes) flattenOne(result, c, parentSegments, [...parentChain, c.route], c.route.meta ? {
263
- ...parentMeta,
264
- ...c.route.meta
265
- } : {
266
- ...parentMeta
267
- });
268
- }
269
- function flattenOne(result, c, parentSegments, chain, meta) {
270
- if (c.isWildcard) {
271
- result.push(makeFlatEntry(parentSegments, chain, meta, true));
272
- if (c.children && c.children.length > 0) flattenWalk(result, c.children, parentSegments, chain, meta);
273
- return;
274
- }
275
- const joined = [...parentSegments, ...c.segments];
276
- if (c.children && c.children.length > 0) flattenWalk(result, c.children, joined, chain, meta);
277
- result.push(makeFlatEntry(joined, chain, meta, false));
278
- }
279
- /** Classify a single flattened route into the appropriate index bucket */
280
- function indexFlatRoute(f, staticMap, segmentMap, dynamicFirst, wildcards) {
281
- if (f.isStatic && f.staticPath && !staticMap.has(f.staticPath)) staticMap.set(f.staticPath, f);
282
- if (f.isWildcard) {
283
- wildcards.push(f);
284
- return;
285
- }
286
- if (f.segmentCount === 0) return;
287
- if (f.firstSegment) {
288
- let bucket = segmentMap.get(f.firstSegment);
289
- if (!bucket) {
290
- bucket = [];
291
- segmentMap.set(f.firstSegment, bucket);
292
- }
293
- bucket.push(f);
294
- } else dynamicFirst.push(f);
295
- }
296
- function buildRouteIndex(routes, compiled) {
297
- const cached = _indexCache.get(routes);
298
- if (cached) return cached;
299
- const flattened = flattenRoutes(compiled);
300
- const staticMap = /* @__PURE__ */new Map();
301
- const segmentMap = /* @__PURE__ */new Map();
302
- const dynamicFirst = [];
303
- const wildcards = [];
304
- for (const f of flattened) indexFlatRoute(f, staticMap, segmentMap, dynamicFirst, wildcards);
305
- const index = {
306
- staticMap,
307
- segmentMap,
308
- dynamicFirst,
309
- wildcards
310
- };
311
- _indexCache.set(routes, index);
312
- return index;
313
- }
314
- /** Split path into segments without allocating a filtered array */
315
- function splitPath(path) {
316
- if (path === "/") return [];
317
- const start = path.charCodeAt(0) === 47 ? 1 : 0;
318
- const end = path.length;
319
- if (start >= end) return [];
320
- const parts = [];
321
- let segStart = start;
322
- for (let i = start; i <= end; i++) if (i === end || path.charCodeAt(i) === 47) {
323
- if (i > segStart) parts.push(path.substring(segStart, i));
324
- segStart = i + 1;
325
- }
326
- return parts;
327
- }
328
- /** Decode only if the segment contains a `%` character */
329
- function decodeSafe(s) {
330
- return s.indexOf("%") >= 0 ? decodeURIComponent(s) : s;
331
- }
332
- /** Collect remaining path segments as a decoded splat value */
333
- function captureSplat(pathParts, from, pathLen) {
334
- const remaining = [];
335
- for (let j = from; j < pathLen; j++) {
336
- const p = pathParts[j];
337
- if (p !== void 0) remaining.push(decodeSafe(p));
338
- }
339
- return remaining.join("/");
340
- }
341
- /** Check whether a flattened route's segment count is compatible with the path length */
342
- function isSegmentCountCompatible(f, pathLen) {
343
- if (f.segmentCount === pathLen) return true;
344
- if (f.hasSplat && pathLen >= f.segmentCount) return true;
345
- if (f.hasOptional && pathLen >= f.minSegments && pathLen <= f.segmentCount) return true;
346
- return false;
347
- }
348
- /** Try to match a flattened route against path parts */
349
- function matchFlattened(f, pathParts, pathLen) {
350
- if (!isSegmentCountCompatible(f, pathLen)) return null;
351
- const params = {};
352
- const segments = f.segments;
353
- const count = f.segmentCount;
354
- for (let i = 0; i < count; i++) {
355
- const seg = segments[i];
356
- const pt = pathParts[i];
357
- if (!seg) return null;
358
- if (seg.isSplat) {
359
- params[seg.paramName] = captureSplat(pathParts, i, pathLen);
360
- return params;
361
- }
362
- if (pt === void 0) {
363
- if (!seg.isOptional) return null;
364
- continue;
365
- }
366
- if (seg.isParam) params[seg.paramName] = decodeSafe(pt);else if (seg.raw !== pt) return null;
367
- }
368
- return params;
369
- }
370
- /** Search a list of flattened candidates for a match */
371
- function searchCandidates(candidates, pathParts, pathLen) {
372
- for (let i = 0; i < candidates.length; i++) {
373
- const f = candidates[i];
374
- if (!f) continue;
375
- const params = matchFlattened(f, pathParts, pathLen);
376
- if (params) return {
377
- params,
378
- matched: f.matchedChain
379
- };
380
- }
381
- return null;
382
- }
383
- /**
384
- * Resolve a raw path (including query string and hash) against the route tree.
385
- * Uses flattened index for O(1) static lookup and first-segment dispatch.
386
- */
387
- function resolveRoute(rawPath, routes) {
388
- const qIdx = rawPath.indexOf("?");
389
- const pathAndHash = qIdx >= 0 ? rawPath.slice(0, qIdx) : rawPath;
390
- const queryPart = qIdx >= 0 ? rawPath.slice(qIdx + 1) : "";
391
- const hIdx = pathAndHash.indexOf("#");
392
- const cleanPath = hIdx >= 0 ? pathAndHash.slice(0, hIdx) : pathAndHash;
393
- const hash = hIdx >= 0 ? pathAndHash.slice(hIdx + 1) : "";
394
- const query = parseQuery(queryPart);
395
- const index = buildRouteIndex(routes, compileRoutes(routes));
396
- const staticMatch = index.staticMap.get(cleanPath);
397
- if (staticMatch) return {
398
- path: cleanPath,
399
- params: {},
400
- query,
401
- hash,
402
- matched: staticMatch.matchedChain,
403
- meta: staticMatch.meta
404
- };
405
- const pathParts = splitPath(cleanPath);
406
- const pathLen = pathParts.length;
407
- if (pathLen > 0) {
408
- const first = pathParts[0];
409
- const bucket = index.segmentMap.get(first);
410
- if (bucket) {
411
- const match = searchCandidates(bucket, pathParts, pathLen);
412
- if (match) return {
413
- path: cleanPath,
414
- params: match.params,
415
- query,
416
- hash,
417
- matched: match.matched,
418
- meta: mergeMeta(match.matched)
419
- };
420
- }
421
- }
422
- const dynMatch = searchCandidates(index.dynamicFirst, pathParts, pathLen);
423
- if (dynMatch) return {
424
- path: cleanPath,
425
- params: dynMatch.params,
426
- query,
427
- hash,
428
- matched: dynMatch.matched,
429
- meta: mergeMeta(dynMatch.matched)
430
- };
431
- const w = index.wildcards[0];
432
- if (w) return {
433
- path: cleanPath,
434
- params: {},
435
- query,
436
- hash,
437
- matched: w.matchedChain,
438
- meta: w.meta
439
- };
440
- return {
441
- path: cleanPath,
442
- params: {},
443
- query,
444
- hash,
445
- matched: [],
446
- meta: {}
447
- };
448
- }
449
- /** Merge meta from matched routes (leaf takes precedence) */
450
- function mergeMeta(matched) {
451
- const meta = {};
452
- for (const record of matched) if (record.meta) Object.assign(meta, record.meta);
453
- return meta;
454
- }
455
- /** Build a path string from a named route's pattern and params */
456
- function buildPath(pattern, params) {
457
- return pattern.replace(/\/:([^/]+)\?/g, (_match, key) => {
458
- const val = params[key];
459
- if (!val) return "";
460
- return `/${encodeURIComponent(val)}`;
461
- }).replace(/:([^/]+)\*?/g, (match, key) => {
462
- const val = params[key] ?? "";
463
- if (match.endsWith("*")) return val.split("/").map(encodeURIComponent).join("/");
464
- return encodeURIComponent(val);
465
- });
466
- }
467
- /** Find a route record by name (recursive, O(n)). Prefer buildNameIndex for repeated lookups. */
468
- function findRouteByName(name, routes) {
469
- for (const route of routes) {
470
- if (route.name === name) return route;
471
- if (route.children) {
472
- const found = findRouteByName(name, route.children);
473
- if (found) return found;
474
- }
475
- }
476
- return null;
477
- }
478
- /**
479
- * Pre-build a name → RouteRecord index from a route tree for O(1) named navigation.
480
- * Called once at router creation time; avoids O(n) depth-first search per push({ name }).
481
- */
482
- function buildNameIndex(routes) {
483
- const index = /* @__PURE__ */new Map();
484
- function walk(list) {
485
- for (const route of list) {
486
- if (route.name) index.set(route.name, route);
487
- if (route.children) walk(route.children);
488
- }
489
- }
490
- walk(routes);
491
- return index;
492
- }
493
-
259
+ * Renders the matched route component at this nesting level.
260
+ *
261
+ * Nested layouts work by placing a second `<RouterView />` inside the layout
262
+ * component — it automatically renders the next level of the matched route.
263
+ *
264
+ * How depth tracking works:
265
+ * Pyreon components run once in depth-first tree order. Each `RouterView`
266
+ * captures `router._viewDepth` at setup time and immediately increments it,
267
+ * so sibling and child views get the correct index. `onUnmount` decrements
268
+ * the counter so dynamic route swaps work correctly.
269
+ *
270
+ * @example
271
+ * // Route config:
272
+ * { path: "/admin", component: AdminLayout, children: [
273
+ * { path: "users", component: AdminUsers },
274
+ * ]}
275
+ *
276
+ * // AdminLayout renders a nested RouterView:
277
+ * function AdminLayout() {
278
+ * return <div><Sidebar /><RouterView /></div>
279
+ * }
280
+ */
281
+ declare const RouterView: ComponentFn<RouterViewProps>;
282
+ interface RouterLinkProps extends Props {
283
+ to: string;
284
+ /** If true, uses router.replace() instead of router.push() */
285
+ replace?: boolean;
286
+ /** CSS class applied when this link is active (default: "router-link-active") */
287
+ activeClass?: string;
288
+ /** CSS class for exact-match active state (default: "router-link-exact-active") */
289
+ exactActiveClass?: string;
290
+ /** If true, only applies activeClass on exact match */
291
+ exact?: boolean;
292
+ /**
293
+ * Prefetch strategy for loader data:
294
+ * - "hover" (default) — prefetch when the user hovers over the link
295
+ * - "viewport" — prefetch when the link scrolls into the viewport
296
+ * - "none" — no prefetching
297
+ */
298
+ prefetch?: "hover" | "viewport" | "none";
299
+ children?: VNodeChild | null;
300
+ }
301
+ declare const RouterLink: ComponentFn<RouterLinkProps>;
494
302
  //#endregion
495
- //#region src/scroll.ts
303
+ //#region src/loader.d.ts
496
304
  /**
497
- * Scroll restoration manager.
498
- *
499
- * Saves scroll position before each navigation and restores it when
500
- * navigating back to a previously visited path.
501
- */
502
-
503
- function lazy(loader, options) {
504
- return {
505
- [LAZY_SYMBOL]: true,
506
- loader,
507
- ...(options?.loading ? {
508
- loadingComponent: options.loading
509
- } : {}),
510
- ...(options?.error ? {
511
- errorComponent: options.error
512
- } : {})
513
- };
514
- }
515
- function isLazy(c) {
516
- return typeof c === "object" && c !== null && c[LAZY_SYMBOL] === true;
517
- }
518
-
519
- //#endregion
520
- //#region src/router.ts
521
-
522
- function setActiveRouter(router) {
523
- if (router) router._viewDepth = 0;
524
- _activeRouter = router;
525
- }
526
- function useRouter() {
527
- const router = useContext(RouterContext) ?? _activeRouter;
528
- if (!router) throw new Error("[pyreon-router] No router installed. Wrap your app in <RouterProvider router={router}>.");
529
- return router;
530
- }
531
- function useRoute() {
532
- const router = useContext(RouterContext) ?? _activeRouter;
533
- if (!router) throw new Error("[pyreon-router] No router installed. Wrap your app in <RouterProvider router={router}>.");
534
- return router.currentRoute;
535
- }
305
+ * Returns the data resolved by the current route's `loader` function.
306
+ * Must be called inside a route component rendered by <RouterView />.
307
+ *
308
+ * @example
309
+ * const routes = [{ path: "/users", component: Users, loader: fetchUsers }]
310
+ *
311
+ * function Users() {
312
+ * const users = useLoaderData<User[]>()
313
+ * return h("ul", null, users.map(u => h("li", null, u.name)))
314
+ * }
315
+ */
316
+ declare function useLoaderData<T = unknown>(): T;
536
317
  /**
537
- * In-component guard: called before the component's route is left.
538
- * Return `false` to cancel, a string to redirect, or `undefined`/`true` to proceed.
539
- * Automatically removed on component unmount.
540
- *
541
- * @example
542
- * onBeforeRouteLeave((to, from) => {
543
- * if (hasUnsavedChanges()) return false
544
- * })
545
- */
546
- function onBeforeRouteLeave(guard) {
547
- const router = useContext(RouterContext) ?? _activeRouter;
548
- if (!router) throw new Error("[pyreon-router] No router installed. Wrap your app in <RouterProvider router={router}>.");
549
- const currentMatched = router.currentRoute().matched;
550
- const wrappedGuard = (to, from) => {
551
- if (!from.matched.some(r => currentMatched.includes(r))) return void 0;
552
- return guard(to, from);
553
- };
554
- const remove = router.beforeEach(wrappedGuard);
555
- onUnmount(() => remove());
556
- return remove;
557
- }
318
+ * SSR helper: pre-run all loaders for the given path before rendering.
319
+ * Call this before `renderToString` so route components can read data via `useLoaderData()`.
320
+ *
321
+ * @example
322
+ * const router = createRouter({ routes, url: req.url })
323
+ * await prefetchLoaderData(router, req.url)
324
+ * const html = await renderToString(h(App, { router }))
325
+ */
326
+ declare function prefetchLoaderData(router: RouterInstance, path: string): Promise<void>;
558
327
  /**
559
- * In-component guard: called when the route changes but the component is reused
560
- * (e.g. `/user/1` `/user/2`). Useful for reacting to param changes.
561
- * Automatically removed on component unmount.
562
- *
563
- * @example
564
- * onBeforeRouteUpdate((to, from) => {
565
- * if (!isValidId(to.params.id)) return false
566
- * })
567
- */
568
- function onBeforeRouteUpdate(guard) {
569
- const router = useContext(RouterContext) ?? _activeRouter;
570
- if (!router) throw new Error("[pyreon-router] No router installed. Wrap your app in <RouterProvider router={router}>.");
571
- const currentMatched = router.currentRoute().matched;
572
- const wrappedGuard = (to, from) => {
573
- if (!to.matched.some(r => currentMatched.includes(r))) return void 0;
574
- return guard(to, from);
575
- };
576
- const remove = router.beforeEach(wrappedGuard);
577
- onUnmount(() => remove());
578
- return remove;
579
- }
328
+ * Serialize loader data to a JSON-safe plain object for embedding in SSR HTML.
329
+ * Keys are route path patterns (stable across server and client).
330
+ *
331
+ * @example — SSR handler:
332
+ * await prefetchLoaderData(router, req.url)
333
+ * const { html, head } = await renderWithHead(h(App, null))
334
+ * const page = `...${head}
335
+ * <script>window.__PYREON_LOADER_DATA__=${JSON.stringify(serializeLoaderData(router))}</script>
336
+ * ...${html}...`
337
+ */
338
+ declare function serializeLoaderData(router: RouterInstance): Record<string, unknown>;
580
339
  /**
581
- * Register a navigation blocker. The `fn` callback is called before each
582
- * navigation return `true` (or resolve to `true`) to block it.
583
- *
584
- * Automatically removed on component unmount if called during component setup.
585
- * Also installs a `beforeunload` handler so the browser shows a confirmation
586
- * dialog when the user tries to close the tab while a blocker is active.
587
- *
588
- * @example
589
- * const blocker = useBlocker((to, from) => {
590
- * return hasUnsavedChanges() && !confirm("Discard changes?")
591
- * })
592
- * // later: blocker.remove()
593
- */
594
- function useBlocker(fn) {
595
- const router = useContext(RouterContext) ?? _activeRouter;
596
- if (!router) throw new Error("[pyreon-router] No router installed. Wrap your app in <RouterProvider router={router}>.");
597
- router._blockers.add(fn);
598
- const beforeUnloadHandler = _isBrowser ? e => {
599
- e.preventDefault();
600
- } : null;
601
- if (beforeUnloadHandler) window.addEventListener("beforeunload", beforeUnloadHandler);
602
- const remove = () => {
603
- router._blockers.delete(fn);
604
- if (beforeUnloadHandler) window.removeEventListener("beforeunload", beforeUnloadHandler);
605
- };
606
- onUnmount(() => remove());
607
- return {
608
- remove
609
- };
610
- }
340
+ * Hydrate loader data from a serialized object (e.g. `window.__PYREON_LOADER_DATA__`).
341
+ * Populates the router's internal `_loaderData` map so the initial render uses
342
+ * server-fetched data without re-running loaders on the client.
343
+ *
344
+ * Call this before `mount()`, after `createRouter()`.
345
+ *
346
+ * @example — client entry:
347
+ * import { hydrateLoaderData } from "@pyreon/router"
348
+ * const router = createRouter({ routes })
349
+ * hydrateLoaderData(router, window.__PYREON_LOADER_DATA__ ?? {})
350
+ * mount(h(App, null), document.getElementById("app")!)
351
+ */
352
+ declare function hydrateLoaderData(router: RouterInstance, serialized: Record<string, unknown>): void;
353
+ //#endregion
354
+ //#region src/match.d.ts
611
355
  /**
612
- * Reactive read/write access to the current route's query parameters.
613
- *
614
- * Returns `[get, set]` where `get` is a reactive signal producing the merged
615
- * query object and `set` navigates to the current path with updated params.
616
- *
617
- * @example
618
- * const [params, setParams] = useSearchParams({ page: "1", sort: "name" })
619
- * params().page // "1" if not in URL
620
- * setParams({ page: "2" }) // navigates to ?page=2&sort=name
621
- */
622
- function useSearchParams(defaults) {
623
- const router = useContext(RouterContext) ?? _activeRouter;
624
- if (!router) throw new Error("[pyreon-router] No router installed. Wrap your app in <RouterProvider router={router}>.");
625
- const get = () => {
626
- const query = router.currentRoute().query;
627
- if (!defaults) return query;
628
- return {
629
- ...defaults,
630
- ...query
631
- };
632
- };
633
- const set = updates => {
634
- const merged = {
635
- ...get(),
636
- ...updates
637
- };
638
- const path = router.currentRoute().path + stringifyQuery(merged);
639
- return router.replace(path);
640
- };
641
- return [get, set];
642
- }
643
- function createRouter(options) {
644
- const opts = Array.isArray(options) ? {
645
- routes: options
646
- } : options;
647
- const {
648
- routes,
649
- mode = "hash",
650
- scrollBehavior,
651
- onError,
652
- maxCacheSize = 100,
653
- trailingSlash = "strip"
654
- } = opts;
655
- const base = mode === "history" ? normalizeBase(opts.base ?? "") : "";
656
- const nameIndex = buildNameIndex(routes);
657
- const guards = [];
658
- const afterHooks = [];
659
- const scrollManager = new ScrollManager(scrollBehavior);
660
- let _navGen = 0;
661
- const getInitialLocation = () => {
662
- if (opts.url) return stripBase(opts.url, base);
663
- if (!_isBrowser) return "/";
664
- if (mode === "history") return stripBase(window.location.pathname, base) + window.location.search;
665
- const hash = window.location.hash;
666
- return hash.startsWith("#") ? hash.slice(1) || "/" : "/";
667
- };
668
- const getCurrentLocation = () => {
669
- if (!_isBrowser) return currentPath();
670
- if (mode === "history") return stripBase(window.location.pathname, base) + window.location.search;
671
- const hash = window.location.hash;
672
- return hash.startsWith("#") ? hash.slice(1) || "/" : "/";
673
- };
674
- const currentPath = signal(normalizeTrailingSlash(getInitialLocation(), trailingSlash));
675
- const currentRoute = computed(() => resolveRoute(currentPath(), routes));
676
- let _popstateHandler = null;
677
- let _hashchangeHandler = null;
678
- if (_isBrowser) if (mode === "history") {
679
- _popstateHandler = () => currentPath.set(getCurrentLocation());
680
- window.addEventListener("popstate", _popstateHandler);
681
- } else {
682
- _hashchangeHandler = () => currentPath.set(getCurrentLocation());
683
- window.addEventListener("hashchange", _hashchangeHandler);
684
- }
685
- const componentCache = /* @__PURE__ */new Map();
686
- const loadingSignal = signal(0);
687
- async function evaluateGuard(guard, to, from, gen) {
688
- const result = await runGuard(guard, to, from);
689
- if (gen !== _navGen) return {
690
- action: "cancel"
691
- };
692
- if (result === false) return {
693
- action: "cancel"
694
- };
695
- if (typeof result === "string") return {
696
- action: "redirect",
697
- target: result
698
- };
699
- return {
700
- action: "continue"
701
- };
702
- }
703
- async function runRouteGuards(records, guardKey, to, from, gen) {
704
- for (const record of records) {
705
- const raw = record[guardKey];
706
- if (!raw) continue;
707
- const routeGuards = Array.isArray(raw) ? raw : [raw];
708
- for (const guard of routeGuards) {
709
- const outcome = await evaluateGuard(guard, to, from, gen);
710
- if (outcome.action !== "continue") return outcome;
711
- }
712
- }
713
- return {
714
- action: "continue"
715
- };
716
- }
717
- async function runGlobalGuards(globalGuards, to, from, gen) {
718
- for (const guard of globalGuards) {
719
- const outcome = await evaluateGuard(guard, to, from, gen);
720
- if (outcome.action !== "continue") return outcome;
721
- }
722
- return {
723
- action: "continue"
724
- };
725
- }
726
- function processLoaderResult(result, record, ac, to) {
727
- if (result.status === "fulfilled") {
728
- router._loaderData.set(record, result.value);
729
- return true;
730
- }
731
- if (ac.signal.aborted) return true;
732
- if (router._onError) {
733
- if (router._onError(result.reason, to) === false) return false;
734
- }
735
- router._loaderData.set(record, void 0);
736
- return true;
737
- }
738
- function syncBrowserUrl(path, replace) {
739
- if (!_isBrowser) return;
740
- const url = mode === "history" ? `${base}${path}` : `#${path}`;
741
- if (replace) window.history.replaceState(null, "", url);else window.history.pushState(null, "", url);
742
- }
743
- function resolveRedirect(to) {
744
- const leaf = to.matched[to.matched.length - 1];
745
- if (!leaf?.redirect) return null;
746
- return sanitizePath(typeof leaf.redirect === "function" ? leaf.redirect(to) : leaf.redirect);
747
- }
748
- async function runAllGuards(to, from, gen) {
749
- const leaveOutcome = await runRouteGuards(from.matched, "beforeLeave", to, from, gen);
750
- if (leaveOutcome.action !== "continue") return leaveOutcome;
751
- const enterOutcome = await runRouteGuards(to.matched, "beforeEnter", to, from, gen);
752
- if (enterOutcome.action !== "continue") return enterOutcome;
753
- return runGlobalGuards(guards, to, from, gen);
754
- }
755
- async function runBlockingLoaders(records, to, gen, ac) {
756
- const loaderCtx = {
757
- params: to.params,
758
- query: to.query,
759
- signal: ac.signal
760
- };
761
- const results = await Promise.allSettled(records.map(r => r.loader ? r.loader(loaderCtx) : Promise.resolve(void 0)));
762
- if (gen !== _navGen) return false;
763
- for (let i = 0; i < records.length; i++) {
764
- const result = results[i];
765
- const record = records[i];
766
- if (!result || !record) continue;
767
- if (!processLoaderResult(result, record, ac, to)) return false;
768
- }
769
- return true;
770
- }
771
- /** Fire-and-forget background revalidation for stale-while-revalidate routes. */
772
- function revalidateSwrLoaders(records, to, ac) {
773
- const loaderCtx = {
774
- params: to.params,
775
- query: to.query,
776
- signal: ac.signal
777
- };
778
- for (const r of records) {
779
- if (!r.loader) continue;
780
- r.loader(loaderCtx).then(data => {
781
- if (!ac.signal.aborted) {
782
- router._loaderData.set(r, data);
783
- loadingSignal.update(n => n + 1);
784
- loadingSignal.update(n => n - 1);
785
- }
786
- }).catch(() => {});
787
- }
788
- }
789
- async function runLoaders(to, gen, ac) {
790
- const loadableRecords = to.matched.filter(r => r.loader);
791
- if (loadableRecords.length === 0) return true;
792
- const blocking = [];
793
- const swr = [];
794
- for (const r of loadableRecords) if (r.staleWhileRevalidate && router._loaderData.has(r)) swr.push(r);else blocking.push(r);
795
- if (blocking.length > 0) {
796
- if (!(await runBlockingLoaders(blocking, to, gen, ac))) return false;
797
- }
798
- if (swr.length > 0) revalidateSwrLoaders(swr, to, ac);
799
- return true;
800
- }
801
- function commitNavigation(path, replace, to, from) {
802
- scrollManager.save(from.path);
803
- currentPath.set(path);
804
- syncBrowserUrl(path, replace);
805
- if (_isBrowser && to.meta.title) document.title = to.meta.title;
806
- for (const record of router._loaderData.keys()) if (!to.matched.includes(record)) router._loaderData.delete(record);
807
- for (const hook of afterHooks) try {
808
- hook(to, from);
809
- } catch (_err) {}
810
- if (_isBrowser) queueMicrotask(() => scrollManager.restore(to, from));
811
- }
812
- async function checkBlockers(to, from, gen) {
813
- for (const blocker of router._blockers) {
814
- const blocked = await blocker(to, from);
815
- if (gen !== _navGen || blocked) return "cancel";
816
- }
817
- return "continue";
818
- }
819
- async function navigate(rawPath, replace, redirectDepth = 0) {
820
- if (redirectDepth > 10) return;
821
- const path = normalizeTrailingSlash(rawPath, trailingSlash);
822
- const gen = ++_navGen;
823
- loadingSignal.update(n => n + 1);
824
- const to = resolveRoute(path, routes);
825
- const from = currentRoute();
826
- const redirectTarget = resolveRedirect(to);
827
- if (redirectTarget !== null) {
828
- loadingSignal.update(n => n - 1);
829
- return navigate(redirectTarget, replace, redirectDepth + 1);
830
- }
831
- if ((await checkBlockers(to, from, gen)) !== "continue") {
832
- loadingSignal.update(n => n - 1);
833
- return;
834
- }
835
- const guardOutcome = await runAllGuards(to, from, gen);
836
- if (guardOutcome.action !== "continue") {
837
- loadingSignal.update(n => n - 1);
838
- if (guardOutcome.action === "redirect") return navigate(sanitizePath(guardOutcome.target), replace, redirectDepth + 1);
839
- return;
840
- }
841
- router._abortController?.abort();
842
- const ac = new AbortController();
843
- router._abortController = ac;
844
- if (!(await runLoaders(to, gen, ac))) {
845
- loadingSignal.update(n => n - 1);
846
- return;
847
- }
848
- commitNavigation(path, replace, to, from);
849
- loadingSignal.update(n => n - 1);
850
- }
851
- let _readyResolve = null;
852
- const _readyPromise = new Promise(resolve => {
853
- _readyResolve = resolve;
854
- });
855
- const router = {
856
- routes,
857
- mode,
858
- _base: base,
859
- currentRoute,
860
- _currentPath: currentPath,
861
- _currentRoute: currentRoute,
862
- _componentCache: componentCache,
863
- _loadingSignal: loadingSignal,
864
- _scrollPositions: /* @__PURE__ */new Map(),
865
- _scrollBehavior: scrollBehavior,
866
- _viewDepth: 0,
867
- _erroredChunks: /* @__PURE__ */new Set(),
868
- _loaderData: /* @__PURE__ */new Map(),
869
- _abortController: null,
870
- _blockers: /* @__PURE__ */new Set(),
871
- _readyResolve,
872
- _readyPromise,
873
- _onError: onError,
874
- _maxCacheSize: maxCacheSize,
875
- async push(location) {
876
- if (typeof location === "string") return navigate(sanitizePath(resolveRelativePath(location, currentPath())), false);
877
- return navigate(resolveNamedPath(location.name, location.params ?? {}, location.query ?? {}, nameIndex), false);
878
- },
879
- async replace(location) {
880
- if (typeof location === "string") return navigate(sanitizePath(resolveRelativePath(location, currentPath())), true);
881
- return navigate(resolveNamedPath(location.name, location.params ?? {}, location.query ?? {}, nameIndex), true);
882
- },
883
- back() {
884
- if (_isBrowser) window.history.back();
885
- },
886
- forward() {
887
- if (_isBrowser) window.history.forward();
888
- },
889
- go(delta) {
890
- if (_isBrowser) window.history.go(delta);
891
- },
892
- beforeEach(guard) {
893
- guards.push(guard);
894
- return () => {
895
- const idx = guards.indexOf(guard);
896
- if (idx >= 0) guards.splice(idx, 1);
897
- };
898
- },
899
- afterEach(hook) {
900
- afterHooks.push(hook);
901
- return () => {
902
- const idx = afterHooks.indexOf(hook);
903
- if (idx >= 0) afterHooks.splice(idx, 1);
904
- };
905
- },
906
- loading: () => loadingSignal() > 0,
907
- isReady() {
908
- return router._readyPromise;
909
- },
910
- destroy() {
911
- if (_popstateHandler) {
912
- window.removeEventListener("popstate", _popstateHandler);
913
- _popstateHandler = null;
914
- }
915
- if (_hashchangeHandler) {
916
- window.removeEventListener("hashchange", _hashchangeHandler);
917
- _hashchangeHandler = null;
918
- }
919
- guards.length = 0;
920
- afterHooks.length = 0;
921
- router._blockers.clear();
922
- componentCache.clear();
923
- router._loaderData.clear();
924
- router._abortController?.abort();
925
- router._abortController = null;
926
- },
927
- _resolve: rawPath => resolveRoute(rawPath, routes)
928
- };
929
- queueMicrotask(() => {
930
- if (router._readyResolve) {
931
- router._readyResolve();
932
- router._readyResolve = null;
933
- }
934
- });
935
- return router;
936
- }
937
- async function runGuard(guard, to, from) {
938
- try {
939
- return await guard(to, from);
940
- } catch (_err) {
941
- return false;
942
- }
943
- }
944
- function resolveNamedPath(name, params, query, index) {
945
- const record = index.get(name);
946
- if (!record) return "/";
947
- let path = buildPath(record.path, params);
948
- const qs = Object.entries(query).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("&");
949
- if (qs) path += `?${qs}`;
950
- return path;
951
- }
952
- /** Normalize a base path: ensure leading `/`, strip trailing `/`. */
953
- function normalizeBase(raw) {
954
- if (!raw) return "";
955
- let b = raw;
956
- if (!b.startsWith("/")) b = `/${b}`;
957
- if (b.endsWith("/")) b = b.slice(0, -1);
958
- return b;
959
- }
960
- /** Strip the base prefix from a full URL path. Returns the app-relative path. */
961
- function stripBase(path, base) {
962
- if (!base) return path;
963
- if (path === base || path === `${base}/`) return "/";
964
- if (path.startsWith(`${base}/`)) return path.slice(base.length);
965
- return path;
966
- }
967
- /** Normalize trailing slash on a path according to the configured strategy. */
968
- function normalizeTrailingSlash(path, strategy) {
969
- if (strategy === "ignore" || path === "/") return path;
970
- const qIdx = path.indexOf("?");
971
- const hIdx = path.indexOf("#");
972
- const endIdx = qIdx >= 0 ? qIdx : hIdx >= 0 ? hIdx : path.length;
973
- const pathPart = path.slice(0, endIdx);
974
- const suffix = path.slice(endIdx);
975
- if (strategy === "strip") return pathPart.length > 1 && pathPart.endsWith("/") ? pathPart.slice(0, -1) + suffix : path;
976
- return !pathPart.endsWith("/") ? `${pathPart}/${suffix}` : path;
977
- }
356
+ * Parse a query string into key-value pairs. Duplicate keys are overwritten
357
+ * (last value wins). Use `parseQueryMulti` to preserve duplicates as arrays.
358
+ */
359
+ declare function parseQuery(qs: string): Record<string, string>;
978
360
  /**
979
- * Resolve a relative path (starting with `.` or `..`) against the current path.
980
- * Non-relative paths are returned as-is.
981
- */
982
- function resolveRelativePath(to, from) {
983
- if (!to.startsWith("./") && !to.startsWith("../") && to !== "." && to !== "..") return to;
984
- const fromSegments = from.split("/").filter(Boolean);
985
- fromSegments.pop();
986
- const toSegments = to.split("/").filter(Boolean);
987
- for (const seg of toSegments) if (seg === "..") fromSegments.pop();else if (seg !== ".") fromSegments.push(seg);
988
- return `/${fromSegments.join("/")}`;
989
- }
990
- /** Block unsafe navigation targets: javascript/data/vbscript URIs and absolute URLs. */
991
- function sanitizePath(path) {
992
- const trimmed = path.trim();
993
- if (/^(?:javascript|data|vbscript):/i.test(trimmed)) return "/";
994
- if (/^\/\/|^https?:/i.test(trimmed)) return "/";
995
- return path;
996
- }
997
-
361
+ * Parse a query string preserving duplicate keys as arrays.
362
+ *
363
+ * @example
364
+ * parseQueryMulti("color=red&color=blue&size=lg")
365
+ * // { color: ["red", "blue"], size: "lg" }
366
+ */
367
+ declare function parseQueryMulti(qs: string): Record<string, string | string[]>;
368
+ declare function stringifyQuery(query: Record<string, string>): string;
369
+ /**
370
+ * Resolve a raw path (including query string and hash) against the route tree.
371
+ * Uses flattened index for O(1) static lookup and first-segment dispatch.
372
+ */
373
+ declare function resolveRoute(rawPath: string, routes: RouteRecord[]): ResolvedRoute;
374
+ /** Build a path string from a named route's pattern and params */
375
+ declare function buildPath(pattern: string, params: Record<string, string>): string;
376
+ /** Find a route record by name (recursive, O(n)). Prefer buildNameIndex for repeated lookups. */
377
+ declare function findRouteByName(name: string, routes: RouteRecord[]): RouteRecord | null;
998
378
  //#endregion
999
- //#region src/components.tsx
1000
-
1001
- /** Prefetch loader data for a route (only once per router + path). */
1002
- function prefetchRoute(router, path) {
1003
- let set = _prefetched.get(router);
1004
- if (!set) {
1005
- set = /* @__PURE__ */new Set();
1006
- _prefetched.set(router, set);
1007
- }
1008
- if (set.has(path)) return;
1009
- set.add(path);
1010
- prefetchLoaderData(router, path).catch(() => {
1011
- set?.delete(path);
1012
- });
1013
- }
1014
- function renderLazyRoute(router, record, raw) {
1015
- if (router._erroredChunks.has(record)) return raw.errorComponent ? h(raw.errorComponent, {}) : null;
1016
- const tryLoad = attempt => raw.loader().then(mod => {
1017
- cacheSet(router, record, typeof mod === "function" ? mod : mod.default);
1018
- router._loadingSignal.update(n => n + 1);
1019
- }).catch(err => {
1020
- if (attempt < 3) return new Promise(res => setTimeout(res, 500 * 2 ** attempt)).then(() => tryLoad(attempt + 1));
1021
- if (typeof window !== "undefined" && isStaleChunk(err)) {
1022
- window.location.reload();
1023
- return;
1024
- }
1025
- router._erroredChunks.add(record);
1026
- router._loadingSignal.update(n => n + 1);
1027
- });
1028
- tryLoad(0);
1029
- return raw.loadingComponent ? h(raw.loadingComponent, {}) : null;
1030
- }
379
+ //#region src/router.d.ts
380
+ declare const RouterContext: _pyreon_core0.Context<RouterInstance | null>;
381
+ declare function useRouter(): Router;
382
+ declare function useRoute<TPath extends string = string>(): () => ResolvedRoute<ExtractParams<TPath> & Record<string, string>, Record<string, string>>;
1031
383
  /**
1032
- * Wraps the route component with a LoaderDataProvider so `useLoaderData()` works
1033
- * inside the component. If the record has no loader, renders the component directly.
1034
- */
1035
- function renderWithLoader(router, record, Comp, route) {
1036
- const routeProps = {
1037
- params: route.params,
1038
- query: route.query,
1039
- meta: route.meta
1040
- };
1041
- if (!record.loader) return h(Comp, routeProps);
1042
- const data = router._loaderData.get(record);
1043
- if (data === void 0 && record.errorComponent) return h(record.errorComponent, routeProps);
1044
- return h(LoaderDataProvider, {
1045
- data,
1046
- children: h(Comp, routeProps)
1047
- });
1048
- }
384
+ * In-component guard: called before the component's route is left.
385
+ * Return `false` to cancel, a string to redirect, or `undefined`/`true` to proceed.
386
+ * Automatically removed on component unmount.
387
+ *
388
+ * @example
389
+ * onBeforeRouteLeave((to, from) => {
390
+ * if (hasUnsavedChanges()) return false
391
+ * })
392
+ */
393
+ declare function onBeforeRouteLeave(guard: NavigationGuard): () => void;
1049
394
  /**
1050
- * Thin provider component that pushes LoaderDataContext before children mount.
1051
- * Uses Pyreon's context stack so useLoaderData() reads it during child setup.
1052
- */
1053
- function LoaderDataProvider(props) {
1054
- pushContext(new Map([[LoaderDataContext.id, props.data]]));
1055
- onUnmount(() => popContext());
1056
- return props.children;
1057
- }
1058
- /** Evict oldest cache entries when the component cache exceeds maxCacheSize. */
1059
- function cacheSet(router, record, comp) {
1060
- router._componentCache.set(record, comp);
1061
- if (router._componentCache.size > router._maxCacheSize) {
1062
- const oldest = router._componentCache.keys().next().value;
1063
- router._componentCache.delete(oldest);
1064
- }
1065
- }
395
+ * In-component guard: called when the route changes but the component is reused
396
+ * (e.g. `/user/1` `/user/2`). Useful for reacting to param changes.
397
+ * Automatically removed on component unmount.
398
+ *
399
+ * @example
400
+ * onBeforeRouteUpdate((to, from) => {
401
+ * if (!isValidId(to.params.id)) return false
402
+ * })
403
+ */
404
+ declare function onBeforeRouteUpdate(guard: NavigationGuard): () => void;
1066
405
  /**
1067
- * Segment-aware prefix check for active link matching.
1068
- * `/admin` is a prefix of `/admin/users` but NOT of `/admin-panel`.
1069
- */
1070
- function isSegmentPrefix(current, target) {
1071
- if (target === "/") return false;
1072
- const cs = current.split("/").filter(Boolean);
1073
- const ts = target.split("/").filter(Boolean);
1074
- if (ts.length > cs.length) return false;
1075
- return ts.every((seg, i) => seg === cs[i]);
1076
- }
406
+ * Register a navigation blocker. The `fn` callback is called before each
407
+ * navigation — return `true` (or resolve to `true`) to block it.
408
+ *
409
+ * Automatically removed on component unmount if called during component setup.
410
+ * Also installs a `beforeunload` handler so the browser shows a confirmation
411
+ * dialog when the user tries to close the tab while a blocker is active.
412
+ *
413
+ * @example
414
+ * const blocker = useBlocker((to, from) => {
415
+ * return hasUnsavedChanges() && !confirm("Discard changes?")
416
+ * })
417
+ * // later: blocker.remove()
418
+ */
419
+ declare function useBlocker(fn: BlockerFn): Blocker;
1077
420
  /**
1078
- * Detect a stale chunk error happens post-deploy when the browser requests
1079
- * a hashed filename that no longer exists on the server. Trigger a full reload
1080
- * so the user gets the new bundle instead of a broken loading state.
1081
- */
1082
- function isStaleChunk(err) {
1083
- if (err instanceof TypeError && String(err.message).includes("Failed to fetch")) return true;
1084
- if (err instanceof SyntaxError) return true;
1085
- return false;
1086
- }
1087
-
421
+ * Reactive read/write access to the current route's query parameters.
422
+ *
423
+ * Returns `[get, set]` where `get` is a reactive signal producing the merged
424
+ * query object and `set` navigates to the current path with updated params.
425
+ *
426
+ * @example
427
+ * const [params, setParams] = useSearchParams({ page: "1", sort: "name" })
428
+ * params().page // "1" if not in URL
429
+ * setParams({ page: "2" }) // navigates to ?page=2&sort=name
430
+ */
431
+ declare function useSearchParams<T extends Record<string, string>>(defaults?: T): [get: () => T, set: (updates: Partial<T>) => Promise<void>];
432
+ declare function createRouter(options: RouterOptions | RouteRecord[]): Router;
1088
433
  //#endregion
1089
- export { RouterContext, RouterLink, RouterProvider, RouterView, buildPath, createRouter, findRouteByName, hydrateLoaderData, lazy, onBeforeRouteLeave, onBeforeRouteUpdate, parseQuery, parseQueryMulti, prefetchLoaderData, resolveRoute, serializeLoaderData, stringifyQuery, useBlocker, useLoaderData, useRoute, useRouter, useSearchParams };
1090
- //# sourceMappingURL=index.d.ts.map
434
+ export { type AfterEachHook, type Blocker, type BlockerFn, type ExtractParams, type LazyComponent, type LoaderContext, type NavigationGuard, type NavigationGuardResult, type ResolvedRoute, type RouteComponent, type RouteLoaderFn, type RouteMeta, type RouteRecord, type Router, RouterContext, RouterLink, type RouterLinkProps, type RouterOptions, RouterProvider, type RouterProviderProps, RouterView, type RouterViewProps, type ScrollBehaviorFn, buildPath, createRouter, findRouteByName, hydrateLoaderData, lazy, onBeforeRouteLeave, onBeforeRouteUpdate, parseQuery, parseQueryMulti, prefetchLoaderData, resolveRoute, serializeLoaderData, stringifyQuery, useBlocker, useLoaderData, useRoute, useRouter, useSearchParams };
435
+ //# sourceMappingURL=index2.d.ts.map