@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 CHANGED
@@ -101,7 +101,10 @@ function escapeRegex(str) {
101
101
  return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
102
102
  }
103
103
  function matchRoute(url, routes2) {
104
- const pathname = url.pathname;
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
- const result = await hook(from, to);
347
- if (result === false) {
348
- return false;
349
- }
350
- if (typeof result === "string") {
351
- return result;
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<any>;
17
+ component: () => Promise<Record<string, unknown>>;
18
18
  /** Optional layout component that wraps the page. */
19
- layout?: () => Promise<any>;
19
+ layout?: () => Promise<Record<string, unknown>>;
20
20
  /** Optional error boundary component shown when loading fails. */
21
- error?: () => Promise<any>;
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<any>>): Route[];
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<any>;
17
+ component: () => Promise<Record<string, unknown>>;
18
18
  /** Optional layout component that wraps the page. */
19
- layout?: () => Promise<any>;
19
+ layout?: () => Promise<Record<string, unknown>>;
20
20
  /** Optional error boundary component shown when loading fails. */
21
- error?: () => Promise<any>;
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<any>>): Route[];
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
- const pathname = url.pathname;
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
- const result = await hook(from, to);
308
- if (result === false) {
309
- return false;
310
- }
311
- if (typeof result === "string") {
312
- return result;
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.5",
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.5",
44
- "@matthesketh/utopia-runtime": "0.0.5"
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",