@matthesketh/utopia-router 0.4.0 → 0.5.1
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 +114 -25
- package/dist/index.d.cts +15 -1
- package/dist/index.d.ts +15 -1
- package/dist/index.js +113 -25
- package/package.json +6 -6
- package/LICENSE +0 -21
package/dist/index.cjs
CHANGED
|
@@ -36,6 +36,7 @@ __export(index_exports, {
|
|
|
36
36
|
isNavigating: () => isNavigating,
|
|
37
37
|
matchRoute: () => matchRoute,
|
|
38
38
|
navigate: () => navigate,
|
|
39
|
+
preloadRoute: () => preloadRoute,
|
|
39
40
|
queryParams: () => queryParams,
|
|
40
41
|
setQueryParam: () => setQueryParam,
|
|
41
42
|
setQueryParams: () => setQueryParams
|
|
@@ -43,26 +44,37 @@ __export(index_exports, {
|
|
|
43
44
|
module.exports = __toCommonJS(index_exports);
|
|
44
45
|
|
|
45
46
|
// src/matcher.ts
|
|
47
|
+
var BACKSLASH_RE = /\\/g;
|
|
48
|
+
var PAGE_FILE_RE = /\/?\+page\.\w+$/;
|
|
49
|
+
var LAYOUT_OR_ERROR_FILE_RE = /\/?\+(layout|error)\.\w+$/;
|
|
50
|
+
var GROUP_SEGMENT_RE = /^\(.+\)$/;
|
|
51
|
+
var REST_PARAM_RE = /^\[\.\.\..+\]$/;
|
|
52
|
+
var DYNAMIC_PARAM_RE = /^\[.+\]$/;
|
|
53
|
+
var ROOT_ROUTE_RE = /^\/$/;
|
|
54
|
+
var REGEX_SPECIAL_CHARS_RE = /[.*+?^${}()|[\]\\]/g;
|
|
55
|
+
var PAGE_FILE_TEST_RE = /\+page\.\w+$/;
|
|
56
|
+
var LAYOUT_FILE_TEST_RE = /\+layout\.\w+$/;
|
|
57
|
+
var ERROR_FILE_TEST_RE = /\+error\.\w+$/;
|
|
46
58
|
function filePathToRoute(filePath) {
|
|
47
|
-
let normalized = filePath.replace(
|
|
59
|
+
let normalized = filePath.replace(BACKSLASH_RE, "/");
|
|
48
60
|
const routesIdx = normalized.indexOf("routes/");
|
|
49
61
|
if (routesIdx !== -1) {
|
|
50
62
|
normalized = normalized.slice(routesIdx + "routes/".length);
|
|
51
63
|
}
|
|
52
|
-
normalized = normalized.replace(
|
|
53
|
-
normalized = normalized.replace(
|
|
64
|
+
normalized = normalized.replace(PAGE_FILE_RE, "");
|
|
65
|
+
normalized = normalized.replace(LAYOUT_OR_ERROR_FILE_RE, "");
|
|
54
66
|
const segments = normalized.split("/").filter(Boolean);
|
|
55
67
|
const routeSegments = [];
|
|
56
68
|
for (const segment of segments) {
|
|
57
|
-
if (
|
|
69
|
+
if (GROUP_SEGMENT_RE.test(segment)) {
|
|
58
70
|
continue;
|
|
59
71
|
}
|
|
60
|
-
if (
|
|
72
|
+
if (REST_PARAM_RE.test(segment)) {
|
|
61
73
|
const paramName = segment.slice(4, -1);
|
|
62
74
|
routeSegments.push(`*${paramName}`);
|
|
63
75
|
continue;
|
|
64
76
|
}
|
|
65
|
-
if (
|
|
77
|
+
if (DYNAMIC_PARAM_RE.test(segment)) {
|
|
66
78
|
const paramName = segment.slice(1, -1);
|
|
67
79
|
routeSegments.push(`:${paramName}`);
|
|
68
80
|
continue;
|
|
@@ -76,7 +88,7 @@ function compilePattern(pattern) {
|
|
|
76
88
|
const params = [];
|
|
77
89
|
if (pattern === "/") {
|
|
78
90
|
return {
|
|
79
|
-
regex:
|
|
91
|
+
regex: ROOT_ROUTE_RE,
|
|
80
92
|
params: []
|
|
81
93
|
};
|
|
82
94
|
}
|
|
@@ -98,12 +110,13 @@ function compilePattern(pattern) {
|
|
|
98
110
|
}
|
|
99
111
|
regexStr = "^" + regexStr + "/?$";
|
|
100
112
|
return {
|
|
113
|
+
// Dynamic regex — built from sanitized input (segments are escaped via escapeRegex).
|
|
101
114
|
regex: new RegExp(regexStr),
|
|
102
115
|
params
|
|
103
116
|
};
|
|
104
117
|
}
|
|
105
118
|
function escapeRegex(str) {
|
|
106
|
-
return str.replace(
|
|
119
|
+
return str.replace(REGEX_SPECIAL_CHARS_RE, "\\$&");
|
|
107
120
|
}
|
|
108
121
|
function matchRoute(url, routes2) {
|
|
109
122
|
let pathname = url.pathname;
|
|
@@ -127,12 +140,12 @@ function buildRouteTable(manifest) {
|
|
|
127
140
|
const layouts = /* @__PURE__ */ new Map();
|
|
128
141
|
const errors = /* @__PURE__ */ new Map();
|
|
129
142
|
for (const [filePath, importFn] of Object.entries(manifest)) {
|
|
130
|
-
const normalized = filePath.replace(
|
|
131
|
-
if (
|
|
143
|
+
const normalized = filePath.replace(BACKSLASH_RE, "/");
|
|
144
|
+
if (PAGE_FILE_TEST_RE.test(normalized)) {
|
|
132
145
|
pages.set(normalized, importFn);
|
|
133
|
-
} else if (
|
|
146
|
+
} else if (LAYOUT_FILE_TEST_RE.test(normalized)) {
|
|
134
147
|
layouts.set(normalized, importFn);
|
|
135
|
-
} else if (
|
|
148
|
+
} else if (ERROR_FILE_TEST_RE.test(normalized)) {
|
|
136
149
|
errors.set(normalized, importFn);
|
|
137
150
|
}
|
|
138
151
|
}
|
|
@@ -179,12 +192,12 @@ function routeSpecificity(path) {
|
|
|
179
192
|
return score;
|
|
180
193
|
}
|
|
181
194
|
function findNearestSpecialFile(pageFilePath, specialFiles) {
|
|
182
|
-
const normalized = pageFilePath.replace(
|
|
195
|
+
const normalized = pageFilePath.replace(BACKSLASH_RE, "/");
|
|
183
196
|
const lastSlash = normalized.lastIndexOf("/");
|
|
184
197
|
let dir = lastSlash !== -1 ? normalized.slice(0, lastSlash) : "";
|
|
185
198
|
while (dir) {
|
|
186
199
|
for (const [specialPath, importFn] of specialFiles) {
|
|
187
|
-
const specialNorm = specialPath.replace(
|
|
200
|
+
const specialNorm = specialPath.replace(BACKSLASH_RE, "/");
|
|
188
201
|
const specialLastSlash = specialNorm.lastIndexOf("/");
|
|
189
202
|
const specialDir = specialLastSlash !== -1 ? specialNorm.slice(0, specialLastSlash) : "";
|
|
190
203
|
if (specialDir === dir) {
|
|
@@ -202,6 +215,7 @@ function findNearestSpecialFile(pageFilePath, specialFiles) {
|
|
|
202
215
|
|
|
203
216
|
// src/router.ts
|
|
204
217
|
var import_utopia_core = require("@matthesketh/utopia-core");
|
|
218
|
+
var VALID_DOM_ID_RE = /^[A-Za-z0-9_-]+$/;
|
|
205
219
|
var currentRoute = (0, import_utopia_core.signal)(null);
|
|
206
220
|
var isNavigating = (0, import_utopia_core.signal)(false);
|
|
207
221
|
var routes = [];
|
|
@@ -324,10 +338,13 @@ async function navigate(url, options = {}) {
|
|
|
324
338
|
currentRoute.set(match);
|
|
325
339
|
requestAnimationFrame(() => {
|
|
326
340
|
if (fullUrl.hash) {
|
|
327
|
-
const
|
|
328
|
-
if (
|
|
329
|
-
el.
|
|
330
|
-
|
|
341
|
+
const hashId = fullUrl.hash.slice(1);
|
|
342
|
+
if (hashId && VALID_DOM_ID_RE.test(hashId)) {
|
|
343
|
+
const el = document.getElementById(hashId);
|
|
344
|
+
if (el) {
|
|
345
|
+
el.scrollIntoView();
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
331
348
|
}
|
|
332
349
|
}
|
|
333
350
|
window.scrollTo(0, 0);
|
|
@@ -401,12 +418,35 @@ function capScrollPositions() {
|
|
|
401
418
|
|
|
402
419
|
// src/components.ts
|
|
403
420
|
var import_utopia_core2 = require("@matthesketh/utopia-core");
|
|
421
|
+
var moduleCache = /* @__PURE__ */ new Map();
|
|
422
|
+
async function preloadRoute() {
|
|
423
|
+
const match = currentRoute.peek();
|
|
424
|
+
if (!match) return;
|
|
425
|
+
const promises = [match.route.component()];
|
|
426
|
+
if (match.route.layout) {
|
|
427
|
+
promises.push(match.route.layout());
|
|
428
|
+
}
|
|
429
|
+
const modules = await Promise.all(promises);
|
|
430
|
+
moduleCache.set(match.route.component, modules[0]);
|
|
431
|
+
if (match.route.layout && modules.length > 1) {
|
|
432
|
+
moduleCache.set(match.route.layout, modules[1]);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
404
435
|
function createRouterView() {
|
|
405
436
|
const container = document.createElement("div");
|
|
406
437
|
container.setAttribute("data-utopia-router-view", "");
|
|
407
438
|
let currentCleanup = null;
|
|
408
439
|
let currentMatch = null;
|
|
409
440
|
let loadId = 0;
|
|
441
|
+
const initialMatch = currentRoute.peek();
|
|
442
|
+
if (initialMatch) {
|
|
443
|
+
const syncResult = tryRenderFromCache(initialMatch);
|
|
444
|
+
if (syncResult) {
|
|
445
|
+
container.appendChild(syncResult.node);
|
|
446
|
+
currentCleanup = syncResult.cleanup;
|
|
447
|
+
currentMatch = initialMatch;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
410
450
|
(0, import_utopia_core2.effect)(() => {
|
|
411
451
|
const match = currentRoute();
|
|
412
452
|
if (match === currentMatch) {
|
|
@@ -440,15 +480,58 @@ function createRouterView() {
|
|
|
440
480
|
});
|
|
441
481
|
return container;
|
|
442
482
|
}
|
|
483
|
+
function tryRenderFromCache(match) {
|
|
484
|
+
const cachedPage = moduleCache.get(match.route.component);
|
|
485
|
+
if (!cachedPage) return null;
|
|
486
|
+
if (match.route.layout && !moduleCache.has(match.route.layout)) return null;
|
|
487
|
+
const cachedLayout = match.route.layout ? moduleCache.get(match.route.layout) : null;
|
|
488
|
+
moduleCache.delete(match.route.component);
|
|
489
|
+
if (match.route.layout) moduleCache.delete(match.route.layout);
|
|
490
|
+
const PageComponent = cachedPage.default ?? cachedPage;
|
|
491
|
+
const LayoutComponent = cachedLayout ? cachedLayout.default ?? cachedLayout : null;
|
|
492
|
+
const pageNode = renderComponent(PageComponent, {
|
|
493
|
+
params: match.params,
|
|
494
|
+
url: match.url
|
|
495
|
+
});
|
|
496
|
+
let node;
|
|
497
|
+
if (LayoutComponent) {
|
|
498
|
+
node = renderComponent(LayoutComponent, {
|
|
499
|
+
params: match.params,
|
|
500
|
+
url: match.url,
|
|
501
|
+
children: pageNode
|
|
502
|
+
});
|
|
503
|
+
} else {
|
|
504
|
+
node = pageNode;
|
|
505
|
+
}
|
|
506
|
+
return {
|
|
507
|
+
node,
|
|
508
|
+
cleanup: () => {
|
|
509
|
+
if (node.parentNode) {
|
|
510
|
+
node.parentNode.removeChild(node);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
};
|
|
514
|
+
}
|
|
443
515
|
async function loadRouteComponent(match) {
|
|
444
516
|
try {
|
|
445
|
-
const
|
|
446
|
-
|
|
447
|
-
|
|
517
|
+
const cachedPage = moduleCache.get(match.route.component);
|
|
518
|
+
const cachedLayout = match.route.layout ? moduleCache.get(match.route.layout) : void 0;
|
|
519
|
+
let pageModule;
|
|
520
|
+
let layoutModule = null;
|
|
521
|
+
if (cachedPage) {
|
|
522
|
+
pageModule = cachedPage;
|
|
523
|
+
layoutModule = cachedLayout ?? null;
|
|
524
|
+
moduleCache.delete(match.route.component);
|
|
525
|
+
if (match.route.layout) moduleCache.delete(match.route.layout);
|
|
526
|
+
} else {
|
|
527
|
+
const promises = [match.route.component()];
|
|
528
|
+
if (match.route.layout) {
|
|
529
|
+
promises.push(match.route.layout());
|
|
530
|
+
}
|
|
531
|
+
const modules = await Promise.all(promises);
|
|
532
|
+
pageModule = modules[0];
|
|
533
|
+
layoutModule = modules.length > 1 ? modules[1] : null;
|
|
448
534
|
}
|
|
449
|
-
const modules = await Promise.all(promises);
|
|
450
|
-
const pageModule = modules[0];
|
|
451
|
-
const layoutModule = modules.length > 1 ? modules[1] : null;
|
|
452
535
|
if (currentRoute.peek() !== match) {
|
|
453
536
|
return null;
|
|
454
537
|
}
|
|
@@ -572,8 +655,13 @@ function clearContainer(container) {
|
|
|
572
655
|
container.removeChild(container.firstChild);
|
|
573
656
|
}
|
|
574
657
|
}
|
|
658
|
+
var AMPERSAND_RE = /&/g;
|
|
659
|
+
var LESS_THAN_RE = /</g;
|
|
660
|
+
var GREATER_THAN_RE = />/g;
|
|
661
|
+
var DOUBLE_QUOTE_RE = /"/g;
|
|
662
|
+
var SINGLE_QUOTE_RE = /'/g;
|
|
575
663
|
function escapeHtml(str) {
|
|
576
|
-
return str.replace(
|
|
664
|
+
return str.replace(AMPERSAND_RE, "&").replace(LESS_THAN_RE, "<").replace(GREATER_THAN_RE, ">").replace(DOUBLE_QUOTE_RE, """).replace(SINGLE_QUOTE_RE, "'");
|
|
577
665
|
}
|
|
578
666
|
|
|
579
667
|
// src/query.ts
|
|
@@ -641,6 +729,7 @@ function getRouteParam(name) {
|
|
|
641
729
|
isNavigating,
|
|
642
730
|
matchRoute,
|
|
643
731
|
navigate,
|
|
732
|
+
preloadRoute,
|
|
644
733
|
queryParams,
|
|
645
734
|
setQueryParam,
|
|
646
735
|
setQueryParams
|
package/dist/index.d.cts
CHANGED
|
@@ -167,6 +167,20 @@ declare function beforeNavigate(hook: BeforeNavigateHook): () => void;
|
|
|
167
167
|
*/
|
|
168
168
|
declare function destroy(): void;
|
|
169
169
|
|
|
170
|
+
/**
|
|
171
|
+
* Pre-load the current route's component (and layout) so that
|
|
172
|
+
* `createRouterView()` can render it synchronously on first paint.
|
|
173
|
+
*
|
|
174
|
+
* Call this **after** `createRouter()` and **before** `mount()`.
|
|
175
|
+
*
|
|
176
|
+
* @example
|
|
177
|
+
* ```ts
|
|
178
|
+
* createRouter(routes)
|
|
179
|
+
* await preloadRoute()
|
|
180
|
+
* mount(App, '#app')
|
|
181
|
+
* ```
|
|
182
|
+
*/
|
|
183
|
+
declare function preloadRoute(): Promise<void>;
|
|
170
184
|
/**
|
|
171
185
|
* Creates a DOM node that renders the current route's component.
|
|
172
186
|
*
|
|
@@ -239,4 +253,4 @@ declare function setQueryParams(params: Record<string, string | null>): void;
|
|
|
239
253
|
*/
|
|
240
254
|
declare function getRouteParam(name: string): _matthesketh_utopia_core.ReadonlySignal<string | null>;
|
|
241
255
|
|
|
242
|
-
export { type BeforeNavigateHook, type Route, type RouteMatch, type RouterState, back, beforeNavigate, buildRouteTable, compilePattern, createLink, createRouter, createRouterView, currentRoute, destroy, filePathToRoute, forward, getQueryParam, getRouteParam, isNavigating, matchRoute, navigate, queryParams, setQueryParam, setQueryParams };
|
|
256
|
+
export { type BeforeNavigateHook, type Route, type RouteMatch, type RouterState, back, beforeNavigate, buildRouteTable, compilePattern, createLink, createRouter, createRouterView, currentRoute, destroy, filePathToRoute, forward, getQueryParam, getRouteParam, isNavigating, matchRoute, navigate, preloadRoute, queryParams, setQueryParam, setQueryParams };
|
package/dist/index.d.ts
CHANGED
|
@@ -167,6 +167,20 @@ declare function beforeNavigate(hook: BeforeNavigateHook): () => void;
|
|
|
167
167
|
*/
|
|
168
168
|
declare function destroy(): void;
|
|
169
169
|
|
|
170
|
+
/**
|
|
171
|
+
* Pre-load the current route's component (and layout) so that
|
|
172
|
+
* `createRouterView()` can render it synchronously on first paint.
|
|
173
|
+
*
|
|
174
|
+
* Call this **after** `createRouter()` and **before** `mount()`.
|
|
175
|
+
*
|
|
176
|
+
* @example
|
|
177
|
+
* ```ts
|
|
178
|
+
* createRouter(routes)
|
|
179
|
+
* await preloadRoute()
|
|
180
|
+
* mount(App, '#app')
|
|
181
|
+
* ```
|
|
182
|
+
*/
|
|
183
|
+
declare function preloadRoute(): Promise<void>;
|
|
170
184
|
/**
|
|
171
185
|
* Creates a DOM node that renders the current route's component.
|
|
172
186
|
*
|
|
@@ -239,4 +253,4 @@ declare function setQueryParams(params: Record<string, string | null>): void;
|
|
|
239
253
|
*/
|
|
240
254
|
declare function getRouteParam(name: string): _matthesketh_utopia_core.ReadonlySignal<string | null>;
|
|
241
255
|
|
|
242
|
-
export { type BeforeNavigateHook, type Route, type RouteMatch, type RouterState, back, beforeNavigate, buildRouteTable, compilePattern, createLink, createRouter, createRouterView, currentRoute, destroy, filePathToRoute, forward, getQueryParam, getRouteParam, isNavigating, matchRoute, navigate, queryParams, setQueryParam, setQueryParams };
|
|
256
|
+
export { type BeforeNavigateHook, type Route, type RouteMatch, type RouterState, back, beforeNavigate, buildRouteTable, compilePattern, createLink, createRouter, createRouterView, currentRoute, destroy, filePathToRoute, forward, getQueryParam, getRouteParam, isNavigating, matchRoute, navigate, preloadRoute, queryParams, setQueryParam, setQueryParams };
|
package/dist/index.js
CHANGED
|
@@ -1,24 +1,35 @@
|
|
|
1
1
|
// src/matcher.ts
|
|
2
|
+
var BACKSLASH_RE = /\\/g;
|
|
3
|
+
var PAGE_FILE_RE = /\/?\+page\.\w+$/;
|
|
4
|
+
var LAYOUT_OR_ERROR_FILE_RE = /\/?\+(layout|error)\.\w+$/;
|
|
5
|
+
var GROUP_SEGMENT_RE = /^\(.+\)$/;
|
|
6
|
+
var REST_PARAM_RE = /^\[\.\.\..+\]$/;
|
|
7
|
+
var DYNAMIC_PARAM_RE = /^\[.+\]$/;
|
|
8
|
+
var ROOT_ROUTE_RE = /^\/$/;
|
|
9
|
+
var REGEX_SPECIAL_CHARS_RE = /[.*+?^${}()|[\]\\]/g;
|
|
10
|
+
var PAGE_FILE_TEST_RE = /\+page\.\w+$/;
|
|
11
|
+
var LAYOUT_FILE_TEST_RE = /\+layout\.\w+$/;
|
|
12
|
+
var ERROR_FILE_TEST_RE = /\+error\.\w+$/;
|
|
2
13
|
function filePathToRoute(filePath) {
|
|
3
|
-
let normalized = filePath.replace(
|
|
14
|
+
let normalized = filePath.replace(BACKSLASH_RE, "/");
|
|
4
15
|
const routesIdx = normalized.indexOf("routes/");
|
|
5
16
|
if (routesIdx !== -1) {
|
|
6
17
|
normalized = normalized.slice(routesIdx + "routes/".length);
|
|
7
18
|
}
|
|
8
|
-
normalized = normalized.replace(
|
|
9
|
-
normalized = normalized.replace(
|
|
19
|
+
normalized = normalized.replace(PAGE_FILE_RE, "");
|
|
20
|
+
normalized = normalized.replace(LAYOUT_OR_ERROR_FILE_RE, "");
|
|
10
21
|
const segments = normalized.split("/").filter(Boolean);
|
|
11
22
|
const routeSegments = [];
|
|
12
23
|
for (const segment of segments) {
|
|
13
|
-
if (
|
|
24
|
+
if (GROUP_SEGMENT_RE.test(segment)) {
|
|
14
25
|
continue;
|
|
15
26
|
}
|
|
16
|
-
if (
|
|
27
|
+
if (REST_PARAM_RE.test(segment)) {
|
|
17
28
|
const paramName = segment.slice(4, -1);
|
|
18
29
|
routeSegments.push(`*${paramName}`);
|
|
19
30
|
continue;
|
|
20
31
|
}
|
|
21
|
-
if (
|
|
32
|
+
if (DYNAMIC_PARAM_RE.test(segment)) {
|
|
22
33
|
const paramName = segment.slice(1, -1);
|
|
23
34
|
routeSegments.push(`:${paramName}`);
|
|
24
35
|
continue;
|
|
@@ -32,7 +43,7 @@ function compilePattern(pattern) {
|
|
|
32
43
|
const params = [];
|
|
33
44
|
if (pattern === "/") {
|
|
34
45
|
return {
|
|
35
|
-
regex:
|
|
46
|
+
regex: ROOT_ROUTE_RE,
|
|
36
47
|
params: []
|
|
37
48
|
};
|
|
38
49
|
}
|
|
@@ -54,12 +65,13 @@ function compilePattern(pattern) {
|
|
|
54
65
|
}
|
|
55
66
|
regexStr = "^" + regexStr + "/?$";
|
|
56
67
|
return {
|
|
68
|
+
// Dynamic regex — built from sanitized input (segments are escaped via escapeRegex).
|
|
57
69
|
regex: new RegExp(regexStr),
|
|
58
70
|
params
|
|
59
71
|
};
|
|
60
72
|
}
|
|
61
73
|
function escapeRegex(str) {
|
|
62
|
-
return str.replace(
|
|
74
|
+
return str.replace(REGEX_SPECIAL_CHARS_RE, "\\$&");
|
|
63
75
|
}
|
|
64
76
|
function matchRoute(url, routes2) {
|
|
65
77
|
let pathname = url.pathname;
|
|
@@ -83,12 +95,12 @@ function buildRouteTable(manifest) {
|
|
|
83
95
|
const layouts = /* @__PURE__ */ new Map();
|
|
84
96
|
const errors = /* @__PURE__ */ new Map();
|
|
85
97
|
for (const [filePath, importFn] of Object.entries(manifest)) {
|
|
86
|
-
const normalized = filePath.replace(
|
|
87
|
-
if (
|
|
98
|
+
const normalized = filePath.replace(BACKSLASH_RE, "/");
|
|
99
|
+
if (PAGE_FILE_TEST_RE.test(normalized)) {
|
|
88
100
|
pages.set(normalized, importFn);
|
|
89
|
-
} else if (
|
|
101
|
+
} else if (LAYOUT_FILE_TEST_RE.test(normalized)) {
|
|
90
102
|
layouts.set(normalized, importFn);
|
|
91
|
-
} else if (
|
|
103
|
+
} else if (ERROR_FILE_TEST_RE.test(normalized)) {
|
|
92
104
|
errors.set(normalized, importFn);
|
|
93
105
|
}
|
|
94
106
|
}
|
|
@@ -135,12 +147,12 @@ function routeSpecificity(path) {
|
|
|
135
147
|
return score;
|
|
136
148
|
}
|
|
137
149
|
function findNearestSpecialFile(pageFilePath, specialFiles) {
|
|
138
|
-
const normalized = pageFilePath.replace(
|
|
150
|
+
const normalized = pageFilePath.replace(BACKSLASH_RE, "/");
|
|
139
151
|
const lastSlash = normalized.lastIndexOf("/");
|
|
140
152
|
let dir = lastSlash !== -1 ? normalized.slice(0, lastSlash) : "";
|
|
141
153
|
while (dir) {
|
|
142
154
|
for (const [specialPath, importFn] of specialFiles) {
|
|
143
|
-
const specialNorm = specialPath.replace(
|
|
155
|
+
const specialNorm = specialPath.replace(BACKSLASH_RE, "/");
|
|
144
156
|
const specialLastSlash = specialNorm.lastIndexOf("/");
|
|
145
157
|
const specialDir = specialLastSlash !== -1 ? specialNorm.slice(0, specialLastSlash) : "";
|
|
146
158
|
if (specialDir === dir) {
|
|
@@ -158,6 +170,7 @@ function findNearestSpecialFile(pageFilePath, specialFiles) {
|
|
|
158
170
|
|
|
159
171
|
// src/router.ts
|
|
160
172
|
import { signal } from "@matthesketh/utopia-core";
|
|
173
|
+
var VALID_DOM_ID_RE = /^[A-Za-z0-9_-]+$/;
|
|
161
174
|
var currentRoute = signal(null);
|
|
162
175
|
var isNavigating = signal(false);
|
|
163
176
|
var routes = [];
|
|
@@ -280,10 +293,13 @@ async function navigate(url, options = {}) {
|
|
|
280
293
|
currentRoute.set(match);
|
|
281
294
|
requestAnimationFrame(() => {
|
|
282
295
|
if (fullUrl.hash) {
|
|
283
|
-
const
|
|
284
|
-
if (
|
|
285
|
-
el.
|
|
286
|
-
|
|
296
|
+
const hashId = fullUrl.hash.slice(1);
|
|
297
|
+
if (hashId && VALID_DOM_ID_RE.test(hashId)) {
|
|
298
|
+
const el = document.getElementById(hashId);
|
|
299
|
+
if (el) {
|
|
300
|
+
el.scrollIntoView();
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
287
303
|
}
|
|
288
304
|
}
|
|
289
305
|
window.scrollTo(0, 0);
|
|
@@ -357,12 +373,35 @@ function capScrollPositions() {
|
|
|
357
373
|
|
|
358
374
|
// src/components.ts
|
|
359
375
|
import { effect } from "@matthesketh/utopia-core";
|
|
376
|
+
var moduleCache = /* @__PURE__ */ new Map();
|
|
377
|
+
async function preloadRoute() {
|
|
378
|
+
const match = currentRoute.peek();
|
|
379
|
+
if (!match) return;
|
|
380
|
+
const promises = [match.route.component()];
|
|
381
|
+
if (match.route.layout) {
|
|
382
|
+
promises.push(match.route.layout());
|
|
383
|
+
}
|
|
384
|
+
const modules = await Promise.all(promises);
|
|
385
|
+
moduleCache.set(match.route.component, modules[0]);
|
|
386
|
+
if (match.route.layout && modules.length > 1) {
|
|
387
|
+
moduleCache.set(match.route.layout, modules[1]);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
360
390
|
function createRouterView() {
|
|
361
391
|
const container = document.createElement("div");
|
|
362
392
|
container.setAttribute("data-utopia-router-view", "");
|
|
363
393
|
let currentCleanup = null;
|
|
364
394
|
let currentMatch = null;
|
|
365
395
|
let loadId = 0;
|
|
396
|
+
const initialMatch = currentRoute.peek();
|
|
397
|
+
if (initialMatch) {
|
|
398
|
+
const syncResult = tryRenderFromCache(initialMatch);
|
|
399
|
+
if (syncResult) {
|
|
400
|
+
container.appendChild(syncResult.node);
|
|
401
|
+
currentCleanup = syncResult.cleanup;
|
|
402
|
+
currentMatch = initialMatch;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
366
405
|
effect(() => {
|
|
367
406
|
const match = currentRoute();
|
|
368
407
|
if (match === currentMatch) {
|
|
@@ -396,15 +435,58 @@ function createRouterView() {
|
|
|
396
435
|
});
|
|
397
436
|
return container;
|
|
398
437
|
}
|
|
438
|
+
function tryRenderFromCache(match) {
|
|
439
|
+
const cachedPage = moduleCache.get(match.route.component);
|
|
440
|
+
if (!cachedPage) return null;
|
|
441
|
+
if (match.route.layout && !moduleCache.has(match.route.layout)) return null;
|
|
442
|
+
const cachedLayout = match.route.layout ? moduleCache.get(match.route.layout) : null;
|
|
443
|
+
moduleCache.delete(match.route.component);
|
|
444
|
+
if (match.route.layout) moduleCache.delete(match.route.layout);
|
|
445
|
+
const PageComponent = cachedPage.default ?? cachedPage;
|
|
446
|
+
const LayoutComponent = cachedLayout ? cachedLayout.default ?? cachedLayout : null;
|
|
447
|
+
const pageNode = renderComponent(PageComponent, {
|
|
448
|
+
params: match.params,
|
|
449
|
+
url: match.url
|
|
450
|
+
});
|
|
451
|
+
let node;
|
|
452
|
+
if (LayoutComponent) {
|
|
453
|
+
node = renderComponent(LayoutComponent, {
|
|
454
|
+
params: match.params,
|
|
455
|
+
url: match.url,
|
|
456
|
+
children: pageNode
|
|
457
|
+
});
|
|
458
|
+
} else {
|
|
459
|
+
node = pageNode;
|
|
460
|
+
}
|
|
461
|
+
return {
|
|
462
|
+
node,
|
|
463
|
+
cleanup: () => {
|
|
464
|
+
if (node.parentNode) {
|
|
465
|
+
node.parentNode.removeChild(node);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
};
|
|
469
|
+
}
|
|
399
470
|
async function loadRouteComponent(match) {
|
|
400
471
|
try {
|
|
401
|
-
const
|
|
402
|
-
|
|
403
|
-
|
|
472
|
+
const cachedPage = moduleCache.get(match.route.component);
|
|
473
|
+
const cachedLayout = match.route.layout ? moduleCache.get(match.route.layout) : void 0;
|
|
474
|
+
let pageModule;
|
|
475
|
+
let layoutModule = null;
|
|
476
|
+
if (cachedPage) {
|
|
477
|
+
pageModule = cachedPage;
|
|
478
|
+
layoutModule = cachedLayout ?? null;
|
|
479
|
+
moduleCache.delete(match.route.component);
|
|
480
|
+
if (match.route.layout) moduleCache.delete(match.route.layout);
|
|
481
|
+
} else {
|
|
482
|
+
const promises = [match.route.component()];
|
|
483
|
+
if (match.route.layout) {
|
|
484
|
+
promises.push(match.route.layout());
|
|
485
|
+
}
|
|
486
|
+
const modules = await Promise.all(promises);
|
|
487
|
+
pageModule = modules[0];
|
|
488
|
+
layoutModule = modules.length > 1 ? modules[1] : null;
|
|
404
489
|
}
|
|
405
|
-
const modules = await Promise.all(promises);
|
|
406
|
-
const pageModule = modules[0];
|
|
407
|
-
const layoutModule = modules.length > 1 ? modules[1] : null;
|
|
408
490
|
if (currentRoute.peek() !== match) {
|
|
409
491
|
return null;
|
|
410
492
|
}
|
|
@@ -528,8 +610,13 @@ function clearContainer(container) {
|
|
|
528
610
|
container.removeChild(container.firstChild);
|
|
529
611
|
}
|
|
530
612
|
}
|
|
613
|
+
var AMPERSAND_RE = /&/g;
|
|
614
|
+
var LESS_THAN_RE = /</g;
|
|
615
|
+
var GREATER_THAN_RE = />/g;
|
|
616
|
+
var DOUBLE_QUOTE_RE = /"/g;
|
|
617
|
+
var SINGLE_QUOTE_RE = /'/g;
|
|
531
618
|
function escapeHtml(str) {
|
|
532
|
-
return str.replace(
|
|
619
|
+
return str.replace(AMPERSAND_RE, "&").replace(LESS_THAN_RE, "<").replace(GREATER_THAN_RE, ">").replace(DOUBLE_QUOTE_RE, """).replace(SINGLE_QUOTE_RE, "'");
|
|
533
620
|
}
|
|
534
621
|
|
|
535
622
|
// src/query.ts
|
|
@@ -596,6 +683,7 @@ export {
|
|
|
596
683
|
isNavigating,
|
|
597
684
|
matchRoute,
|
|
598
685
|
navigate,
|
|
686
|
+
preloadRoute,
|
|
599
687
|
queryParams,
|
|
600
688
|
setQueryParam,
|
|
601
689
|
setQueryParams
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@matthesketh/utopia-router",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "File-based routing for UtopiaJS",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -39,12 +39,12 @@
|
|
|
39
39
|
"files": [
|
|
40
40
|
"dist"
|
|
41
41
|
],
|
|
42
|
-
"dependencies": {
|
|
43
|
-
"@matthesketh/utopia-core": "0.4.0",
|
|
44
|
-
"@matthesketh/utopia-runtime": "0.4.0"
|
|
45
|
-
},
|
|
46
42
|
"scripts": {
|
|
47
43
|
"build": "tsup src/index.ts --format esm,cjs --dts",
|
|
48
44
|
"dev": "tsup src/index.ts --format esm,cjs --dts --watch"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"@matthesketh/utopia-core": "workspace:*",
|
|
48
|
+
"@matthesketh/utopia-runtime": "workspace:*"
|
|
49
49
|
}
|
|
50
|
-
}
|
|
50
|
+
}
|
package/LICENSE
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 Matt Hesketh
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|