@matthesketh/utopia-router 0.0.5 → 0.2.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/dist/index.cjs +49 -8
- package/dist/index.d.cts +6 -4
- package/dist/index.d.ts +6 -4
- package/dist/index.js +49 -8
- package/package.json +3 -3
package/dist/index.cjs
CHANGED
|
@@ -101,7 +101,10 @@ function escapeRegex(str) {
|
|
|
101
101
|
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
102
102
|
}
|
|
103
103
|
function matchRoute(url, routes2) {
|
|
104
|
-
|
|
104
|
+
let pathname = url.pathname;
|
|
105
|
+
if (pathname.length > 1 && pathname.endsWith("/")) {
|
|
106
|
+
pathname = pathname.slice(0, -1);
|
|
107
|
+
}
|
|
105
108
|
for (const route of routes2) {
|
|
106
109
|
const match = route.pattern.exec(pathname);
|
|
107
110
|
if (match) {
|
|
@@ -200,6 +203,8 @@ var routes = [];
|
|
|
200
203
|
var beforeNavigateHooks = [];
|
|
201
204
|
var scrollPositions = /* @__PURE__ */ new Map();
|
|
202
205
|
var navIndex = 0;
|
|
206
|
+
var MAX_SCROLL_ENTRIES = 50;
|
|
207
|
+
var redirectDepth = 0;
|
|
203
208
|
var cleanup = null;
|
|
204
209
|
function createRouter(routeTable) {
|
|
205
210
|
if (cleanup) {
|
|
@@ -222,11 +227,20 @@ function createRouter(routeTable) {
|
|
|
222
227
|
const state = event.state;
|
|
223
228
|
const targetIndex = state?._utopiaNavIndex ?? 0;
|
|
224
229
|
scrollPositions.set(navIndex, { x: window.scrollX, y: window.scrollY });
|
|
230
|
+
capScrollPositions();
|
|
225
231
|
navIndex = targetIndex;
|
|
226
232
|
const url = new URL(window.location.href);
|
|
227
233
|
const match = matchRoute(url, routes);
|
|
228
234
|
runBeforeNavigateHooks(currentRoute.peek(), match).then((result) => {
|
|
229
235
|
if (result === false) {
|
|
236
|
+
const prev = currentRoute.peek();
|
|
237
|
+
if (prev) {
|
|
238
|
+
history.pushState(
|
|
239
|
+
{ _utopiaNavIndex: navIndex },
|
|
240
|
+
"",
|
|
241
|
+
prev.url.pathname + prev.url.search + prev.url.hash
|
|
242
|
+
);
|
|
243
|
+
}
|
|
230
244
|
return;
|
|
231
245
|
}
|
|
232
246
|
if (typeof result === "string") {
|
|
@@ -267,6 +281,12 @@ function createRouter(routeTable) {
|
|
|
267
281
|
}
|
|
268
282
|
async function navigate(url, options = {}) {
|
|
269
283
|
if (typeof window === "undefined") return;
|
|
284
|
+
redirectDepth++;
|
|
285
|
+
if (redirectDepth > 10) {
|
|
286
|
+
console.error("[utopia] Maximum navigation redirects exceeded");
|
|
287
|
+
redirectDepth = 0;
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
270
290
|
isNavigating.set(true);
|
|
271
291
|
try {
|
|
272
292
|
const fullUrl = new URL(url, window.location.origin);
|
|
@@ -276,10 +296,19 @@ async function navigate(url, options = {}) {
|
|
|
276
296
|
return;
|
|
277
297
|
}
|
|
278
298
|
if (typeof hookResult === "string") {
|
|
299
|
+
try {
|
|
300
|
+
const redirectUrl = new URL(hookResult, window.location.origin);
|
|
301
|
+
if (redirectUrl.origin !== window.location.origin) {
|
|
302
|
+
console.error("[utopia] Cross-origin redirect blocked:", hookResult);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
} catch {
|
|
306
|
+
}
|
|
279
307
|
await navigate(hookResult, options);
|
|
280
308
|
return;
|
|
281
309
|
}
|
|
282
310
|
scrollPositions.set(navIndex, { x: window.scrollX, y: window.scrollY });
|
|
311
|
+
capScrollPositions();
|
|
283
312
|
navIndex++;
|
|
284
313
|
const state = { _utopiaNavIndex: navIndex };
|
|
285
314
|
if (options.replace) {
|
|
@@ -299,6 +328,7 @@ async function navigate(url, options = {}) {
|
|
|
299
328
|
window.scrollTo(0, 0);
|
|
300
329
|
});
|
|
301
330
|
} finally {
|
|
331
|
+
redirectDepth--;
|
|
302
332
|
isNavigating.set(false);
|
|
303
333
|
}
|
|
304
334
|
}
|
|
@@ -343,16 +373,26 @@ function findAnchorElement(target) {
|
|
|
343
373
|
}
|
|
344
374
|
async function runBeforeNavigateHooks(from, to) {
|
|
345
375
|
for (const hook of beforeNavigateHooks) {
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
376
|
+
try {
|
|
377
|
+
const result = await hook(from, to);
|
|
378
|
+
if (result === false) {
|
|
379
|
+
return false;
|
|
380
|
+
}
|
|
381
|
+
if (typeof result === "string") {
|
|
382
|
+
return result;
|
|
383
|
+
}
|
|
384
|
+
} catch (err) {
|
|
385
|
+
console.error("[utopia] Navigation guard error:", err);
|
|
352
386
|
}
|
|
353
387
|
}
|
|
354
388
|
return void 0;
|
|
355
389
|
}
|
|
390
|
+
function capScrollPositions() {
|
|
391
|
+
if (scrollPositions.size > MAX_SCROLL_ENTRIES) {
|
|
392
|
+
const firstKey = scrollPositions.keys().next().value;
|
|
393
|
+
if (firstKey !== void 0) scrollPositions.delete(firstKey);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
356
396
|
|
|
357
397
|
// src/components.ts
|
|
358
398
|
var import_utopia_core2 = require("@matthesketh/utopia-core");
|
|
@@ -497,7 +537,7 @@ function createLink(props) {
|
|
|
497
537
|
anchor.appendChild(props.children);
|
|
498
538
|
}
|
|
499
539
|
if (props.activeClass) {
|
|
500
|
-
(0, import_utopia_core2.effect)(() => {
|
|
540
|
+
const dispose = (0, import_utopia_core2.effect)(() => {
|
|
501
541
|
const match = currentRoute();
|
|
502
542
|
const isActive = match ? match.url.pathname === props.href || match.url.pathname.startsWith(props.href + "/") : false;
|
|
503
543
|
if (isActive) {
|
|
@@ -506,6 +546,7 @@ function createLink(props) {
|
|
|
506
546
|
anchor.classList.remove(props.activeClass);
|
|
507
547
|
}
|
|
508
548
|
});
|
|
549
|
+
anchor.__dispose = dispose;
|
|
509
550
|
}
|
|
510
551
|
return anchor;
|
|
511
552
|
}
|
package/dist/index.d.cts
CHANGED
|
@@ -14,11 +14,13 @@ interface Route {
|
|
|
14
14
|
/** Parameter names extracted from the path (e.g., ['id'] for '/users/:id'). */
|
|
15
15
|
params: string[];
|
|
16
16
|
/** Lazy component import — called only when the route is matched. */
|
|
17
|
-
component: () => Promise<
|
|
17
|
+
component: () => Promise<Record<string, unknown>>;
|
|
18
18
|
/** Optional layout component that wraps the page. */
|
|
19
|
-
layout?: () => Promise<
|
|
19
|
+
layout?: () => Promise<Record<string, unknown>>;
|
|
20
20
|
/** Optional error boundary component shown when loading fails. */
|
|
21
|
-
error?: () => Promise<
|
|
21
|
+
error?: () => Promise<Record<string, unknown>>;
|
|
22
|
+
/** Optional metadata (e.g. page title, auth requirements). */
|
|
23
|
+
meta?: Record<string, unknown>;
|
|
22
24
|
}
|
|
23
25
|
/**
|
|
24
26
|
* The result of successfully matching a URL against a route.
|
|
@@ -114,7 +116,7 @@ declare function matchRoute(url: URL, routes: Route[]): RouteMatch | null;
|
|
|
114
116
|
* @param manifest - Record of file paths to lazy import functions
|
|
115
117
|
* @returns Ordered array of compiled Route objects
|
|
116
118
|
*/
|
|
117
|
-
declare function buildRouteTable(manifest: Record<string, () => Promise<
|
|
119
|
+
declare function buildRouteTable(manifest: Record<string, () => Promise<Record<string, unknown>>>): Route[];
|
|
118
120
|
|
|
119
121
|
/** The currently matched route, or null if no route matches. */
|
|
120
122
|
declare const currentRoute: _matthesketh_utopia_core.Signal<RouteMatch | null>;
|
package/dist/index.d.ts
CHANGED
|
@@ -14,11 +14,13 @@ interface Route {
|
|
|
14
14
|
/** Parameter names extracted from the path (e.g., ['id'] for '/users/:id'). */
|
|
15
15
|
params: string[];
|
|
16
16
|
/** Lazy component import — called only when the route is matched. */
|
|
17
|
-
component: () => Promise<
|
|
17
|
+
component: () => Promise<Record<string, unknown>>;
|
|
18
18
|
/** Optional layout component that wraps the page. */
|
|
19
|
-
layout?: () => Promise<
|
|
19
|
+
layout?: () => Promise<Record<string, unknown>>;
|
|
20
20
|
/** Optional error boundary component shown when loading fails. */
|
|
21
|
-
error?: () => Promise<
|
|
21
|
+
error?: () => Promise<Record<string, unknown>>;
|
|
22
|
+
/** Optional metadata (e.g. page title, auth requirements). */
|
|
23
|
+
meta?: Record<string, unknown>;
|
|
22
24
|
}
|
|
23
25
|
/**
|
|
24
26
|
* The result of successfully matching a URL against a route.
|
|
@@ -114,7 +116,7 @@ declare function matchRoute(url: URL, routes: Route[]): RouteMatch | null;
|
|
|
114
116
|
* @param manifest - Record of file paths to lazy import functions
|
|
115
117
|
* @returns Ordered array of compiled Route objects
|
|
116
118
|
*/
|
|
117
|
-
declare function buildRouteTable(manifest: Record<string, () => Promise<
|
|
119
|
+
declare function buildRouteTable(manifest: Record<string, () => Promise<Record<string, unknown>>>): Route[];
|
|
118
120
|
|
|
119
121
|
/** The currently matched route, or null if no route matches. */
|
|
120
122
|
declare const currentRoute: _matthesketh_utopia_core.Signal<RouteMatch | null>;
|
package/dist/index.js
CHANGED
|
@@ -62,7 +62,10 @@ function escapeRegex(str) {
|
|
|
62
62
|
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
63
63
|
}
|
|
64
64
|
function matchRoute(url, routes2) {
|
|
65
|
-
|
|
65
|
+
let pathname = url.pathname;
|
|
66
|
+
if (pathname.length > 1 && pathname.endsWith("/")) {
|
|
67
|
+
pathname = pathname.slice(0, -1);
|
|
68
|
+
}
|
|
66
69
|
for (const route of routes2) {
|
|
67
70
|
const match = route.pattern.exec(pathname);
|
|
68
71
|
if (match) {
|
|
@@ -161,6 +164,8 @@ var routes = [];
|
|
|
161
164
|
var beforeNavigateHooks = [];
|
|
162
165
|
var scrollPositions = /* @__PURE__ */ new Map();
|
|
163
166
|
var navIndex = 0;
|
|
167
|
+
var MAX_SCROLL_ENTRIES = 50;
|
|
168
|
+
var redirectDepth = 0;
|
|
164
169
|
var cleanup = null;
|
|
165
170
|
function createRouter(routeTable) {
|
|
166
171
|
if (cleanup) {
|
|
@@ -183,11 +188,20 @@ function createRouter(routeTable) {
|
|
|
183
188
|
const state = event.state;
|
|
184
189
|
const targetIndex = state?._utopiaNavIndex ?? 0;
|
|
185
190
|
scrollPositions.set(navIndex, { x: window.scrollX, y: window.scrollY });
|
|
191
|
+
capScrollPositions();
|
|
186
192
|
navIndex = targetIndex;
|
|
187
193
|
const url = new URL(window.location.href);
|
|
188
194
|
const match = matchRoute(url, routes);
|
|
189
195
|
runBeforeNavigateHooks(currentRoute.peek(), match).then((result) => {
|
|
190
196
|
if (result === false) {
|
|
197
|
+
const prev = currentRoute.peek();
|
|
198
|
+
if (prev) {
|
|
199
|
+
history.pushState(
|
|
200
|
+
{ _utopiaNavIndex: navIndex },
|
|
201
|
+
"",
|
|
202
|
+
prev.url.pathname + prev.url.search + prev.url.hash
|
|
203
|
+
);
|
|
204
|
+
}
|
|
191
205
|
return;
|
|
192
206
|
}
|
|
193
207
|
if (typeof result === "string") {
|
|
@@ -228,6 +242,12 @@ function createRouter(routeTable) {
|
|
|
228
242
|
}
|
|
229
243
|
async function navigate(url, options = {}) {
|
|
230
244
|
if (typeof window === "undefined") return;
|
|
245
|
+
redirectDepth++;
|
|
246
|
+
if (redirectDepth > 10) {
|
|
247
|
+
console.error("[utopia] Maximum navigation redirects exceeded");
|
|
248
|
+
redirectDepth = 0;
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
231
251
|
isNavigating.set(true);
|
|
232
252
|
try {
|
|
233
253
|
const fullUrl = new URL(url, window.location.origin);
|
|
@@ -237,10 +257,19 @@ async function navigate(url, options = {}) {
|
|
|
237
257
|
return;
|
|
238
258
|
}
|
|
239
259
|
if (typeof hookResult === "string") {
|
|
260
|
+
try {
|
|
261
|
+
const redirectUrl = new URL(hookResult, window.location.origin);
|
|
262
|
+
if (redirectUrl.origin !== window.location.origin) {
|
|
263
|
+
console.error("[utopia] Cross-origin redirect blocked:", hookResult);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
} catch {
|
|
267
|
+
}
|
|
240
268
|
await navigate(hookResult, options);
|
|
241
269
|
return;
|
|
242
270
|
}
|
|
243
271
|
scrollPositions.set(navIndex, { x: window.scrollX, y: window.scrollY });
|
|
272
|
+
capScrollPositions();
|
|
244
273
|
navIndex++;
|
|
245
274
|
const state = { _utopiaNavIndex: navIndex };
|
|
246
275
|
if (options.replace) {
|
|
@@ -260,6 +289,7 @@ async function navigate(url, options = {}) {
|
|
|
260
289
|
window.scrollTo(0, 0);
|
|
261
290
|
});
|
|
262
291
|
} finally {
|
|
292
|
+
redirectDepth--;
|
|
263
293
|
isNavigating.set(false);
|
|
264
294
|
}
|
|
265
295
|
}
|
|
@@ -304,16 +334,26 @@ function findAnchorElement(target) {
|
|
|
304
334
|
}
|
|
305
335
|
async function runBeforeNavigateHooks(from, to) {
|
|
306
336
|
for (const hook of beforeNavigateHooks) {
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
337
|
+
try {
|
|
338
|
+
const result = await hook(from, to);
|
|
339
|
+
if (result === false) {
|
|
340
|
+
return false;
|
|
341
|
+
}
|
|
342
|
+
if (typeof result === "string") {
|
|
343
|
+
return result;
|
|
344
|
+
}
|
|
345
|
+
} catch (err) {
|
|
346
|
+
console.error("[utopia] Navigation guard error:", err);
|
|
313
347
|
}
|
|
314
348
|
}
|
|
315
349
|
return void 0;
|
|
316
350
|
}
|
|
351
|
+
function capScrollPositions() {
|
|
352
|
+
if (scrollPositions.size > MAX_SCROLL_ENTRIES) {
|
|
353
|
+
const firstKey = scrollPositions.keys().next().value;
|
|
354
|
+
if (firstKey !== void 0) scrollPositions.delete(firstKey);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
317
357
|
|
|
318
358
|
// src/components.ts
|
|
319
359
|
import { effect } from "@matthesketh/utopia-core";
|
|
@@ -458,7 +498,7 @@ function createLink(props) {
|
|
|
458
498
|
anchor.appendChild(props.children);
|
|
459
499
|
}
|
|
460
500
|
if (props.activeClass) {
|
|
461
|
-
effect(() => {
|
|
501
|
+
const dispose = effect(() => {
|
|
462
502
|
const match = currentRoute();
|
|
463
503
|
const isActive = match ? match.url.pathname === props.href || match.url.pathname.startsWith(props.href + "/") : false;
|
|
464
504
|
if (isActive) {
|
|
@@ -467,6 +507,7 @@ function createLink(props) {
|
|
|
467
507
|
anchor.classList.remove(props.activeClass);
|
|
468
508
|
}
|
|
469
509
|
});
|
|
510
|
+
anchor.__dispose = dispose;
|
|
470
511
|
}
|
|
471
512
|
return anchor;
|
|
472
513
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@matthesketh/utopia-router",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "File-based routing for UtopiaJS",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -40,8 +40,8 @@
|
|
|
40
40
|
"dist"
|
|
41
41
|
],
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@matthesketh/utopia-core": "0.0
|
|
44
|
-
"@matthesketh/utopia-runtime": "0.0
|
|
43
|
+
"@matthesketh/utopia-core": "0.2.0",
|
|
44
|
+
"@matthesketh/utopia-runtime": "0.2.0"
|
|
45
45
|
},
|
|
46
46
|
"scripts": {
|
|
47
47
|
"build": "tsup src/index.ts --format esm,cjs --dts",
|