@pyreon/router 0.3.0 → 0.4.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/lib/analysis/index.js.html +1 -1
- package/lib/index.js +510 -115
- package/lib/index.js.map +1 -1
- package/lib/types/index.d.ts +512 -112
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index2.d.ts +119 -6
- package/lib/types/index2.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/components.tsx +2 -1
- package/src/index.ts +12 -1
- package/src/match.ts +490 -87
- package/src/router.ts +336 -25
- package/src/tests/router.test.ts +629 -2
- package/src/types.ts +79 -7
package/lib/index.js
CHANGED
|
@@ -135,69 +135,253 @@ function stringifyQuery(query) {
|
|
|
135
135
|
for (const [k, v] of Object.entries(query)) parts.push(v ? `${encodeURIComponent(k)}=${encodeURIComponent(v)}` : encodeURIComponent(k));
|
|
136
136
|
return parts.length ? `?${parts.join("&")}` : "";
|
|
137
137
|
}
|
|
138
|
+
/** WeakMap cache: compile each RouteRecord[] once */
|
|
139
|
+
const _compiledCache = /* @__PURE__ */ new WeakMap();
|
|
140
|
+
function compileSegment(raw) {
|
|
141
|
+
if (raw.endsWith("*") && raw.startsWith(":")) return {
|
|
142
|
+
raw,
|
|
143
|
+
isParam: true,
|
|
144
|
+
isSplat: true,
|
|
145
|
+
isOptional: false,
|
|
146
|
+
paramName: raw.slice(1, -1)
|
|
147
|
+
};
|
|
148
|
+
if (raw.endsWith("?") && raw.startsWith(":")) return {
|
|
149
|
+
raw,
|
|
150
|
+
isParam: true,
|
|
151
|
+
isSplat: false,
|
|
152
|
+
isOptional: true,
|
|
153
|
+
paramName: raw.slice(1, -1)
|
|
154
|
+
};
|
|
155
|
+
if (raw.startsWith(":")) return {
|
|
156
|
+
raw,
|
|
157
|
+
isParam: true,
|
|
158
|
+
isSplat: false,
|
|
159
|
+
isOptional: false,
|
|
160
|
+
paramName: raw.slice(1)
|
|
161
|
+
};
|
|
162
|
+
return {
|
|
163
|
+
raw,
|
|
164
|
+
isParam: false,
|
|
165
|
+
isSplat: false,
|
|
166
|
+
isOptional: false,
|
|
167
|
+
paramName: ""
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
function compileRoute(route) {
|
|
171
|
+
const pattern = route.path;
|
|
172
|
+
if (pattern === "(.*)" || pattern === "*") return {
|
|
173
|
+
route,
|
|
174
|
+
isWildcard: true,
|
|
175
|
+
segments: [],
|
|
176
|
+
segmentCount: 0,
|
|
177
|
+
isStatic: false,
|
|
178
|
+
staticPath: null,
|
|
179
|
+
children: null,
|
|
180
|
+
firstSegment: null
|
|
181
|
+
};
|
|
182
|
+
const segments = pattern.split("/").filter(Boolean).map(compileSegment);
|
|
183
|
+
const isStatic = segments.every((s) => !s.isParam);
|
|
184
|
+
const staticPath = isStatic ? `/${segments.map((s) => s.raw).join("/")}` : null;
|
|
185
|
+
const first = segments.length > 0 ? segments[0] : void 0;
|
|
186
|
+
const firstSegment = first && !first.isParam ? first.raw : null;
|
|
187
|
+
return {
|
|
188
|
+
route,
|
|
189
|
+
isWildcard: false,
|
|
190
|
+
segments,
|
|
191
|
+
segmentCount: segments.length,
|
|
192
|
+
isStatic,
|
|
193
|
+
staticPath,
|
|
194
|
+
children: null,
|
|
195
|
+
firstSegment
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
/** Expand alias paths into additional compiled entries sharing the original RouteRecord */
|
|
199
|
+
function expandAliases(r, c) {
|
|
200
|
+
if (!r.alias) return [];
|
|
201
|
+
return (Array.isArray(r.alias) ? r.alias : [r.alias]).map((aliasPath) => {
|
|
202
|
+
const { alias: _, ...withoutAlias } = r;
|
|
203
|
+
const ac = compileRoute({
|
|
204
|
+
...withoutAlias,
|
|
205
|
+
path: aliasPath
|
|
206
|
+
});
|
|
207
|
+
ac.children = c.children;
|
|
208
|
+
ac.route = r;
|
|
209
|
+
return ac;
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
function compileRoutes(routes) {
|
|
213
|
+
const cached = _compiledCache.get(routes);
|
|
214
|
+
if (cached) return cached;
|
|
215
|
+
const compiled = [];
|
|
216
|
+
for (const r of routes) {
|
|
217
|
+
const c = compileRoute(r);
|
|
218
|
+
if (r.children && r.children.length > 0) c.children = compileRoutes(r.children);
|
|
219
|
+
compiled.push(c);
|
|
220
|
+
compiled.push(...expandAliases(r, c));
|
|
221
|
+
}
|
|
222
|
+
_compiledCache.set(routes, compiled);
|
|
223
|
+
return compiled;
|
|
224
|
+
}
|
|
225
|
+
/** Extract first static segment from a segment list, or null if dynamic/empty */
|
|
226
|
+
function getFirstSegment(segments) {
|
|
227
|
+
const first = segments[0];
|
|
228
|
+
if (first && !first.isParam) return first.raw;
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
/** Build a FlattenedRoute from segments + metadata */
|
|
232
|
+
function makeFlatEntry(segments, chain, meta, isWildcard) {
|
|
233
|
+
const isStatic = !isWildcard && segments.every((s) => !s.isParam);
|
|
234
|
+
const hasOptional = segments.some((s) => s.isOptional);
|
|
235
|
+
let minSegs = segments.length;
|
|
236
|
+
if (hasOptional) while (minSegs > 0 && segments[minSegs - 1]?.isOptional) minSegs--;
|
|
237
|
+
return {
|
|
238
|
+
segments,
|
|
239
|
+
segmentCount: segments.length,
|
|
240
|
+
matchedChain: chain,
|
|
241
|
+
isStatic,
|
|
242
|
+
staticPath: isStatic ? `/${segments.map((s) => s.raw).join("/")}` : null,
|
|
243
|
+
meta,
|
|
244
|
+
firstSegment: getFirstSegment(segments),
|
|
245
|
+
hasSplat: segments.some((s) => s.isSplat),
|
|
246
|
+
isWildcard,
|
|
247
|
+
hasOptional,
|
|
248
|
+
minSegments: minSegs
|
|
249
|
+
};
|
|
250
|
+
}
|
|
138
251
|
/**
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
* Supports:
|
|
143
|
-
* - Exact segments: "/about"
|
|
144
|
-
* - Param segments: "/user/:id"
|
|
145
|
-
* - Wildcard: "(.*)" matches everything
|
|
252
|
+
* Flatten nested routes into leaf entries with pre-joined segments.
|
|
253
|
+
* This eliminates recursion during matching for the common case.
|
|
146
254
|
*/
|
|
147
|
-
function
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
255
|
+
function flattenRoutes(compiled) {
|
|
256
|
+
const result = [];
|
|
257
|
+
flattenWalk(result, compiled, [], [], {});
|
|
258
|
+
return result;
|
|
259
|
+
}
|
|
260
|
+
function flattenWalk(result, routes, parentSegments, parentChain, parentMeta) {
|
|
261
|
+
for (const c of routes) flattenOne(result, c, parentSegments, [...parentChain, c.route], c.route.meta ? {
|
|
262
|
+
...parentMeta,
|
|
263
|
+
...c.route.meta
|
|
264
|
+
} : { ...parentMeta });
|
|
265
|
+
}
|
|
266
|
+
function flattenOne(result, c, parentSegments, chain, meta) {
|
|
267
|
+
if (c.isWildcard) {
|
|
268
|
+
result.push(makeFlatEntry(parentSegments, chain, meta, true));
|
|
269
|
+
if (c.children && c.children.length > 0) flattenWalk(result, c.children, parentSegments, chain, meta);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
const joined = [...parentSegments, ...c.segments];
|
|
273
|
+
if (c.children && c.children.length > 0) flattenWalk(result, c.children, joined, chain, meta);
|
|
274
|
+
result.push(makeFlatEntry(joined, chain, meta, false));
|
|
275
|
+
}
|
|
276
|
+
const _indexCache = /* @__PURE__ */ new WeakMap();
|
|
277
|
+
/** Classify a single flattened route into the appropriate index bucket */
|
|
278
|
+
function indexFlatRoute(f, staticMap, segmentMap, dynamicFirst, wildcards) {
|
|
279
|
+
if (f.isStatic && f.staticPath && !staticMap.has(f.staticPath)) staticMap.set(f.staticPath, f);
|
|
280
|
+
if (f.isWildcard) {
|
|
281
|
+
wildcards.push(f);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (f.segmentCount === 0) return;
|
|
285
|
+
if (f.firstSegment) {
|
|
286
|
+
let bucket = segmentMap.get(f.firstSegment);
|
|
287
|
+
if (!bucket) {
|
|
288
|
+
bucket = [];
|
|
289
|
+
segmentMap.set(f.firstSegment, bucket);
|
|
290
|
+
}
|
|
291
|
+
bucket.push(f);
|
|
292
|
+
} else dynamicFirst.push(f);
|
|
293
|
+
}
|
|
294
|
+
function buildRouteIndex(routes, compiled) {
|
|
295
|
+
const cached = _indexCache.get(routes);
|
|
296
|
+
if (cached) return cached;
|
|
297
|
+
const flattened = flattenRoutes(compiled);
|
|
298
|
+
const staticMap = /* @__PURE__ */ new Map();
|
|
299
|
+
const segmentMap = /* @__PURE__ */ new Map();
|
|
300
|
+
const dynamicFirst = [];
|
|
301
|
+
const wildcards = [];
|
|
302
|
+
for (const f of flattened) indexFlatRoute(f, staticMap, segmentMap, dynamicFirst, wildcards);
|
|
303
|
+
const index = {
|
|
304
|
+
staticMap,
|
|
305
|
+
segmentMap,
|
|
306
|
+
dynamicFirst,
|
|
307
|
+
wildcards
|
|
308
|
+
};
|
|
309
|
+
_indexCache.set(routes, index);
|
|
310
|
+
return index;
|
|
311
|
+
}
|
|
312
|
+
/** Split path into segments without allocating a filtered array */
|
|
313
|
+
function splitPath(path) {
|
|
314
|
+
if (path === "/") return [];
|
|
315
|
+
const start = path.charCodeAt(0) === 47 ? 1 : 0;
|
|
316
|
+
const end = path.length;
|
|
317
|
+
if (start >= end) return [];
|
|
318
|
+
const parts = [];
|
|
319
|
+
let segStart = start;
|
|
320
|
+
for (let i = start; i <= end; i++) if (i === end || path.charCodeAt(i) === 47) {
|
|
321
|
+
if (i > segStart) parts.push(path.substring(segStart, i));
|
|
322
|
+
segStart = i + 1;
|
|
323
|
+
}
|
|
324
|
+
return parts;
|
|
325
|
+
}
|
|
326
|
+
/** Decode only if the segment contains a `%` character */
|
|
327
|
+
function decodeSafe(s) {
|
|
328
|
+
return s.indexOf("%") >= 0 ? decodeURIComponent(s) : s;
|
|
329
|
+
}
|
|
330
|
+
/** Collect remaining path segments as a decoded splat value */
|
|
331
|
+
function captureSplat(pathParts, from, pathLen) {
|
|
332
|
+
const remaining = [];
|
|
333
|
+
for (let j = from; j < pathLen; j++) {
|
|
334
|
+
const p = pathParts[j];
|
|
335
|
+
if (p !== void 0) remaining.push(decodeSafe(p));
|
|
336
|
+
}
|
|
337
|
+
return remaining.join("/");
|
|
338
|
+
}
|
|
339
|
+
/** Check whether a flattened route's segment count is compatible with the path length */
|
|
340
|
+
function isSegmentCountCompatible(f, pathLen) {
|
|
341
|
+
if (f.segmentCount === pathLen) return true;
|
|
342
|
+
if (f.hasSplat && pathLen >= f.segmentCount) return true;
|
|
343
|
+
if (f.hasOptional && pathLen >= f.minSegments && pathLen <= f.segmentCount) return true;
|
|
344
|
+
return false;
|
|
345
|
+
}
|
|
346
|
+
/** Try to match a flattened route against path parts */
|
|
347
|
+
function matchFlattened(f, pathParts, pathLen) {
|
|
348
|
+
if (!isSegmentCountCompatible(f, pathLen)) return null;
|
|
151
349
|
const params = {};
|
|
152
|
-
|
|
153
|
-
|
|
350
|
+
const segments = f.segments;
|
|
351
|
+
const count = f.segmentCount;
|
|
352
|
+
for (let i = 0; i < count; i++) {
|
|
353
|
+
const seg = segments[i];
|
|
154
354
|
const pt = pathParts[i];
|
|
155
|
-
if (
|
|
156
|
-
|
|
157
|
-
params[paramName] = pathParts
|
|
355
|
+
if (!seg) return null;
|
|
356
|
+
if (seg.isSplat) {
|
|
357
|
+
params[seg.paramName] = captureSplat(pathParts, i, pathLen);
|
|
158
358
|
return params;
|
|
159
359
|
}
|
|
160
|
-
if (
|
|
161
|
-
|
|
360
|
+
if (pt === void 0) {
|
|
361
|
+
if (!seg.isOptional) return null;
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
if (seg.isParam) params[seg.paramName] = decodeSafe(pt);
|
|
365
|
+
else if (seg.raw !== pt) return null;
|
|
162
366
|
}
|
|
163
|
-
if (patternParts.length !== pathParts.length) return null;
|
|
164
367
|
return params;
|
|
165
368
|
}
|
|
166
|
-
/**
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
params
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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;
|
|
369
|
+
/** Search a list of flattened candidates for a match */
|
|
370
|
+
function searchCandidates(candidates, pathParts, pathLen) {
|
|
371
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
372
|
+
const f = candidates[i];
|
|
373
|
+
if (!f) continue;
|
|
374
|
+
const params = matchFlattened(f, pathParts, pathLen);
|
|
375
|
+
if (params) return {
|
|
376
|
+
params,
|
|
377
|
+
matched: f.matchedChain
|
|
378
|
+
};
|
|
192
379
|
}
|
|
193
|
-
return
|
|
194
|
-
params,
|
|
195
|
-
rest: `/${pathParts.slice(patternParts.length).join("/")}`
|
|
196
|
-
};
|
|
380
|
+
return null;
|
|
197
381
|
}
|
|
198
382
|
/**
|
|
199
383
|
* Resolve a raw path (including query string and hash) against the route tree.
|
|
200
|
-
*
|
|
384
|
+
* Uses flattened index for O(1) static lookup and first-segment dispatch.
|
|
201
385
|
*/
|
|
202
386
|
function resolveRoute(rawPath, routes) {
|
|
203
387
|
const qIdx = rawPath.indexOf("?");
|
|
@@ -207,14 +391,50 @@ function resolveRoute(rawPath, routes) {
|
|
|
207
391
|
const cleanPath = hIdx >= 0 ? pathAndHash.slice(0, hIdx) : pathAndHash;
|
|
208
392
|
const hash = hIdx >= 0 ? pathAndHash.slice(hIdx + 1) : "";
|
|
209
393
|
const query = parseQuery(queryPart);
|
|
210
|
-
const
|
|
211
|
-
|
|
394
|
+
const index = buildRouteIndex(routes, compileRoutes(routes));
|
|
395
|
+
const staticMatch = index.staticMap.get(cleanPath);
|
|
396
|
+
if (staticMatch) return {
|
|
397
|
+
path: cleanPath,
|
|
398
|
+
params: {},
|
|
399
|
+
query,
|
|
400
|
+
hash,
|
|
401
|
+
matched: staticMatch.matchedChain,
|
|
402
|
+
meta: staticMatch.meta
|
|
403
|
+
};
|
|
404
|
+
const pathParts = splitPath(cleanPath);
|
|
405
|
+
const pathLen = pathParts.length;
|
|
406
|
+
if (pathLen > 0) {
|
|
407
|
+
const first = pathParts[0];
|
|
408
|
+
const bucket = index.segmentMap.get(first);
|
|
409
|
+
if (bucket) {
|
|
410
|
+
const match = searchCandidates(bucket, pathParts, pathLen);
|
|
411
|
+
if (match) return {
|
|
412
|
+
path: cleanPath,
|
|
413
|
+
params: match.params,
|
|
414
|
+
query,
|
|
415
|
+
hash,
|
|
416
|
+
matched: match.matched,
|
|
417
|
+
meta: mergeMeta(match.matched)
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
const dynMatch = searchCandidates(index.dynamicFirst, pathParts, pathLen);
|
|
422
|
+
if (dynMatch) return {
|
|
212
423
|
path: cleanPath,
|
|
213
|
-
params:
|
|
424
|
+
params: dynMatch.params,
|
|
425
|
+
query,
|
|
426
|
+
hash,
|
|
427
|
+
matched: dynMatch.matched,
|
|
428
|
+
meta: mergeMeta(dynMatch.matched)
|
|
429
|
+
};
|
|
430
|
+
const w = index.wildcards[0];
|
|
431
|
+
if (w) return {
|
|
432
|
+
path: cleanPath,
|
|
433
|
+
params: {},
|
|
214
434
|
query,
|
|
215
435
|
hash,
|
|
216
|
-
matched:
|
|
217
|
-
meta:
|
|
436
|
+
matched: w.matchedChain,
|
|
437
|
+
meta: w.meta
|
|
218
438
|
};
|
|
219
439
|
return {
|
|
220
440
|
path: cleanPath,
|
|
@@ -225,44 +445,6 @@ function resolveRoute(rawPath, routes) {
|
|
|
225
445
|
meta: {}
|
|
226
446
|
};
|
|
227
447
|
}
|
|
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
448
|
/** Merge meta from matched routes (leaf takes precedence) */
|
|
267
449
|
function mergeMeta(matched) {
|
|
268
450
|
const meta = {};
|
|
@@ -271,7 +453,11 @@ function mergeMeta(matched) {
|
|
|
271
453
|
}
|
|
272
454
|
/** Build a path string from a named route's pattern and params */
|
|
273
455
|
function buildPath(pattern, params) {
|
|
274
|
-
return pattern.replace(
|
|
456
|
+
return pattern.replace(/\/:([^/]+)\?/g, (_match, key) => {
|
|
457
|
+
const val = params[key];
|
|
458
|
+
if (!val) return "";
|
|
459
|
+
return `/${encodeURIComponent(val)}`;
|
|
460
|
+
}).replace(/:([^/]+)\*?/g, (match, key) => {
|
|
275
461
|
const val = params[key] ?? "";
|
|
276
462
|
if (match.endsWith("*")) return val.split("/").map(encodeURIComponent).join("/");
|
|
277
463
|
return encodeURIComponent(val);
|
|
@@ -393,28 +579,134 @@ function useRoute() {
|
|
|
393
579
|
if (!router) throw new Error("[pyreon-router] No router installed. Wrap your app in <RouterProvider router={router}>.");
|
|
394
580
|
return router.currentRoute;
|
|
395
581
|
}
|
|
582
|
+
/**
|
|
583
|
+
* In-component guard: called before the component's route is left.
|
|
584
|
+
* Return `false` to cancel, a string to redirect, or `undefined`/`true` to proceed.
|
|
585
|
+
* Automatically removed on component unmount.
|
|
586
|
+
*
|
|
587
|
+
* @example
|
|
588
|
+
* onBeforeRouteLeave((to, from) => {
|
|
589
|
+
* if (hasUnsavedChanges()) return false
|
|
590
|
+
* })
|
|
591
|
+
*/
|
|
592
|
+
function onBeforeRouteLeave(guard) {
|
|
593
|
+
const router = useContext(RouterContext) ?? _activeRouter;
|
|
594
|
+
if (!router) throw new Error("[pyreon-router] No router installed. Wrap your app in <RouterProvider router={router}>.");
|
|
595
|
+
const currentMatched = router.currentRoute().matched;
|
|
596
|
+
const wrappedGuard = (to, from) => {
|
|
597
|
+
if (!from.matched.some((r) => currentMatched.includes(r))) return void 0;
|
|
598
|
+
return guard(to, from);
|
|
599
|
+
};
|
|
600
|
+
const remove = router.beforeEach(wrappedGuard);
|
|
601
|
+
onUnmount(() => remove());
|
|
602
|
+
return remove;
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* In-component guard: called when the route changes but the component is reused
|
|
606
|
+
* (e.g. `/user/1` → `/user/2`). Useful for reacting to param changes.
|
|
607
|
+
* Automatically removed on component unmount.
|
|
608
|
+
*
|
|
609
|
+
* @example
|
|
610
|
+
* onBeforeRouteUpdate((to, from) => {
|
|
611
|
+
* if (!isValidId(to.params.id)) return false
|
|
612
|
+
* })
|
|
613
|
+
*/
|
|
614
|
+
function onBeforeRouteUpdate(guard) {
|
|
615
|
+
const router = useContext(RouterContext) ?? _activeRouter;
|
|
616
|
+
if (!router) throw new Error("[pyreon-router] No router installed. Wrap your app in <RouterProvider router={router}>.");
|
|
617
|
+
const currentMatched = router.currentRoute().matched;
|
|
618
|
+
const wrappedGuard = (to, from) => {
|
|
619
|
+
if (!to.matched.some((r) => currentMatched.includes(r))) return void 0;
|
|
620
|
+
return guard(to, from);
|
|
621
|
+
};
|
|
622
|
+
const remove = router.beforeEach(wrappedGuard);
|
|
623
|
+
onUnmount(() => remove());
|
|
624
|
+
return remove;
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* Register a navigation blocker. The `fn` callback is called before each
|
|
628
|
+
* navigation — return `true` (or resolve to `true`) to block it.
|
|
629
|
+
*
|
|
630
|
+
* Automatically removed on component unmount if called during component setup.
|
|
631
|
+
* Also installs a `beforeunload` handler so the browser shows a confirmation
|
|
632
|
+
* dialog when the user tries to close the tab while a blocker is active.
|
|
633
|
+
*
|
|
634
|
+
* @example
|
|
635
|
+
* const blocker = useBlocker((to, from) => {
|
|
636
|
+
* return hasUnsavedChanges() && !confirm("Discard changes?")
|
|
637
|
+
* })
|
|
638
|
+
* // later: blocker.remove()
|
|
639
|
+
*/
|
|
640
|
+
function useBlocker(fn) {
|
|
641
|
+
const router = useContext(RouterContext) ?? _activeRouter;
|
|
642
|
+
if (!router) throw new Error("[pyreon-router] No router installed. Wrap your app in <RouterProvider router={router}>.");
|
|
643
|
+
router._blockers.add(fn);
|
|
644
|
+
const beforeUnloadHandler = _isBrowser ? (e) => {
|
|
645
|
+
e.preventDefault();
|
|
646
|
+
} : null;
|
|
647
|
+
if (beforeUnloadHandler) window.addEventListener("beforeunload", beforeUnloadHandler);
|
|
648
|
+
const remove = () => {
|
|
649
|
+
router._blockers.delete(fn);
|
|
650
|
+
if (beforeUnloadHandler) window.removeEventListener("beforeunload", beforeUnloadHandler);
|
|
651
|
+
};
|
|
652
|
+
onUnmount(() => remove());
|
|
653
|
+
return { remove };
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* Reactive read/write access to the current route's query parameters.
|
|
657
|
+
*
|
|
658
|
+
* Returns `[get, set]` where `get` is a reactive signal producing the merged
|
|
659
|
+
* query object and `set` navigates to the current path with updated params.
|
|
660
|
+
*
|
|
661
|
+
* @example
|
|
662
|
+
* const [params, setParams] = useSearchParams({ page: "1", sort: "name" })
|
|
663
|
+
* params().page // "1" if not in URL
|
|
664
|
+
* setParams({ page: "2" }) // navigates to ?page=2&sort=name
|
|
665
|
+
*/
|
|
666
|
+
function useSearchParams(defaults) {
|
|
667
|
+
const router = useContext(RouterContext) ?? _activeRouter;
|
|
668
|
+
if (!router) throw new Error("[pyreon-router] No router installed. Wrap your app in <RouterProvider router={router}>.");
|
|
669
|
+
const get = () => {
|
|
670
|
+
const query = router.currentRoute().query;
|
|
671
|
+
if (!defaults) return query;
|
|
672
|
+
return {
|
|
673
|
+
...defaults,
|
|
674
|
+
...query
|
|
675
|
+
};
|
|
676
|
+
};
|
|
677
|
+
const set = (updates) => {
|
|
678
|
+
const merged = {
|
|
679
|
+
...get(),
|
|
680
|
+
...updates
|
|
681
|
+
};
|
|
682
|
+
const path = router.currentRoute().path + stringifyQuery(merged);
|
|
683
|
+
return router.replace(path);
|
|
684
|
+
};
|
|
685
|
+
return [get, set];
|
|
686
|
+
}
|
|
396
687
|
function createRouter(options) {
|
|
397
688
|
const opts = Array.isArray(options) ? { routes: options } : options;
|
|
398
|
-
const { routes, mode = "hash", scrollBehavior, onError, maxCacheSize = 100 } = opts;
|
|
689
|
+
const { routes, mode = "hash", scrollBehavior, onError, maxCacheSize = 100, trailingSlash = "strip" } = opts;
|
|
690
|
+
const base = mode === "history" ? normalizeBase(opts.base ?? "") : "";
|
|
399
691
|
const nameIndex = buildNameIndex(routes);
|
|
400
692
|
const guards = [];
|
|
401
693
|
const afterHooks = [];
|
|
402
694
|
const scrollManager = new ScrollManager(scrollBehavior);
|
|
403
695
|
let _navGen = 0;
|
|
404
696
|
const getInitialLocation = () => {
|
|
405
|
-
if (opts.url) return opts.url;
|
|
697
|
+
if (opts.url) return stripBase(opts.url, base);
|
|
406
698
|
if (!_isBrowser) return "/";
|
|
407
|
-
if (mode === "history") return window.location.pathname + window.location.search;
|
|
699
|
+
if (mode === "history") return stripBase(window.location.pathname, base) + window.location.search;
|
|
408
700
|
const hash = window.location.hash;
|
|
409
701
|
return hash.startsWith("#") ? hash.slice(1) || "/" : "/";
|
|
410
702
|
};
|
|
411
703
|
const getCurrentLocation = () => {
|
|
412
704
|
if (!_isBrowser) return currentPath();
|
|
413
|
-
if (mode === "history") return window.location.pathname + window.location.search;
|
|
705
|
+
if (mode === "history") return stripBase(window.location.pathname, base) + window.location.search;
|
|
414
706
|
const hash = window.location.hash;
|
|
415
707
|
return hash.startsWith("#") ? hash.slice(1) || "/" : "/";
|
|
416
708
|
};
|
|
417
|
-
const currentPath = signal(getInitialLocation());
|
|
709
|
+
const currentPath = signal(normalizeTrailingSlash(getInitialLocation(), trailingSlash));
|
|
418
710
|
const currentRoute = computed(() => resolveRoute(currentPath(), routes));
|
|
419
711
|
let _popstateHandler = null;
|
|
420
712
|
let _hashchangeHandler = null;
|
|
@@ -470,7 +762,7 @@ function createRouter(options) {
|
|
|
470
762
|
}
|
|
471
763
|
function syncBrowserUrl(path, replace) {
|
|
472
764
|
if (!_isBrowser) return;
|
|
473
|
-
const url = mode === "history" ? path : `#${path}`;
|
|
765
|
+
const url = mode === "history" ? `${base}${path}` : `#${path}`;
|
|
474
766
|
if (replace) window.history.replaceState(null, "", url);
|
|
475
767
|
else window.history.pushState(null, "", url);
|
|
476
768
|
}
|
|
@@ -486,27 +778,53 @@ function createRouter(options) {
|
|
|
486
778
|
if (enterOutcome.action !== "continue") return enterOutcome;
|
|
487
779
|
return runGlobalGuards(guards, to, from, gen);
|
|
488
780
|
}
|
|
489
|
-
async function
|
|
490
|
-
const loadableRecords = to.matched.filter((r) => r.loader);
|
|
491
|
-
if (loadableRecords.length === 0) return true;
|
|
781
|
+
async function runBlockingLoaders(records, to, gen, ac) {
|
|
492
782
|
const loaderCtx = {
|
|
493
783
|
params: to.params,
|
|
494
784
|
query: to.query,
|
|
495
785
|
signal: ac.signal
|
|
496
786
|
};
|
|
497
|
-
const results = await Promise.allSettled(
|
|
498
|
-
if (!r.loader) return Promise.resolve(void 0);
|
|
499
|
-
return r.loader(loaderCtx);
|
|
500
|
-
}));
|
|
787
|
+
const results = await Promise.allSettled(records.map((r) => r.loader ? r.loader(loaderCtx) : Promise.resolve(void 0)));
|
|
501
788
|
if (gen !== _navGen) return false;
|
|
502
|
-
for (let i = 0; i <
|
|
789
|
+
for (let i = 0; i < records.length; i++) {
|
|
503
790
|
const result = results[i];
|
|
504
|
-
const record =
|
|
791
|
+
const record = records[i];
|
|
505
792
|
if (!result || !record) continue;
|
|
506
793
|
if (!processLoaderResult(result, record, ac, to)) return false;
|
|
507
794
|
}
|
|
508
795
|
return true;
|
|
509
796
|
}
|
|
797
|
+
/** Fire-and-forget background revalidation for stale-while-revalidate routes. */
|
|
798
|
+
function revalidateSwrLoaders(records, to, ac) {
|
|
799
|
+
const loaderCtx = {
|
|
800
|
+
params: to.params,
|
|
801
|
+
query: to.query,
|
|
802
|
+
signal: ac.signal
|
|
803
|
+
};
|
|
804
|
+
for (const r of records) {
|
|
805
|
+
if (!r.loader) continue;
|
|
806
|
+
r.loader(loaderCtx).then((data) => {
|
|
807
|
+
if (!ac.signal.aborted) {
|
|
808
|
+
router._loaderData.set(r, data);
|
|
809
|
+
loadingSignal.update((n) => n + 1);
|
|
810
|
+
loadingSignal.update((n) => n - 1);
|
|
811
|
+
}
|
|
812
|
+
}).catch(() => {});
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
async function runLoaders(to, gen, ac) {
|
|
816
|
+
const loadableRecords = to.matched.filter((r) => r.loader);
|
|
817
|
+
if (loadableRecords.length === 0) return true;
|
|
818
|
+
const blocking = [];
|
|
819
|
+
const swr = [];
|
|
820
|
+
for (const r of loadableRecords) if (r.staleWhileRevalidate && router._loaderData.has(r)) swr.push(r);
|
|
821
|
+
else blocking.push(r);
|
|
822
|
+
if (blocking.length > 0) {
|
|
823
|
+
if (!await runBlockingLoaders(blocking, to, gen, ac)) return false;
|
|
824
|
+
}
|
|
825
|
+
if (swr.length > 0) revalidateSwrLoaders(swr, to, ac);
|
|
826
|
+
return true;
|
|
827
|
+
}
|
|
510
828
|
function commitNavigation(path, replace, to, from) {
|
|
511
829
|
scrollManager.save(from.path);
|
|
512
830
|
currentPath.set(path);
|
|
@@ -518,8 +836,16 @@ function createRouter(options) {
|
|
|
518
836
|
} catch (_err) {}
|
|
519
837
|
if (_isBrowser) queueMicrotask(() => scrollManager.restore(to, from));
|
|
520
838
|
}
|
|
521
|
-
async function
|
|
839
|
+
async function checkBlockers(to, from, gen) {
|
|
840
|
+
for (const blocker of router._blockers) {
|
|
841
|
+
const blocked = await blocker(to, from);
|
|
842
|
+
if (gen !== _navGen || blocked) return "cancel";
|
|
843
|
+
}
|
|
844
|
+
return "continue";
|
|
845
|
+
}
|
|
846
|
+
async function navigate(rawPath, replace, redirectDepth = 0) {
|
|
522
847
|
if (redirectDepth > 10) return;
|
|
848
|
+
const path = normalizeTrailingSlash(rawPath, trailingSlash);
|
|
523
849
|
const gen = ++_navGen;
|
|
524
850
|
loadingSignal.update((n) => n + 1);
|
|
525
851
|
const to = resolveRoute(path, routes);
|
|
@@ -529,6 +855,10 @@ function createRouter(options) {
|
|
|
529
855
|
loadingSignal.update((n) => n - 1);
|
|
530
856
|
return navigate(redirectTarget, replace, redirectDepth + 1);
|
|
531
857
|
}
|
|
858
|
+
if (await checkBlockers(to, from, gen) !== "continue") {
|
|
859
|
+
loadingSignal.update((n) => n - 1);
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
532
862
|
const guardOutcome = await runAllGuards(to, from, gen);
|
|
533
863
|
if (guardOutcome.action !== "continue") {
|
|
534
864
|
loadingSignal.update((n) => n - 1);
|
|
@@ -545,9 +875,14 @@ function createRouter(options) {
|
|
|
545
875
|
commitNavigation(path, replace, to, from);
|
|
546
876
|
loadingSignal.update((n) => n - 1);
|
|
547
877
|
}
|
|
878
|
+
let _readyResolve = null;
|
|
879
|
+
const _readyPromise = new Promise((resolve) => {
|
|
880
|
+
_readyResolve = resolve;
|
|
881
|
+
});
|
|
548
882
|
const router = {
|
|
549
883
|
routes,
|
|
550
884
|
mode,
|
|
885
|
+
_base: base,
|
|
551
886
|
currentRoute,
|
|
552
887
|
_currentPath: currentPath,
|
|
553
888
|
_currentRoute: currentRoute,
|
|
@@ -559,18 +894,28 @@ function createRouter(options) {
|
|
|
559
894
|
_erroredChunks: /* @__PURE__ */ new Set(),
|
|
560
895
|
_loaderData: /* @__PURE__ */ new Map(),
|
|
561
896
|
_abortController: null,
|
|
897
|
+
_blockers: /* @__PURE__ */ new Set(),
|
|
898
|
+
_readyResolve,
|
|
899
|
+
_readyPromise,
|
|
562
900
|
_onError: onError,
|
|
563
901
|
_maxCacheSize: maxCacheSize,
|
|
564
902
|
async push(location) {
|
|
565
|
-
if (typeof location === "string") return navigate(sanitizePath(location), false);
|
|
903
|
+
if (typeof location === "string") return navigate(sanitizePath(resolveRelativePath(location, currentPath())), false);
|
|
566
904
|
return navigate(resolveNamedPath(location.name, location.params ?? {}, location.query ?? {}, nameIndex), false);
|
|
567
905
|
},
|
|
568
|
-
async replace(
|
|
569
|
-
return navigate(sanitizePath(
|
|
906
|
+
async replace(location) {
|
|
907
|
+
if (typeof location === "string") return navigate(sanitizePath(resolveRelativePath(location, currentPath())), true);
|
|
908
|
+
return navigate(resolveNamedPath(location.name, location.params ?? {}, location.query ?? {}, nameIndex), true);
|
|
570
909
|
},
|
|
571
910
|
back() {
|
|
572
911
|
if (_isBrowser) window.history.back();
|
|
573
912
|
},
|
|
913
|
+
forward() {
|
|
914
|
+
if (_isBrowser) window.history.forward();
|
|
915
|
+
},
|
|
916
|
+
go(delta) {
|
|
917
|
+
if (_isBrowser) window.history.go(delta);
|
|
918
|
+
},
|
|
574
919
|
beforeEach(guard) {
|
|
575
920
|
guards.push(guard);
|
|
576
921
|
return () => {
|
|
@@ -586,6 +931,9 @@ function createRouter(options) {
|
|
|
586
931
|
};
|
|
587
932
|
},
|
|
588
933
|
loading: () => loadingSignal() > 0,
|
|
934
|
+
isReady() {
|
|
935
|
+
return router._readyPromise;
|
|
936
|
+
},
|
|
589
937
|
destroy() {
|
|
590
938
|
if (_popstateHandler) {
|
|
591
939
|
window.removeEventListener("popstate", _popstateHandler);
|
|
@@ -597,6 +945,7 @@ function createRouter(options) {
|
|
|
597
945
|
}
|
|
598
946
|
guards.length = 0;
|
|
599
947
|
afterHooks.length = 0;
|
|
948
|
+
router._blockers.clear();
|
|
600
949
|
componentCache.clear();
|
|
601
950
|
router._loaderData.clear();
|
|
602
951
|
router._abortController?.abort();
|
|
@@ -604,6 +953,12 @@ function createRouter(options) {
|
|
|
604
953
|
},
|
|
605
954
|
_resolve: (rawPath) => resolveRoute(rawPath, routes)
|
|
606
955
|
};
|
|
956
|
+
queueMicrotask(() => {
|
|
957
|
+
if (router._readyResolve) {
|
|
958
|
+
router._readyResolve();
|
|
959
|
+
router._readyResolve = null;
|
|
960
|
+
}
|
|
961
|
+
});
|
|
607
962
|
return router;
|
|
608
963
|
}
|
|
609
964
|
async function runGuard(guard, to, from) {
|
|
@@ -621,6 +976,45 @@ function resolveNamedPath(name, params, query, index) {
|
|
|
621
976
|
if (qs) path += `?${qs}`;
|
|
622
977
|
return path;
|
|
623
978
|
}
|
|
979
|
+
/** Normalize a base path: ensure leading `/`, strip trailing `/`. */
|
|
980
|
+
function normalizeBase(raw) {
|
|
981
|
+
if (!raw) return "";
|
|
982
|
+
let b = raw;
|
|
983
|
+
if (!b.startsWith("/")) b = `/${b}`;
|
|
984
|
+
if (b.endsWith("/")) b = b.slice(0, -1);
|
|
985
|
+
return b;
|
|
986
|
+
}
|
|
987
|
+
/** Strip the base prefix from a full URL path. Returns the app-relative path. */
|
|
988
|
+
function stripBase(path, base) {
|
|
989
|
+
if (!base) return path;
|
|
990
|
+
if (path === base || path === `${base}/`) return "/";
|
|
991
|
+
if (path.startsWith(`${base}/`)) return path.slice(base.length);
|
|
992
|
+
return path;
|
|
993
|
+
}
|
|
994
|
+
/** Normalize trailing slash on a path according to the configured strategy. */
|
|
995
|
+
function normalizeTrailingSlash(path, strategy) {
|
|
996
|
+
if (strategy === "ignore" || path === "/") return path;
|
|
997
|
+
const qIdx = path.indexOf("?");
|
|
998
|
+
const hIdx = path.indexOf("#");
|
|
999
|
+
const endIdx = qIdx >= 0 ? qIdx : hIdx >= 0 ? hIdx : path.length;
|
|
1000
|
+
const pathPart = path.slice(0, endIdx);
|
|
1001
|
+
const suffix = path.slice(endIdx);
|
|
1002
|
+
if (strategy === "strip") return pathPart.length > 1 && pathPart.endsWith("/") ? pathPart.slice(0, -1) + suffix : path;
|
|
1003
|
+
return !pathPart.endsWith("/") ? `${pathPart}/${suffix}` : path;
|
|
1004
|
+
}
|
|
1005
|
+
/**
|
|
1006
|
+
* Resolve a relative path (starting with `.` or `..`) against the current path.
|
|
1007
|
+
* Non-relative paths are returned as-is.
|
|
1008
|
+
*/
|
|
1009
|
+
function resolveRelativePath(to, from) {
|
|
1010
|
+
if (!to.startsWith("./") && !to.startsWith("../") && to !== "." && to !== "..") return to;
|
|
1011
|
+
const fromSegments = from.split("/").filter(Boolean);
|
|
1012
|
+
fromSegments.pop();
|
|
1013
|
+
const toSegments = to.split("/").filter(Boolean);
|
|
1014
|
+
for (const seg of toSegments) if (seg === "..") fromSegments.pop();
|
|
1015
|
+
else if (seg !== ".") fromSegments.push(seg);
|
|
1016
|
+
return `/${fromSegments.join("/")}`;
|
|
1017
|
+
}
|
|
624
1018
|
/** Block unsafe navigation targets: javascript/data/vbscript URIs and absolute URLs. */
|
|
625
1019
|
function sanitizePath(path) {
|
|
626
1020
|
const trimmed = path.trim();
|
|
@@ -704,7 +1098,8 @@ const RouterLink = (props) => {
|
|
|
704
1098
|
if (prefetchMode !== "hover" || !router) return;
|
|
705
1099
|
prefetchRoute(router, props.to);
|
|
706
1100
|
};
|
|
707
|
-
const
|
|
1101
|
+
const inst = router;
|
|
1102
|
+
const href = inst?.mode === "history" ? `${inst._base}${props.to}` : `#${props.to}`;
|
|
708
1103
|
const activeClass = () => {
|
|
709
1104
|
if (!router) return "";
|
|
710
1105
|
const current = router.currentRoute().path;
|
|
@@ -826,5 +1221,5 @@ function isStaleChunk(err) {
|
|
|
826
1221
|
}
|
|
827
1222
|
|
|
828
1223
|
//#endregion
|
|
829
|
-
export { RouterContext, RouterLink, RouterProvider, RouterView, buildPath, createRouter, findRouteByName, hydrateLoaderData, lazy, parseQuery, parseQueryMulti, prefetchLoaderData, resolveRoute, serializeLoaderData, stringifyQuery, useLoaderData, useRoute, useRouter };
|
|
1224
|
+
export { RouterContext, RouterLink, RouterProvider, RouterView, buildPath, createRouter, findRouteByName, hydrateLoaderData, lazy, onBeforeRouteLeave, onBeforeRouteUpdate, parseQuery, parseQueryMulti, prefetchLoaderData, resolveRoute, serializeLoaderData, stringifyQuery, useBlocker, useLoaderData, useRoute, useRouter, useSearchParams };
|
|
830
1225
|
//# sourceMappingURL=index.js.map
|