@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/types/index.d.ts
CHANGED
|
@@ -133,67 +133,256 @@ function stringifyQuery(query) {
|
|
|
133
133
|
for (const [k, v] of Object.entries(query)) parts.push(v ? `${encodeURIComponent(k)}=${encodeURIComponent(v)}` : encodeURIComponent(k));
|
|
134
134
|
return parts.length ? `?${parts.join("&")}` : "";
|
|
135
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
|
+
};
|
|
251
|
+
}
|
|
136
252
|
/**
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
* Supports:
|
|
141
|
-
* - Exact segments: "/about"
|
|
142
|
-
* - Param segments: "/user/:id"
|
|
143
|
-
* - Wildcard: "(.*)" matches everything
|
|
253
|
+
* Flatten nested routes into leaf entries with pre-joined segments.
|
|
254
|
+
* This eliminates recursion during matching for the common case.
|
|
144
255
|
*/
|
|
145
|
-
function
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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;
|
|
149
351
|
const params = {};
|
|
150
|
-
|
|
151
|
-
|
|
352
|
+
const segments = f.segments;
|
|
353
|
+
const count = f.segmentCount;
|
|
354
|
+
for (let i = 0; i < count; i++) {
|
|
355
|
+
const seg = segments[i];
|
|
152
356
|
const pt = pathParts[i];
|
|
153
|
-
if (
|
|
154
|
-
|
|
155
|
-
params[paramName] = pathParts
|
|
357
|
+
if (!seg) return null;
|
|
358
|
+
if (seg.isSplat) {
|
|
359
|
+
params[seg.paramName] = captureSplat(pathParts, i, pathLen);
|
|
156
360
|
return params;
|
|
157
361
|
}
|
|
158
|
-
if (
|
|
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;
|
|
159
367
|
}
|
|
160
|
-
if (patternParts.length !== pathParts.length) return null;
|
|
161
368
|
return params;
|
|
162
369
|
}
|
|
163
|
-
/**
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
params
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
const pathParts = path.split("/").filter(Boolean);
|
|
174
|
-
if (pathParts.length < patternParts.length) return null;
|
|
175
|
-
const params = {};
|
|
176
|
-
for (let i = 0; i < patternParts.length; i++) {
|
|
177
|
-
const pp = patternParts[i];
|
|
178
|
-
const pt = pathParts[i];
|
|
179
|
-
if (pp.endsWith("*") && pp.startsWith(":")) {
|
|
180
|
-
const paramName = pp.slice(1, -1);
|
|
181
|
-
params[paramName] = pathParts.slice(i).map(decodeURIComponent).join("/");
|
|
182
|
-
return {
|
|
183
|
-
params,
|
|
184
|
-
rest: "/"
|
|
185
|
-
};
|
|
186
|
-
}
|
|
187
|
-
if (pp.startsWith(":")) params[pp.slice(1)] = decodeURIComponent(pt);else if (pp !== pt) return null;
|
|
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
|
+
};
|
|
188
380
|
}
|
|
189
|
-
return
|
|
190
|
-
params,
|
|
191
|
-
rest: `/${pathParts.slice(patternParts.length).join("/")}`
|
|
192
|
-
};
|
|
381
|
+
return null;
|
|
193
382
|
}
|
|
194
383
|
/**
|
|
195
384
|
* Resolve a raw path (including query string and hash) against the route tree.
|
|
196
|
-
*
|
|
385
|
+
* Uses flattened index for O(1) static lookup and first-segment dispatch.
|
|
197
386
|
*/
|
|
198
387
|
function resolveRoute(rawPath, routes) {
|
|
199
388
|
const qIdx = rawPath.indexOf("?");
|
|
@@ -203,14 +392,50 @@ function resolveRoute(rawPath, routes) {
|
|
|
203
392
|
const cleanPath = hIdx >= 0 ? pathAndHash.slice(0, hIdx) : pathAndHash;
|
|
204
393
|
const hash = hIdx >= 0 ? pathAndHash.slice(hIdx + 1) : "";
|
|
205
394
|
const query = parseQuery(queryPart);
|
|
206
|
-
const
|
|
207
|
-
|
|
395
|
+
const index = buildRouteIndex(routes, compileRoutes(routes));
|
|
396
|
+
const staticMatch = index.staticMap.get(cleanPath);
|
|
397
|
+
if (staticMatch) return {
|
|
208
398
|
path: cleanPath,
|
|
209
|
-
params:
|
|
399
|
+
params: {},
|
|
210
400
|
query,
|
|
211
401
|
hash,
|
|
212
|
-
matched:
|
|
213
|
-
meta:
|
|
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
|
|
214
439
|
};
|
|
215
440
|
return {
|
|
216
441
|
path: cleanPath,
|
|
@@ -221,44 +446,6 @@ function resolveRoute(rawPath, routes) {
|
|
|
221
446
|
meta: {}
|
|
222
447
|
};
|
|
223
448
|
}
|
|
224
|
-
function matchRoutes(path, routes, parentMatched, parentParams = {}) {
|
|
225
|
-
for (const route of routes) {
|
|
226
|
-
const result = matchSingleRoute(path, route, parentMatched, parentParams);
|
|
227
|
-
if (result) return result;
|
|
228
|
-
}
|
|
229
|
-
return null;
|
|
230
|
-
}
|
|
231
|
-
function matchSingleRoute(path, route, parentMatched, parentParams) {
|
|
232
|
-
if (!route.children || route.children.length === 0) {
|
|
233
|
-
const params = matchPath(route.path, path);
|
|
234
|
-
if (params === null) return null;
|
|
235
|
-
return {
|
|
236
|
-
params: {
|
|
237
|
-
...parentParams,
|
|
238
|
-
...params
|
|
239
|
-
},
|
|
240
|
-
matched: [...parentMatched, route]
|
|
241
|
-
};
|
|
242
|
-
}
|
|
243
|
-
const prefix = matchPrefix(route.path, path);
|
|
244
|
-
if (prefix === null) return null;
|
|
245
|
-
const allParams = {
|
|
246
|
-
...parentParams,
|
|
247
|
-
...prefix.params
|
|
248
|
-
};
|
|
249
|
-
const matched = [...parentMatched, route];
|
|
250
|
-
const childMatch = matchRoutes(prefix.rest, route.children, matched, allParams);
|
|
251
|
-
if (childMatch) return childMatch;
|
|
252
|
-
const exactParams = matchPath(route.path, path);
|
|
253
|
-
if (exactParams === null) return null;
|
|
254
|
-
return {
|
|
255
|
-
params: {
|
|
256
|
-
...parentParams,
|
|
257
|
-
...exactParams
|
|
258
|
-
},
|
|
259
|
-
matched
|
|
260
|
-
};
|
|
261
|
-
}
|
|
262
449
|
/** Merge meta from matched routes (leaf takes precedence) */
|
|
263
450
|
function mergeMeta(matched) {
|
|
264
451
|
const meta = {};
|
|
@@ -267,7 +454,11 @@ function mergeMeta(matched) {
|
|
|
267
454
|
}
|
|
268
455
|
/** Build a path string from a named route's pattern and params */
|
|
269
456
|
function buildPath(pattern, params) {
|
|
270
|
-
return pattern.replace(
|
|
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) => {
|
|
271
462
|
const val = params[key] ?? "";
|
|
272
463
|
if (match.endsWith("*")) return val.split("/").map(encodeURIComponent).join("/");
|
|
273
464
|
return encodeURIComponent(val);
|
|
@@ -342,6 +533,113 @@ function useRoute() {
|
|
|
342
533
|
if (!router) throw new Error("[pyreon-router] No router installed. Wrap your app in <RouterProvider router={router}>.");
|
|
343
534
|
return router.currentRoute;
|
|
344
535
|
}
|
|
536
|
+
/**
|
|
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
|
+
}
|
|
558
|
+
/**
|
|
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
|
+
}
|
|
580
|
+
/**
|
|
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
|
+
}
|
|
611
|
+
/**
|
|
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
|
+
}
|
|
345
643
|
function createRouter(options) {
|
|
346
644
|
const opts = Array.isArray(options) ? {
|
|
347
645
|
routes: options
|
|
@@ -351,27 +649,29 @@ function createRouter(options) {
|
|
|
351
649
|
mode = "hash",
|
|
352
650
|
scrollBehavior,
|
|
353
651
|
onError,
|
|
354
|
-
maxCacheSize = 100
|
|
652
|
+
maxCacheSize = 100,
|
|
653
|
+
trailingSlash = "strip"
|
|
355
654
|
} = opts;
|
|
655
|
+
const base = mode === "history" ? normalizeBase(opts.base ?? "") : "";
|
|
356
656
|
const nameIndex = buildNameIndex(routes);
|
|
357
657
|
const guards = [];
|
|
358
658
|
const afterHooks = [];
|
|
359
659
|
const scrollManager = new ScrollManager(scrollBehavior);
|
|
360
660
|
let _navGen = 0;
|
|
361
661
|
const getInitialLocation = () => {
|
|
362
|
-
if (opts.url) return opts.url;
|
|
662
|
+
if (opts.url) return stripBase(opts.url, base);
|
|
363
663
|
if (!_isBrowser) return "/";
|
|
364
|
-
if (mode === "history") return window.location.pathname + window.location.search;
|
|
664
|
+
if (mode === "history") return stripBase(window.location.pathname, base) + window.location.search;
|
|
365
665
|
const hash = window.location.hash;
|
|
366
666
|
return hash.startsWith("#") ? hash.slice(1) || "/" : "/";
|
|
367
667
|
};
|
|
368
668
|
const getCurrentLocation = () => {
|
|
369
669
|
if (!_isBrowser) return currentPath();
|
|
370
|
-
if (mode === "history") return window.location.pathname + window.location.search;
|
|
670
|
+
if (mode === "history") return stripBase(window.location.pathname, base) + window.location.search;
|
|
371
671
|
const hash = window.location.hash;
|
|
372
672
|
return hash.startsWith("#") ? hash.slice(1) || "/" : "/";
|
|
373
673
|
};
|
|
374
|
-
const currentPath = signal(getInitialLocation());
|
|
674
|
+
const currentPath = signal(normalizeTrailingSlash(getInitialLocation(), trailingSlash));
|
|
375
675
|
const currentRoute = computed(() => resolveRoute(currentPath(), routes));
|
|
376
676
|
let _popstateHandler = null;
|
|
377
677
|
let _hashchangeHandler = null;
|
|
@@ -437,7 +737,7 @@ function createRouter(options) {
|
|
|
437
737
|
}
|
|
438
738
|
function syncBrowserUrl(path, replace) {
|
|
439
739
|
if (!_isBrowser) return;
|
|
440
|
-
const url = mode === "history" ? path : `#${path}`;
|
|
740
|
+
const url = mode === "history" ? `${base}${path}` : `#${path}`;
|
|
441
741
|
if (replace) window.history.replaceState(null, "", url);else window.history.pushState(null, "", url);
|
|
442
742
|
}
|
|
443
743
|
function resolveRedirect(to) {
|
|
@@ -452,27 +752,52 @@ function createRouter(options) {
|
|
|
452
752
|
if (enterOutcome.action !== "continue") return enterOutcome;
|
|
453
753
|
return runGlobalGuards(guards, to, from, gen);
|
|
454
754
|
}
|
|
455
|
-
async function
|
|
456
|
-
const loadableRecords = to.matched.filter(r => r.loader);
|
|
457
|
-
if (loadableRecords.length === 0) return true;
|
|
755
|
+
async function runBlockingLoaders(records, to, gen, ac) {
|
|
458
756
|
const loaderCtx = {
|
|
459
757
|
params: to.params,
|
|
460
758
|
query: to.query,
|
|
461
759
|
signal: ac.signal
|
|
462
760
|
};
|
|
463
|
-
const results = await Promise.allSettled(
|
|
464
|
-
if (!r.loader) return Promise.resolve(void 0);
|
|
465
|
-
return r.loader(loaderCtx);
|
|
466
|
-
}));
|
|
761
|
+
const results = await Promise.allSettled(records.map(r => r.loader ? r.loader(loaderCtx) : Promise.resolve(void 0)));
|
|
467
762
|
if (gen !== _navGen) return false;
|
|
468
|
-
for (let i = 0; i <
|
|
763
|
+
for (let i = 0; i < records.length; i++) {
|
|
469
764
|
const result = results[i];
|
|
470
|
-
const record =
|
|
765
|
+
const record = records[i];
|
|
471
766
|
if (!result || !record) continue;
|
|
472
767
|
if (!processLoaderResult(result, record, ac, to)) return false;
|
|
473
768
|
}
|
|
474
769
|
return true;
|
|
475
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
|
+
}
|
|
476
801
|
function commitNavigation(path, replace, to, from) {
|
|
477
802
|
scrollManager.save(from.path);
|
|
478
803
|
currentPath.set(path);
|
|
@@ -484,8 +809,16 @@ function createRouter(options) {
|
|
|
484
809
|
} catch (_err) {}
|
|
485
810
|
if (_isBrowser) queueMicrotask(() => scrollManager.restore(to, from));
|
|
486
811
|
}
|
|
487
|
-
async function
|
|
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) {
|
|
488
820
|
if (redirectDepth > 10) return;
|
|
821
|
+
const path = normalizeTrailingSlash(rawPath, trailingSlash);
|
|
489
822
|
const gen = ++_navGen;
|
|
490
823
|
loadingSignal.update(n => n + 1);
|
|
491
824
|
const to = resolveRoute(path, routes);
|
|
@@ -495,6 +828,10 @@ function createRouter(options) {
|
|
|
495
828
|
loadingSignal.update(n => n - 1);
|
|
496
829
|
return navigate(redirectTarget, replace, redirectDepth + 1);
|
|
497
830
|
}
|
|
831
|
+
if ((await checkBlockers(to, from, gen)) !== "continue") {
|
|
832
|
+
loadingSignal.update(n => n - 1);
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
498
835
|
const guardOutcome = await runAllGuards(to, from, gen);
|
|
499
836
|
if (guardOutcome.action !== "continue") {
|
|
500
837
|
loadingSignal.update(n => n - 1);
|
|
@@ -511,9 +848,14 @@ function createRouter(options) {
|
|
|
511
848
|
commitNavigation(path, replace, to, from);
|
|
512
849
|
loadingSignal.update(n => n - 1);
|
|
513
850
|
}
|
|
851
|
+
let _readyResolve = null;
|
|
852
|
+
const _readyPromise = new Promise(resolve => {
|
|
853
|
+
_readyResolve = resolve;
|
|
854
|
+
});
|
|
514
855
|
const router = {
|
|
515
856
|
routes,
|
|
516
857
|
mode,
|
|
858
|
+
_base: base,
|
|
517
859
|
currentRoute,
|
|
518
860
|
_currentPath: currentPath,
|
|
519
861
|
_currentRoute: currentRoute,
|
|
@@ -525,18 +867,28 @@ function createRouter(options) {
|
|
|
525
867
|
_erroredChunks: /* @__PURE__ */new Set(),
|
|
526
868
|
_loaderData: /* @__PURE__ */new Map(),
|
|
527
869
|
_abortController: null,
|
|
870
|
+
_blockers: /* @__PURE__ */new Set(),
|
|
871
|
+
_readyResolve,
|
|
872
|
+
_readyPromise,
|
|
528
873
|
_onError: onError,
|
|
529
874
|
_maxCacheSize: maxCacheSize,
|
|
530
875
|
async push(location) {
|
|
531
|
-
if (typeof location === "string") return navigate(sanitizePath(location), false);
|
|
876
|
+
if (typeof location === "string") return navigate(sanitizePath(resolveRelativePath(location, currentPath())), false);
|
|
532
877
|
return navigate(resolveNamedPath(location.name, location.params ?? {}, location.query ?? {}, nameIndex), false);
|
|
533
878
|
},
|
|
534
|
-
async replace(
|
|
535
|
-
return navigate(sanitizePath(
|
|
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);
|
|
536
882
|
},
|
|
537
883
|
back() {
|
|
538
884
|
if (_isBrowser) window.history.back();
|
|
539
885
|
},
|
|
886
|
+
forward() {
|
|
887
|
+
if (_isBrowser) window.history.forward();
|
|
888
|
+
},
|
|
889
|
+
go(delta) {
|
|
890
|
+
if (_isBrowser) window.history.go(delta);
|
|
891
|
+
},
|
|
540
892
|
beforeEach(guard) {
|
|
541
893
|
guards.push(guard);
|
|
542
894
|
return () => {
|
|
@@ -552,6 +904,9 @@ function createRouter(options) {
|
|
|
552
904
|
};
|
|
553
905
|
},
|
|
554
906
|
loading: () => loadingSignal() > 0,
|
|
907
|
+
isReady() {
|
|
908
|
+
return router._readyPromise;
|
|
909
|
+
},
|
|
555
910
|
destroy() {
|
|
556
911
|
if (_popstateHandler) {
|
|
557
912
|
window.removeEventListener("popstate", _popstateHandler);
|
|
@@ -563,6 +918,7 @@ function createRouter(options) {
|
|
|
563
918
|
}
|
|
564
919
|
guards.length = 0;
|
|
565
920
|
afterHooks.length = 0;
|
|
921
|
+
router._blockers.clear();
|
|
566
922
|
componentCache.clear();
|
|
567
923
|
router._loaderData.clear();
|
|
568
924
|
router._abortController?.abort();
|
|
@@ -570,6 +926,12 @@ function createRouter(options) {
|
|
|
570
926
|
},
|
|
571
927
|
_resolve: rawPath => resolveRoute(rawPath, routes)
|
|
572
928
|
};
|
|
929
|
+
queueMicrotask(() => {
|
|
930
|
+
if (router._readyResolve) {
|
|
931
|
+
router._readyResolve();
|
|
932
|
+
router._readyResolve = null;
|
|
933
|
+
}
|
|
934
|
+
});
|
|
573
935
|
return router;
|
|
574
936
|
}
|
|
575
937
|
async function runGuard(guard, to, from) {
|
|
@@ -587,6 +949,44 @@ function resolveNamedPath(name, params, query, index) {
|
|
|
587
949
|
if (qs) path += `?${qs}`;
|
|
588
950
|
return path;
|
|
589
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
|
+
}
|
|
978
|
+
/**
|
|
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
|
+
}
|
|
590
990
|
/** Block unsafe navigation targets: javascript/data/vbscript URIs and absolute URLs. */
|
|
591
991
|
function sanitizePath(path) {
|
|
592
992
|
const trimmed = path.trim();
|
|
@@ -686,5 +1086,5 @@ function isStaleChunk(err) {
|
|
|
686
1086
|
}
|
|
687
1087
|
|
|
688
1088
|
//#endregion
|
|
689
|
-
export { RouterContext, RouterLink, RouterProvider, RouterView, buildPath, createRouter, findRouteByName, hydrateLoaderData, lazy, parseQuery, parseQueryMulti, prefetchLoaderData, resolveRoute, serializeLoaderData, stringifyQuery, useLoaderData, useRoute, useRouter };
|
|
1089
|
+
export { RouterContext, RouterLink, RouterProvider, RouterView, buildPath, createRouter, findRouteByName, hydrateLoaderData, lazy, onBeforeRouteLeave, onBeforeRouteUpdate, parseQuery, parseQueryMulti, prefetchLoaderData, resolveRoute, serializeLoaderData, stringifyQuery, useBlocker, useLoaderData, useRoute, useRouter, useSearchParams };
|
|
690
1090
|
//# sourceMappingURL=index.d.ts.map
|