@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/lib/index.js
ADDED
|
@@ -0,0 +1,830 @@
|
|
|
1
|
+
import { createContext, createRef, h, onUnmount, popContext, pushContext, useContext } from "@pyreon/core";
|
|
2
|
+
import { computed, signal } from "@pyreon/reactivity";
|
|
3
|
+
|
|
4
|
+
//#region src/loader.ts
|
|
5
|
+
/**
|
|
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
|
+
const LoaderDataContext = createContext(void 0);
|
|
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
|
+
}
|
|
47
|
+
/**
|
|
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
|
+
}
|
|
63
|
+
/**
|
|
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]);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
//#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;
|
|
128
|
+
else if (Array.isArray(existing)) existing.push(val);
|
|
129
|
+
else result[key] = [existing, val];
|
|
130
|
+
}
|
|
131
|
+
return result;
|
|
132
|
+
}
|
|
133
|
+
function stringifyQuery(query) {
|
|
134
|
+
const parts = [];
|
|
135
|
+
for (const [k, v] of Object.entries(query)) parts.push(v ? `${encodeURIComponent(k)}=${encodeURIComponent(v)}` : encodeURIComponent(k));
|
|
136
|
+
return parts.length ? `?${parts.join("&")}` : "";
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Match a single route pattern against a path segment.
|
|
140
|
+
* Returns extracted params or null if no match.
|
|
141
|
+
*
|
|
142
|
+
* Supports:
|
|
143
|
+
* - Exact segments: "/about"
|
|
144
|
+
* - Param segments: "/user/:id"
|
|
145
|
+
* - Wildcard: "(.*)" matches everything
|
|
146
|
+
*/
|
|
147
|
+
function matchPath(pattern, path) {
|
|
148
|
+
if (pattern === "(.*)" || pattern === "*") return {};
|
|
149
|
+
const patternParts = pattern.split("/").filter(Boolean);
|
|
150
|
+
const pathParts = path.split("/").filter(Boolean);
|
|
151
|
+
const params = {};
|
|
152
|
+
for (let i = 0; i < patternParts.length; i++) {
|
|
153
|
+
const pp = patternParts[i];
|
|
154
|
+
const pt = pathParts[i];
|
|
155
|
+
if (pp.endsWith("*") && pp.startsWith(":")) {
|
|
156
|
+
const paramName = pp.slice(1, -1);
|
|
157
|
+
params[paramName] = pathParts.slice(i).map(decodeURIComponent).join("/");
|
|
158
|
+
return params;
|
|
159
|
+
}
|
|
160
|
+
if (pp.startsWith(":")) params[pp.slice(1)] = decodeURIComponent(pt);
|
|
161
|
+
else if (pp !== pt) return null;
|
|
162
|
+
}
|
|
163
|
+
if (patternParts.length !== pathParts.length) return null;
|
|
164
|
+
return params;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Check if a path starts with a route's prefix (for nested route matching).
|
|
168
|
+
* Returns the remaining path suffix, or null if no match.
|
|
169
|
+
*/
|
|
170
|
+
function matchPrefix(pattern, path) {
|
|
171
|
+
if (pattern === "(.*)" || pattern === "*") return {
|
|
172
|
+
params: {},
|
|
173
|
+
rest: path
|
|
174
|
+
};
|
|
175
|
+
const patternParts = pattern.split("/").filter(Boolean);
|
|
176
|
+
const pathParts = path.split("/").filter(Boolean);
|
|
177
|
+
if (pathParts.length < patternParts.length) return null;
|
|
178
|
+
const params = {};
|
|
179
|
+
for (let i = 0; i < patternParts.length; i++) {
|
|
180
|
+
const pp = patternParts[i];
|
|
181
|
+
const pt = pathParts[i];
|
|
182
|
+
if (pp.endsWith("*") && pp.startsWith(":")) {
|
|
183
|
+
const paramName = pp.slice(1, -1);
|
|
184
|
+
params[paramName] = pathParts.slice(i).map(decodeURIComponent).join("/");
|
|
185
|
+
return {
|
|
186
|
+
params,
|
|
187
|
+
rest: "/"
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
if (pp.startsWith(":")) params[pp.slice(1)] = decodeURIComponent(pt);
|
|
191
|
+
else if (pp !== pt) return null;
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
params,
|
|
195
|
+
rest: `/${pathParts.slice(patternParts.length).join("/")}`
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Resolve a raw path (including query string and hash) against the route tree.
|
|
200
|
+
* Handles nested routes recursively.
|
|
201
|
+
*/
|
|
202
|
+
function resolveRoute(rawPath, routes) {
|
|
203
|
+
const qIdx = rawPath.indexOf("?");
|
|
204
|
+
const pathAndHash = qIdx >= 0 ? rawPath.slice(0, qIdx) : rawPath;
|
|
205
|
+
const queryPart = qIdx >= 0 ? rawPath.slice(qIdx + 1) : "";
|
|
206
|
+
const hIdx = pathAndHash.indexOf("#");
|
|
207
|
+
const cleanPath = hIdx >= 0 ? pathAndHash.slice(0, hIdx) : pathAndHash;
|
|
208
|
+
const hash = hIdx >= 0 ? pathAndHash.slice(hIdx + 1) : "";
|
|
209
|
+
const query = parseQuery(queryPart);
|
|
210
|
+
const match = matchRoutes(cleanPath, routes, []);
|
|
211
|
+
if (match) return {
|
|
212
|
+
path: cleanPath,
|
|
213
|
+
params: match.params,
|
|
214
|
+
query,
|
|
215
|
+
hash,
|
|
216
|
+
matched: match.matched,
|
|
217
|
+
meta: mergeMeta(match.matched)
|
|
218
|
+
};
|
|
219
|
+
return {
|
|
220
|
+
path: cleanPath,
|
|
221
|
+
params: {},
|
|
222
|
+
query,
|
|
223
|
+
hash,
|
|
224
|
+
matched: [],
|
|
225
|
+
meta: {}
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
function matchRoutes(path, routes, parentMatched, parentParams = {}) {
|
|
229
|
+
for (const route of routes) {
|
|
230
|
+
const result = matchSingleRoute(path, route, parentMatched, parentParams);
|
|
231
|
+
if (result) return result;
|
|
232
|
+
}
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
function matchSingleRoute(path, route, parentMatched, parentParams) {
|
|
236
|
+
if (!route.children || route.children.length === 0) {
|
|
237
|
+
const params = matchPath(route.path, path);
|
|
238
|
+
if (params === null) return null;
|
|
239
|
+
return {
|
|
240
|
+
params: {
|
|
241
|
+
...parentParams,
|
|
242
|
+
...params
|
|
243
|
+
},
|
|
244
|
+
matched: [...parentMatched, route]
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
const prefix = matchPrefix(route.path, path);
|
|
248
|
+
if (prefix === null) return null;
|
|
249
|
+
const allParams = {
|
|
250
|
+
...parentParams,
|
|
251
|
+
...prefix.params
|
|
252
|
+
};
|
|
253
|
+
const matched = [...parentMatched, route];
|
|
254
|
+
const childMatch = matchRoutes(prefix.rest, route.children, matched, allParams);
|
|
255
|
+
if (childMatch) return childMatch;
|
|
256
|
+
const exactParams = matchPath(route.path, path);
|
|
257
|
+
if (exactParams === null) return null;
|
|
258
|
+
return {
|
|
259
|
+
params: {
|
|
260
|
+
...parentParams,
|
|
261
|
+
...exactParams
|
|
262
|
+
},
|
|
263
|
+
matched
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
/** Merge meta from matched routes (leaf takes precedence) */
|
|
267
|
+
function mergeMeta(matched) {
|
|
268
|
+
const meta = {};
|
|
269
|
+
for (const record of matched) if (record.meta) Object.assign(meta, record.meta);
|
|
270
|
+
return meta;
|
|
271
|
+
}
|
|
272
|
+
/** Build a path string from a named route's pattern and params */
|
|
273
|
+
function buildPath(pattern, params) {
|
|
274
|
+
return pattern.replace(/:([^/]+)\*?/g, (match, key) => {
|
|
275
|
+
const val = params[key] ?? "";
|
|
276
|
+
if (match.endsWith("*")) return val.split("/").map(encodeURIComponent).join("/");
|
|
277
|
+
return encodeURIComponent(val);
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
/** Find a route record by name (recursive, O(n)). Prefer buildNameIndex for repeated lookups. */
|
|
281
|
+
function findRouteByName(name, routes) {
|
|
282
|
+
for (const route of routes) {
|
|
283
|
+
if (route.name === name) return route;
|
|
284
|
+
if (route.children) {
|
|
285
|
+
const found = findRouteByName(name, route.children);
|
|
286
|
+
if (found) return found;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Pre-build a name → RouteRecord index from a route tree for O(1) named navigation.
|
|
293
|
+
* Called once at router creation time; avoids O(n) depth-first search per push({ name }).
|
|
294
|
+
*/
|
|
295
|
+
function buildNameIndex(routes) {
|
|
296
|
+
const index = /* @__PURE__ */ new Map();
|
|
297
|
+
function walk(list) {
|
|
298
|
+
for (const route of list) {
|
|
299
|
+
if (route.name) index.set(route.name, route);
|
|
300
|
+
if (route.children) walk(route.children);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
walk(routes);
|
|
304
|
+
return index;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
//#endregion
|
|
308
|
+
//#region src/scroll.ts
|
|
309
|
+
/**
|
|
310
|
+
* Scroll restoration manager.
|
|
311
|
+
*
|
|
312
|
+
* Saves scroll position before each navigation and restores it when
|
|
313
|
+
* navigating back to a previously visited path.
|
|
314
|
+
*/
|
|
315
|
+
var ScrollManager = class {
|
|
316
|
+
_positions = /* @__PURE__ */ new Map();
|
|
317
|
+
_behavior;
|
|
318
|
+
constructor(behavior = "top") {
|
|
319
|
+
this._behavior = behavior;
|
|
320
|
+
}
|
|
321
|
+
/** Call before navigating away — saves current scroll position for `fromPath` */
|
|
322
|
+
save(fromPath) {
|
|
323
|
+
this._positions.set(fromPath, window.scrollY);
|
|
324
|
+
}
|
|
325
|
+
/** Call after navigation is committed — applies scroll behavior */
|
|
326
|
+
restore(to, from) {
|
|
327
|
+
const behavior = to.meta.scrollBehavior ?? this._behavior ?? "top";
|
|
328
|
+
if (typeof behavior === "function") {
|
|
329
|
+
const result = behavior(to, from, this._positions.get(to.path) ?? null);
|
|
330
|
+
this._applyResult(result, to.path);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
this._applyResult(behavior, to.path);
|
|
334
|
+
}
|
|
335
|
+
_applyResult(result, toPath) {
|
|
336
|
+
if (result === "none") return;
|
|
337
|
+
if (result === "top" || result === void 0) {
|
|
338
|
+
window.scrollTo({
|
|
339
|
+
top: 0,
|
|
340
|
+
behavior: "instant"
|
|
341
|
+
});
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
if (result === "restore") {
|
|
345
|
+
const saved = this._positions.get(toPath) ?? 0;
|
|
346
|
+
window.scrollTo({
|
|
347
|
+
top: saved,
|
|
348
|
+
behavior: "instant"
|
|
349
|
+
});
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
window.scrollTo({
|
|
353
|
+
top: result,
|
|
354
|
+
behavior: "instant"
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
getSavedPosition(path) {
|
|
358
|
+
return this._positions.get(path) ?? null;
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
//#endregion
|
|
363
|
+
//#region src/types.ts
|
|
364
|
+
const LAZY_SYMBOL = Symbol("pyreon.lazy");
|
|
365
|
+
function lazy(loader, options) {
|
|
366
|
+
return {
|
|
367
|
+
[LAZY_SYMBOL]: true,
|
|
368
|
+
loader,
|
|
369
|
+
...options?.loading ? { loadingComponent: options.loading } : {},
|
|
370
|
+
...options?.error ? { errorComponent: options.error } : {}
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
function isLazy(c) {
|
|
374
|
+
return typeof c === "object" && c !== null && c[LAZY_SYMBOL] === true;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
//#endregion
|
|
378
|
+
//#region src/router.ts
|
|
379
|
+
const _isBrowser = typeof window !== "undefined";
|
|
380
|
+
const RouterContext = createContext(null);
|
|
381
|
+
let _activeRouter = null;
|
|
382
|
+
function setActiveRouter(router) {
|
|
383
|
+
if (router) router._viewDepth = 0;
|
|
384
|
+
_activeRouter = router;
|
|
385
|
+
}
|
|
386
|
+
function useRouter() {
|
|
387
|
+
const router = useContext(RouterContext) ?? _activeRouter;
|
|
388
|
+
if (!router) throw new Error("[pyreon-router] No router installed. Wrap your app in <RouterProvider router={router}>.");
|
|
389
|
+
return router;
|
|
390
|
+
}
|
|
391
|
+
function useRoute() {
|
|
392
|
+
const router = useContext(RouterContext) ?? _activeRouter;
|
|
393
|
+
if (!router) throw new Error("[pyreon-router] No router installed. Wrap your app in <RouterProvider router={router}>.");
|
|
394
|
+
return router.currentRoute;
|
|
395
|
+
}
|
|
396
|
+
function createRouter(options) {
|
|
397
|
+
const opts = Array.isArray(options) ? { routes: options } : options;
|
|
398
|
+
const { routes, mode = "hash", scrollBehavior, onError, maxCacheSize = 100 } = opts;
|
|
399
|
+
const nameIndex = buildNameIndex(routes);
|
|
400
|
+
const guards = [];
|
|
401
|
+
const afterHooks = [];
|
|
402
|
+
const scrollManager = new ScrollManager(scrollBehavior);
|
|
403
|
+
let _navGen = 0;
|
|
404
|
+
const getInitialLocation = () => {
|
|
405
|
+
if (opts.url) return opts.url;
|
|
406
|
+
if (!_isBrowser) return "/";
|
|
407
|
+
if (mode === "history") return window.location.pathname + window.location.search;
|
|
408
|
+
const hash = window.location.hash;
|
|
409
|
+
return hash.startsWith("#") ? hash.slice(1) || "/" : "/";
|
|
410
|
+
};
|
|
411
|
+
const getCurrentLocation = () => {
|
|
412
|
+
if (!_isBrowser) return currentPath();
|
|
413
|
+
if (mode === "history") return window.location.pathname + window.location.search;
|
|
414
|
+
const hash = window.location.hash;
|
|
415
|
+
return hash.startsWith("#") ? hash.slice(1) || "/" : "/";
|
|
416
|
+
};
|
|
417
|
+
const currentPath = signal(getInitialLocation());
|
|
418
|
+
const currentRoute = computed(() => resolveRoute(currentPath(), routes));
|
|
419
|
+
let _popstateHandler = null;
|
|
420
|
+
let _hashchangeHandler = null;
|
|
421
|
+
if (_isBrowser) if (mode === "history") {
|
|
422
|
+
_popstateHandler = () => currentPath.set(getCurrentLocation());
|
|
423
|
+
window.addEventListener("popstate", _popstateHandler);
|
|
424
|
+
} else {
|
|
425
|
+
_hashchangeHandler = () => currentPath.set(getCurrentLocation());
|
|
426
|
+
window.addEventListener("hashchange", _hashchangeHandler);
|
|
427
|
+
}
|
|
428
|
+
const componentCache = /* @__PURE__ */ new Map();
|
|
429
|
+
const loadingSignal = signal(0);
|
|
430
|
+
async function evaluateGuard(guard, to, from, gen) {
|
|
431
|
+
const result = await runGuard(guard, to, from);
|
|
432
|
+
if (gen !== _navGen) return { action: "cancel" };
|
|
433
|
+
if (result === false) return { action: "cancel" };
|
|
434
|
+
if (typeof result === "string") return {
|
|
435
|
+
action: "redirect",
|
|
436
|
+
target: result
|
|
437
|
+
};
|
|
438
|
+
return { action: "continue" };
|
|
439
|
+
}
|
|
440
|
+
async function runRouteGuards(records, guardKey, to, from, gen) {
|
|
441
|
+
for (const record of records) {
|
|
442
|
+
const raw = record[guardKey];
|
|
443
|
+
if (!raw) continue;
|
|
444
|
+
const routeGuards = Array.isArray(raw) ? raw : [raw];
|
|
445
|
+
for (const guard of routeGuards) {
|
|
446
|
+
const outcome = await evaluateGuard(guard, to, from, gen);
|
|
447
|
+
if (outcome.action !== "continue") return outcome;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
return { action: "continue" };
|
|
451
|
+
}
|
|
452
|
+
async function runGlobalGuards(globalGuards, to, from, gen) {
|
|
453
|
+
for (const guard of globalGuards) {
|
|
454
|
+
const outcome = await evaluateGuard(guard, to, from, gen);
|
|
455
|
+
if (outcome.action !== "continue") return outcome;
|
|
456
|
+
}
|
|
457
|
+
return { action: "continue" };
|
|
458
|
+
}
|
|
459
|
+
function processLoaderResult(result, record, ac, to) {
|
|
460
|
+
if (result.status === "fulfilled") {
|
|
461
|
+
router._loaderData.set(record, result.value);
|
|
462
|
+
return true;
|
|
463
|
+
}
|
|
464
|
+
if (ac.signal.aborted) return true;
|
|
465
|
+
if (router._onError) {
|
|
466
|
+
if (router._onError(result.reason, to) === false) return false;
|
|
467
|
+
}
|
|
468
|
+
router._loaderData.set(record, void 0);
|
|
469
|
+
return true;
|
|
470
|
+
}
|
|
471
|
+
function syncBrowserUrl(path, replace) {
|
|
472
|
+
if (!_isBrowser) return;
|
|
473
|
+
const url = mode === "history" ? path : `#${path}`;
|
|
474
|
+
if (replace) window.history.replaceState(null, "", url);
|
|
475
|
+
else window.history.pushState(null, "", url);
|
|
476
|
+
}
|
|
477
|
+
function resolveRedirect(to) {
|
|
478
|
+
const leaf = to.matched[to.matched.length - 1];
|
|
479
|
+
if (!leaf?.redirect) return null;
|
|
480
|
+
return sanitizePath(typeof leaf.redirect === "function" ? leaf.redirect(to) : leaf.redirect);
|
|
481
|
+
}
|
|
482
|
+
async function runAllGuards(to, from, gen) {
|
|
483
|
+
const leaveOutcome = await runRouteGuards(from.matched, "beforeLeave", to, from, gen);
|
|
484
|
+
if (leaveOutcome.action !== "continue") return leaveOutcome;
|
|
485
|
+
const enterOutcome = await runRouteGuards(to.matched, "beforeEnter", to, from, gen);
|
|
486
|
+
if (enterOutcome.action !== "continue") return enterOutcome;
|
|
487
|
+
return runGlobalGuards(guards, to, from, gen);
|
|
488
|
+
}
|
|
489
|
+
async function runLoaders(to, gen, ac) {
|
|
490
|
+
const loadableRecords = to.matched.filter((r) => r.loader);
|
|
491
|
+
if (loadableRecords.length === 0) return true;
|
|
492
|
+
const loaderCtx = {
|
|
493
|
+
params: to.params,
|
|
494
|
+
query: to.query,
|
|
495
|
+
signal: ac.signal
|
|
496
|
+
};
|
|
497
|
+
const results = await Promise.allSettled(loadableRecords.map((r) => {
|
|
498
|
+
if (!r.loader) return Promise.resolve(void 0);
|
|
499
|
+
return r.loader(loaderCtx);
|
|
500
|
+
}));
|
|
501
|
+
if (gen !== _navGen) return false;
|
|
502
|
+
for (let i = 0; i < loadableRecords.length; i++) {
|
|
503
|
+
const result = results[i];
|
|
504
|
+
const record = loadableRecords[i];
|
|
505
|
+
if (!result || !record) continue;
|
|
506
|
+
if (!processLoaderResult(result, record, ac, to)) return false;
|
|
507
|
+
}
|
|
508
|
+
return true;
|
|
509
|
+
}
|
|
510
|
+
function commitNavigation(path, replace, to, from) {
|
|
511
|
+
scrollManager.save(from.path);
|
|
512
|
+
currentPath.set(path);
|
|
513
|
+
syncBrowserUrl(path, replace);
|
|
514
|
+
if (_isBrowser && to.meta.title) document.title = to.meta.title;
|
|
515
|
+
for (const record of router._loaderData.keys()) if (!to.matched.includes(record)) router._loaderData.delete(record);
|
|
516
|
+
for (const hook of afterHooks) try {
|
|
517
|
+
hook(to, from);
|
|
518
|
+
} catch (_err) {}
|
|
519
|
+
if (_isBrowser) queueMicrotask(() => scrollManager.restore(to, from));
|
|
520
|
+
}
|
|
521
|
+
async function navigate(path, replace, redirectDepth = 0) {
|
|
522
|
+
if (redirectDepth > 10) return;
|
|
523
|
+
const gen = ++_navGen;
|
|
524
|
+
loadingSignal.update((n) => n + 1);
|
|
525
|
+
const to = resolveRoute(path, routes);
|
|
526
|
+
const from = currentRoute();
|
|
527
|
+
const redirectTarget = resolveRedirect(to);
|
|
528
|
+
if (redirectTarget !== null) {
|
|
529
|
+
loadingSignal.update((n) => n - 1);
|
|
530
|
+
return navigate(redirectTarget, replace, redirectDepth + 1);
|
|
531
|
+
}
|
|
532
|
+
const guardOutcome = await runAllGuards(to, from, gen);
|
|
533
|
+
if (guardOutcome.action !== "continue") {
|
|
534
|
+
loadingSignal.update((n) => n - 1);
|
|
535
|
+
if (guardOutcome.action === "redirect") return navigate(sanitizePath(guardOutcome.target), replace, redirectDepth + 1);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
router._abortController?.abort();
|
|
539
|
+
const ac = new AbortController();
|
|
540
|
+
router._abortController = ac;
|
|
541
|
+
if (!await runLoaders(to, gen, ac)) {
|
|
542
|
+
loadingSignal.update((n) => n - 1);
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
commitNavigation(path, replace, to, from);
|
|
546
|
+
loadingSignal.update((n) => n - 1);
|
|
547
|
+
}
|
|
548
|
+
const router = {
|
|
549
|
+
routes,
|
|
550
|
+
mode,
|
|
551
|
+
currentRoute,
|
|
552
|
+
_currentPath: currentPath,
|
|
553
|
+
_currentRoute: currentRoute,
|
|
554
|
+
_componentCache: componentCache,
|
|
555
|
+
_loadingSignal: loadingSignal,
|
|
556
|
+
_scrollPositions: /* @__PURE__ */ new Map(),
|
|
557
|
+
_scrollBehavior: scrollBehavior,
|
|
558
|
+
_viewDepth: 0,
|
|
559
|
+
_erroredChunks: /* @__PURE__ */ new Set(),
|
|
560
|
+
_loaderData: /* @__PURE__ */ new Map(),
|
|
561
|
+
_abortController: null,
|
|
562
|
+
_onError: onError,
|
|
563
|
+
_maxCacheSize: maxCacheSize,
|
|
564
|
+
async push(location) {
|
|
565
|
+
if (typeof location === "string") return navigate(sanitizePath(location), false);
|
|
566
|
+
return navigate(resolveNamedPath(location.name, location.params ?? {}, location.query ?? {}, nameIndex), false);
|
|
567
|
+
},
|
|
568
|
+
async replace(path) {
|
|
569
|
+
return navigate(sanitizePath(path), true);
|
|
570
|
+
},
|
|
571
|
+
back() {
|
|
572
|
+
if (_isBrowser) window.history.back();
|
|
573
|
+
},
|
|
574
|
+
beforeEach(guard) {
|
|
575
|
+
guards.push(guard);
|
|
576
|
+
return () => {
|
|
577
|
+
const idx = guards.indexOf(guard);
|
|
578
|
+
if (idx >= 0) guards.splice(idx, 1);
|
|
579
|
+
};
|
|
580
|
+
},
|
|
581
|
+
afterEach(hook) {
|
|
582
|
+
afterHooks.push(hook);
|
|
583
|
+
return () => {
|
|
584
|
+
const idx = afterHooks.indexOf(hook);
|
|
585
|
+
if (idx >= 0) afterHooks.splice(idx, 1);
|
|
586
|
+
};
|
|
587
|
+
},
|
|
588
|
+
loading: () => loadingSignal() > 0,
|
|
589
|
+
destroy() {
|
|
590
|
+
if (_popstateHandler) {
|
|
591
|
+
window.removeEventListener("popstate", _popstateHandler);
|
|
592
|
+
_popstateHandler = null;
|
|
593
|
+
}
|
|
594
|
+
if (_hashchangeHandler) {
|
|
595
|
+
window.removeEventListener("hashchange", _hashchangeHandler);
|
|
596
|
+
_hashchangeHandler = null;
|
|
597
|
+
}
|
|
598
|
+
guards.length = 0;
|
|
599
|
+
afterHooks.length = 0;
|
|
600
|
+
componentCache.clear();
|
|
601
|
+
router._loaderData.clear();
|
|
602
|
+
router._abortController?.abort();
|
|
603
|
+
router._abortController = null;
|
|
604
|
+
},
|
|
605
|
+
_resolve: (rawPath) => resolveRoute(rawPath, routes)
|
|
606
|
+
};
|
|
607
|
+
return router;
|
|
608
|
+
}
|
|
609
|
+
async function runGuard(guard, to, from) {
|
|
610
|
+
try {
|
|
611
|
+
return await guard(to, from);
|
|
612
|
+
} catch (_err) {
|
|
613
|
+
return false;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
function resolveNamedPath(name, params, query, index) {
|
|
617
|
+
const record = index.get(name);
|
|
618
|
+
if (!record) return "/";
|
|
619
|
+
let path = buildPath(record.path, params);
|
|
620
|
+
const qs = Object.entries(query).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("&");
|
|
621
|
+
if (qs) path += `?${qs}`;
|
|
622
|
+
return path;
|
|
623
|
+
}
|
|
624
|
+
/** Block unsafe navigation targets: javascript/data/vbscript URIs and absolute URLs. */
|
|
625
|
+
function sanitizePath(path) {
|
|
626
|
+
const trimmed = path.trim();
|
|
627
|
+
if (/^(?:javascript|data|vbscript):/i.test(trimmed)) return "/";
|
|
628
|
+
if (/^\/\/|^https?:/i.test(trimmed)) return "/";
|
|
629
|
+
return path;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
//#endregion
|
|
633
|
+
//#region src/components.tsx
|
|
634
|
+
const _prefetched = /* @__PURE__ */ new WeakMap();
|
|
635
|
+
const RouterProvider = (props) => {
|
|
636
|
+
const router = props.router;
|
|
637
|
+
pushContext(new Map([[RouterContext.id, router]]));
|
|
638
|
+
onUnmount(() => {
|
|
639
|
+
popContext();
|
|
640
|
+
router.destroy();
|
|
641
|
+
setActiveRouter(null);
|
|
642
|
+
});
|
|
643
|
+
setActiveRouter(router);
|
|
644
|
+
return props.children ?? null;
|
|
645
|
+
};
|
|
646
|
+
/**
|
|
647
|
+
* Renders the matched route component at this nesting level.
|
|
648
|
+
*
|
|
649
|
+
* Nested layouts work by placing a second `<RouterView />` inside the layout
|
|
650
|
+
* component — it automatically renders the next level of the matched route.
|
|
651
|
+
*
|
|
652
|
+
* How depth tracking works:
|
|
653
|
+
* Pyreon components run once in depth-first tree order. Each `RouterView`
|
|
654
|
+
* captures `router._viewDepth` at setup time and immediately increments it,
|
|
655
|
+
* so sibling and child views get the correct index. `onUnmount` decrements
|
|
656
|
+
* the counter so dynamic route swaps work correctly.
|
|
657
|
+
*
|
|
658
|
+
* @example
|
|
659
|
+
* // Route config:
|
|
660
|
+
* { path: "/admin", component: AdminLayout, children: [
|
|
661
|
+
* { path: "users", component: AdminUsers },
|
|
662
|
+
* ]}
|
|
663
|
+
*
|
|
664
|
+
* // AdminLayout renders a nested RouterView:
|
|
665
|
+
* function AdminLayout() {
|
|
666
|
+
* return <div><Sidebar /><RouterView /></div>
|
|
667
|
+
* }
|
|
668
|
+
*/
|
|
669
|
+
const RouterView = (props) => {
|
|
670
|
+
const router = props.router ?? useContext(RouterContext);
|
|
671
|
+
if (!router) return null;
|
|
672
|
+
const depth = router._viewDepth;
|
|
673
|
+
router._viewDepth++;
|
|
674
|
+
onUnmount(() => {
|
|
675
|
+
router._viewDepth--;
|
|
676
|
+
});
|
|
677
|
+
const child = () => {
|
|
678
|
+
router._loadingSignal();
|
|
679
|
+
const route = router.currentRoute();
|
|
680
|
+
if (route.matched.length === 0) return null;
|
|
681
|
+
const record = route.matched[depth];
|
|
682
|
+
if (!record) return null;
|
|
683
|
+
const cached = router._componentCache.get(record);
|
|
684
|
+
if (cached) return renderWithLoader(router, record, cached, route);
|
|
685
|
+
const raw = record.component;
|
|
686
|
+
if (!isLazy(raw)) {
|
|
687
|
+
cacheSet(router, record, raw);
|
|
688
|
+
return renderWithLoader(router, record, raw, route);
|
|
689
|
+
}
|
|
690
|
+
return renderLazyRoute(router, record, raw);
|
|
691
|
+
};
|
|
692
|
+
return h("div", { "data-pyreon-router-view": true }, child);
|
|
693
|
+
};
|
|
694
|
+
const RouterLink = (props) => {
|
|
695
|
+
const router = useContext(RouterContext);
|
|
696
|
+
const prefetchMode = props.prefetch ?? "hover";
|
|
697
|
+
const handleClick = (e) => {
|
|
698
|
+
e.preventDefault();
|
|
699
|
+
if (!router) return;
|
|
700
|
+
if (props.replace) router.replace(props.to);
|
|
701
|
+
else router.push(props.to);
|
|
702
|
+
};
|
|
703
|
+
const handleMouseEnter = () => {
|
|
704
|
+
if (prefetchMode !== "hover" || !router) return;
|
|
705
|
+
prefetchRoute(router, props.to);
|
|
706
|
+
};
|
|
707
|
+
const href = router?.mode === "history" ? props.to : `#${props.to}`;
|
|
708
|
+
const activeClass = () => {
|
|
709
|
+
if (!router) return "";
|
|
710
|
+
const current = router.currentRoute().path;
|
|
711
|
+
const target = props.to;
|
|
712
|
+
const isExact = current === target;
|
|
713
|
+
const isActive = isExact || !props.exact && isSegmentPrefix(current, target);
|
|
714
|
+
const classes = [];
|
|
715
|
+
if (isActive) classes.push(props.activeClass ?? "router-link-active");
|
|
716
|
+
if (isExact) classes.push(props.exactActiveClass ?? "router-link-exact-active");
|
|
717
|
+
return classes.join(" ").trim();
|
|
718
|
+
};
|
|
719
|
+
const ref = createRef();
|
|
720
|
+
if (prefetchMode === "viewport" && router && typeof IntersectionObserver !== "undefined") {
|
|
721
|
+
const observer = new IntersectionObserver((entries) => {
|
|
722
|
+
for (const entry of entries) if (entry.isIntersecting) {
|
|
723
|
+
prefetchRoute(router, props.to);
|
|
724
|
+
observer.disconnect();
|
|
725
|
+
break;
|
|
726
|
+
}
|
|
727
|
+
});
|
|
728
|
+
queueMicrotask(() => {
|
|
729
|
+
observer.observe(ref.current);
|
|
730
|
+
});
|
|
731
|
+
onUnmount(() => observer.disconnect());
|
|
732
|
+
}
|
|
733
|
+
return h("a", {
|
|
734
|
+
ref,
|
|
735
|
+
href,
|
|
736
|
+
class: activeClass,
|
|
737
|
+
onClick: handleClick,
|
|
738
|
+
onMouseEnter: handleMouseEnter
|
|
739
|
+
}, props.children ?? props.to);
|
|
740
|
+
};
|
|
741
|
+
/** Prefetch loader data for a route (only once per router + path). */
|
|
742
|
+
function prefetchRoute(router, path) {
|
|
743
|
+
let set = _prefetched.get(router);
|
|
744
|
+
if (!set) {
|
|
745
|
+
set = /* @__PURE__ */ new Set();
|
|
746
|
+
_prefetched.set(router, set);
|
|
747
|
+
}
|
|
748
|
+
if (set.has(path)) return;
|
|
749
|
+
set.add(path);
|
|
750
|
+
prefetchLoaderData(router, path).catch(() => {
|
|
751
|
+
set?.delete(path);
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
function renderLazyRoute(router, record, raw) {
|
|
755
|
+
if (router._erroredChunks.has(record)) return raw.errorComponent ? h(raw.errorComponent, {}) : null;
|
|
756
|
+
const tryLoad = (attempt) => raw.loader().then((mod) => {
|
|
757
|
+
cacheSet(router, record, typeof mod === "function" ? mod : mod.default);
|
|
758
|
+
router._loadingSignal.update((n) => n + 1);
|
|
759
|
+
}).catch((err) => {
|
|
760
|
+
if (attempt < 3) return new Promise((res) => setTimeout(res, 500 * 2 ** attempt)).then(() => tryLoad(attempt + 1));
|
|
761
|
+
if (typeof window !== "undefined" && isStaleChunk(err)) {
|
|
762
|
+
window.location.reload();
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
router._erroredChunks.add(record);
|
|
766
|
+
router._loadingSignal.update((n) => n + 1);
|
|
767
|
+
});
|
|
768
|
+
tryLoad(0);
|
|
769
|
+
return raw.loadingComponent ? h(raw.loadingComponent, {}) : null;
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* Wraps the route component with a LoaderDataProvider so `useLoaderData()` works
|
|
773
|
+
* inside the component. If the record has no loader, renders the component directly.
|
|
774
|
+
*/
|
|
775
|
+
function renderWithLoader(router, record, Comp, route) {
|
|
776
|
+
const routeProps = {
|
|
777
|
+
params: route.params,
|
|
778
|
+
query: route.query,
|
|
779
|
+
meta: route.meta
|
|
780
|
+
};
|
|
781
|
+
if (!record.loader) return h(Comp, routeProps);
|
|
782
|
+
const data = router._loaderData.get(record);
|
|
783
|
+
if (data === void 0 && record.errorComponent) return h(record.errorComponent, routeProps);
|
|
784
|
+
return h(LoaderDataProvider, {
|
|
785
|
+
data,
|
|
786
|
+
children: h(Comp, routeProps)
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
/**
|
|
790
|
+
* Thin provider component that pushes LoaderDataContext before children mount.
|
|
791
|
+
* Uses Pyreon's context stack so useLoaderData() reads it during child setup.
|
|
792
|
+
*/
|
|
793
|
+
function LoaderDataProvider(props) {
|
|
794
|
+
pushContext(new Map([[LoaderDataContext.id, props.data]]));
|
|
795
|
+
onUnmount(() => popContext());
|
|
796
|
+
return props.children;
|
|
797
|
+
}
|
|
798
|
+
/** Evict oldest cache entries when the component cache exceeds maxCacheSize. */
|
|
799
|
+
function cacheSet(router, record, comp) {
|
|
800
|
+
router._componentCache.set(record, comp);
|
|
801
|
+
if (router._componentCache.size > router._maxCacheSize) {
|
|
802
|
+
const oldest = router._componentCache.keys().next().value;
|
|
803
|
+
router._componentCache.delete(oldest);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
/**
|
|
807
|
+
* Segment-aware prefix check for active link matching.
|
|
808
|
+
* `/admin` is a prefix of `/admin/users` but NOT of `/admin-panel`.
|
|
809
|
+
*/
|
|
810
|
+
function isSegmentPrefix(current, target) {
|
|
811
|
+
if (target === "/") return false;
|
|
812
|
+
const cs = current.split("/").filter(Boolean);
|
|
813
|
+
const ts = target.split("/").filter(Boolean);
|
|
814
|
+
if (ts.length > cs.length) return false;
|
|
815
|
+
return ts.every((seg, i) => seg === cs[i]);
|
|
816
|
+
}
|
|
817
|
+
/**
|
|
818
|
+
* Detect a stale chunk error — happens post-deploy when the browser requests
|
|
819
|
+
* a hashed filename that no longer exists on the server. Trigger a full reload
|
|
820
|
+
* so the user gets the new bundle instead of a broken loading state.
|
|
821
|
+
*/
|
|
822
|
+
function isStaleChunk(err) {
|
|
823
|
+
if (err instanceof TypeError && String(err.message).includes("Failed to fetch")) return true;
|
|
824
|
+
if (err instanceof SyntaxError) return true;
|
|
825
|
+
return false;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
//#endregion
|
|
829
|
+
export { RouterContext, RouterLink, RouterProvider, RouterView, buildPath, createRouter, findRouteByName, hydrateLoaderData, lazy, parseQuery, parseQueryMulti, prefetchLoaderData, resolveRoute, serializeLoaderData, stringifyQuery, useLoaderData, useRoute, useRouter };
|
|
830
|
+
//# sourceMappingURL=index.js.map
|