@ilha/router 0.9.1 → 0.9.2
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/README.md +26 -6
- package/dist/index.d.ts +52 -3
- package/dist/index.js +2 -2247
- package/dist/{plugin-DogkskcY.js → plugin-BHuojFhQ.js} +36 -18
- package/dist/plugin.d.ts +17 -0
- package/dist/public-types.d.ts +7 -0
- package/dist/rolldown.js +1 -1
- package/dist/route-match.d.ts +22 -0
- package/dist/rspack.js +1 -1
- package/dist/server-island-registry.d.ts +46 -1
- package/dist/server-island-registry.js +76 -21
- package/dist/src-BBsbD5vU.js +2365 -0
- package/dist/ssr.d.ts +9 -0
- package/dist/ssr.js +57 -16
- package/dist/vite.js +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,2365 @@
|
|
|
1
|
+
import { ISLAND_MOUNT_HANDLES, ISLAND_MOUNT_INTERNAL, context, html, ilha, mount } from "ilha";
|
|
2
|
+
|
|
3
|
+
//#region src/hash.ts
|
|
4
|
+
const isBrowser$1 = typeof window !== "undefined" && typeof document !== "undefined";
|
|
5
|
+
const historyAdapter = {
|
|
6
|
+
readLocation() {
|
|
7
|
+
if (!isBrowser$1) return {
|
|
8
|
+
pathname: "/",
|
|
9
|
+
search: "",
|
|
10
|
+
hash: ""
|
|
11
|
+
};
|
|
12
|
+
return {
|
|
13
|
+
pathname: location.pathname,
|
|
14
|
+
search: location.search,
|
|
15
|
+
hash: location.hash
|
|
16
|
+
};
|
|
17
|
+
},
|
|
18
|
+
push(to, state) {
|
|
19
|
+
if (!isBrowser$1) return;
|
|
20
|
+
history.pushState(state ?? null, "", to);
|
|
21
|
+
},
|
|
22
|
+
replace(to, state) {
|
|
23
|
+
if (!isBrowser$1) return;
|
|
24
|
+
history.replaceState(state ?? null, "", to);
|
|
25
|
+
},
|
|
26
|
+
onChange(handler) {
|
|
27
|
+
if (!isBrowser$1) return () => {};
|
|
28
|
+
window.addEventListener("popstate", handler);
|
|
29
|
+
return () => window.removeEventListener("popstate", handler);
|
|
30
|
+
},
|
|
31
|
+
toLinkHref(p) {
|
|
32
|
+
return p;
|
|
33
|
+
},
|
|
34
|
+
extractLogicalPath(anchor) {
|
|
35
|
+
const href = anchor.getAttribute("href");
|
|
36
|
+
if (!href) return null;
|
|
37
|
+
if (anchor.protocol && !/^(http:|https:)$/.test(anchor.protocol)) return null;
|
|
38
|
+
if (href.startsWith("#")) return null;
|
|
39
|
+
if (!!anchor.hostname && (anchor.hostname !== location.hostname || anchor.protocol !== location.protocol)) return null;
|
|
40
|
+
return anchor.pathname + anchor.search + anchor.hash;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
function parseHash(rawHash) {
|
|
44
|
+
const stripped = rawHash.startsWith("#") ? rawHash.slice(1) : rawHash;
|
|
45
|
+
const path = stripped === "" ? "/" : stripped.startsWith("/") ? stripped : "/" + stripped;
|
|
46
|
+
const u = new URL(path, "http://_");
|
|
47
|
+
return {
|
|
48
|
+
pathname: u.pathname,
|
|
49
|
+
search: u.search,
|
|
50
|
+
hash: u.hash
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
const hashAdapter = {
|
|
54
|
+
readLocation() {
|
|
55
|
+
if (!isBrowser$1) return {
|
|
56
|
+
pathname: "/",
|
|
57
|
+
search: "",
|
|
58
|
+
hash: ""
|
|
59
|
+
};
|
|
60
|
+
return parseHash(location.hash);
|
|
61
|
+
},
|
|
62
|
+
push(to, state) {
|
|
63
|
+
if (!isBrowser$1) return;
|
|
64
|
+
history.pushState(state ?? null, "", to.startsWith("#") ? to : "#" + to);
|
|
65
|
+
},
|
|
66
|
+
replace(to, state) {
|
|
67
|
+
if (!isBrowser$1) return;
|
|
68
|
+
history.replaceState(state ?? null, "", to.startsWith("#") ? to : "#" + to);
|
|
69
|
+
},
|
|
70
|
+
onChange(handler) {
|
|
71
|
+
if (!isBrowser$1) return () => {};
|
|
72
|
+
window.addEventListener("popstate", handler);
|
|
73
|
+
window.addEventListener("hashchange", handler);
|
|
74
|
+
return () => {
|
|
75
|
+
window.removeEventListener("popstate", handler);
|
|
76
|
+
window.removeEventListener("hashchange", handler);
|
|
77
|
+
};
|
|
78
|
+
},
|
|
79
|
+
toLinkHref(p) {
|
|
80
|
+
if (p.startsWith("#")) return p;
|
|
81
|
+
return "#" + p;
|
|
82
|
+
},
|
|
83
|
+
extractLogicalPath(anchor) {
|
|
84
|
+
const href = anchor.getAttribute("href");
|
|
85
|
+
if (!href) return null;
|
|
86
|
+
if (anchor.protocol && !/^(http:|https:)$/.test(anchor.protocol)) return null;
|
|
87
|
+
if (href.startsWith("#")) {
|
|
88
|
+
const inner = href.slice(1);
|
|
89
|
+
if (inner === "" || !inner.startsWith("/")) return null;
|
|
90
|
+
return inner;
|
|
91
|
+
}
|
|
92
|
+
if (/^https?:\/\//i.test(href)) try {
|
|
93
|
+
const u = new URL(href);
|
|
94
|
+
if (u.origin !== location.origin) return null;
|
|
95
|
+
if (!u.hash || u.hash === "#") return null;
|
|
96
|
+
const inner = u.hash.slice(1);
|
|
97
|
+
if (!inner.startsWith("/")) return null;
|
|
98
|
+
return inner;
|
|
99
|
+
} catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
return href;
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
let _mode = "history";
|
|
106
|
+
let _adapter = historyAdapter;
|
|
107
|
+
/**
|
|
108
|
+
* Set the router's history mode. Call this once at app entry, before
|
|
109
|
+
* mounting any router. Defaults to "history" (HTML5 History API).
|
|
110
|
+
*
|
|
111
|
+
* Use "hash" when the document is loaded over file:// (Electron, Tauri, etc.)
|
|
112
|
+
* or any time there's no server able to serve a SPA fallback at arbitrary
|
|
113
|
+
* pathnames.
|
|
114
|
+
*
|
|
115
|
+
* Switching modes mid-session is supported but not common — listeners
|
|
116
|
+
* registered before the switch will keep using their original adapter
|
|
117
|
+
* until they're re-attached (typically by unmounting and remounting
|
|
118
|
+
* the router).
|
|
119
|
+
*/
|
|
120
|
+
function setHistoryMode(mode) {
|
|
121
|
+
_mode = mode;
|
|
122
|
+
_adapter = mode === "hash" ? hashAdapter : historyAdapter;
|
|
123
|
+
}
|
|
124
|
+
function getHistoryMode() {
|
|
125
|
+
return _mode;
|
|
126
|
+
}
|
|
127
|
+
/** Internal — used by index.ts. Not part of the public API. */
|
|
128
|
+
function getAdapter() {
|
|
129
|
+
return _adapter;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
//#endregion
|
|
133
|
+
//#region src/route-match.ts
|
|
134
|
+
function parsePattern(pattern) {
|
|
135
|
+
const segments = pattern.split("/").filter(Boolean);
|
|
136
|
+
return {
|
|
137
|
+
segments,
|
|
138
|
+
kinds: segments.map((segment) => segment.startsWith("*") ? 0 : segment.startsWith(":") ? 1 : 2)
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Match `segments` against a pathname. Returns raw (still-encoded) captured
|
|
143
|
+
* params, or `null` when the path doesn't match — the caller decides whether to
|
|
144
|
+
* decode via {@link safeDecode}.
|
|
145
|
+
*/
|
|
146
|
+
function matchSegments(segments, pathname) {
|
|
147
|
+
const pathSegments = pathname.split("/").filter(Boolean);
|
|
148
|
+
const params = {};
|
|
149
|
+
let cursor = 0;
|
|
150
|
+
for (let i = 0; i < segments.length; i++) {
|
|
151
|
+
const segment = segments[i];
|
|
152
|
+
const isLast = i === segments.length - 1;
|
|
153
|
+
if (segment.startsWith("**") && isLast) {
|
|
154
|
+
const name = segment.slice(2).replace(/^:/, "");
|
|
155
|
+
if (name) params[name] = pathSegments.slice(cursor).join("/");
|
|
156
|
+
cursor = pathSegments.length;
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
if (segment.startsWith("*")) {
|
|
160
|
+
if (pathSegments[cursor] === void 0) return null;
|
|
161
|
+
cursor++;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (segment.startsWith(":")) {
|
|
165
|
+
const value = pathSegments[cursor];
|
|
166
|
+
if (value === void 0) return null;
|
|
167
|
+
params[segment.slice(1)] = value;
|
|
168
|
+
cursor++;
|
|
169
|
+
} else if (pathSegments[cursor] === segment) cursor++;
|
|
170
|
+
else return null;
|
|
171
|
+
}
|
|
172
|
+
return cursor === pathSegments.length ? params : null;
|
|
173
|
+
}
|
|
174
|
+
/** Decode a route-param value, tolerating malformed percent-encoding. */
|
|
175
|
+
function safeDecode(value) {
|
|
176
|
+
try {
|
|
177
|
+
return decodeURIComponent(value);
|
|
178
|
+
} catch {
|
|
179
|
+
return value;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
//#endregion
|
|
184
|
+
//#region src/index.ts
|
|
185
|
+
const isBrowser = typeof window !== "undefined" && typeof document !== "undefined";
|
|
186
|
+
/**
|
|
187
|
+
* Identity function for declaring a loader. Exists purely as a type anchor and
|
|
188
|
+
* a marker for the Vite plugin to detect by export name.
|
|
189
|
+
*
|
|
190
|
+
* Loaders must read `ctx.params`/`ctx.url` rather than `useRoute()` — the
|
|
191
|
+
* route store still holds the previous route while a navigation's loader is
|
|
192
|
+
* in flight, so `useRoute().params()` inside a loader reads stale params.
|
|
193
|
+
*/
|
|
194
|
+
function loader(fn) {
|
|
195
|
+
return fn;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Declare a loader that runs in the browser when the view hydrates or on
|
|
199
|
+
* client navigations:(() => head({ title: "About" }));
|
|
200
|
+
*
|
|
201
|
+
* Inside `.server.tsx` pages the function executes over RPC when the view
|
|
202
|
+
* hydrates (its code never ships); it is a side-effect loader there — the
|
|
203
|
+
* return value cannot flow back into server-rendered island markup.
|
|
204
|
+
*/
|
|
205
|
+
loader.client = (fn) => fn;
|
|
206
|
+
function isValidRedirectStatus(status) {
|
|
207
|
+
return status >= 300 && status <= 399;
|
|
208
|
+
}
|
|
209
|
+
function isValidErrorStatus(status) {
|
|
210
|
+
return status >= 400 && status <= 599;
|
|
211
|
+
}
|
|
212
|
+
var Redirect = class {
|
|
213
|
+
__ilhaRedirect = true;
|
|
214
|
+
to;
|
|
215
|
+
status;
|
|
216
|
+
constructor(to, status = 302) {
|
|
217
|
+
this.to = to;
|
|
218
|
+
this.status = isValidRedirectStatus(status) ? status : 302;
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
var LoaderError = class {
|
|
222
|
+
__ilhaLoaderError = true;
|
|
223
|
+
status;
|
|
224
|
+
message;
|
|
225
|
+
constructor(status, message) {
|
|
226
|
+
this.message = message;
|
|
227
|
+
this.status = isValidErrorStatus(status) ? status : 500;
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
function redirect(to, status = 302) {
|
|
231
|
+
throw new Redirect(to, status);
|
|
232
|
+
}
|
|
233
|
+
function error(status, message) {
|
|
234
|
+
throw new LoaderError(status, message);
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Compose a list of loaders into a single loader. Later loaders win on key
|
|
238
|
+
* collision (page loader overrides layout loader for the same key). All loaders
|
|
239
|
+
* run concurrently within a chain since they share the same abort signal and
|
|
240
|
+
* request — re-fetching is cheap with a request-scoped cache (future work).
|
|
241
|
+
*
|
|
242
|
+
* For v1 we run them in parallel via `Promise.all`. If a loader throws a
|
|
243
|
+
* `Redirect` or `LoaderError`, the composed loader re-throws it unchanged.
|
|
244
|
+
*/
|
|
245
|
+
function composeLoaders(loaders) {
|
|
246
|
+
if (loaders.length === 0) return async () => ({});
|
|
247
|
+
if (loaders.length === 1) return loaders[0];
|
|
248
|
+
return async (ctx) => {
|
|
249
|
+
const results = await Promise.all(loaders.map((l) => l(ctx)));
|
|
250
|
+
return Object.assign({}, ...results);
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
const WRAP_LAYOUT_LEAF = Symbol.for("ilha.router.wrapLayout.leaf");
|
|
254
|
+
const WRAP_LAYOUT_HANDLER = Symbol.for("ilha.router.wrapLayout.handler");
|
|
255
|
+
const ISLAND_CALL = Symbol.for("ilha.islandCall");
|
|
256
|
+
function extractHydratableInnerHtml(block) {
|
|
257
|
+
const m = block.match(/^<([a-zA-Z][\w-]*)\s[^>]*>([\s\S]*)<\/\1>\s*$/);
|
|
258
|
+
return m ? m[2] : block;
|
|
259
|
+
}
|
|
260
|
+
function parseHydratableOpenTag(block) {
|
|
261
|
+
const m = block.match(/^<([a-zA-Z][\w-]*)\s([^>]*)>/);
|
|
262
|
+
if (!m) return null;
|
|
263
|
+
return {
|
|
264
|
+
tag: m[1],
|
|
265
|
+
attrs: m[2]
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
const K_PAGE_RAW_OPEN_RE = /<(pre|script|style|textarea)\b/i;
|
|
269
|
+
/** Skip through matching close tag, allowing nested `<pre>` (twoslash popups inside code blocks). */
|
|
270
|
+
function skipRawElement(html, i, tag) {
|
|
271
|
+
const openRe = new RegExp(`<${tag}\\b`, "gi");
|
|
272
|
+
const closeRe = new RegExp(`</${tag}>`, "gi");
|
|
273
|
+
let depth = 1;
|
|
274
|
+
let pos = i;
|
|
275
|
+
while (depth > 0 && pos < html.length) {
|
|
276
|
+
openRe.lastIndex = pos;
|
|
277
|
+
closeRe.lastIndex = pos;
|
|
278
|
+
const openM = openRe.exec(html);
|
|
279
|
+
const closeM = closeRe.exec(html);
|
|
280
|
+
if (!closeM) return null;
|
|
281
|
+
if (openM && openM.index < closeM.index) {
|
|
282
|
+
depth += 1;
|
|
283
|
+
pos = openM.index + openM[0].length;
|
|
284
|
+
} else {
|
|
285
|
+
depth -= 1;
|
|
286
|
+
if (depth === 0) return closeM.index + closeM[0].length;
|
|
287
|
+
pos = closeM.index + closeM[0].length;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return pos;
|
|
291
|
+
}
|
|
292
|
+
/** Next `<div` or `</div>` outside raw/pre/script regions (MDX + twoslash often contain `</div>` / nested `<pre>`). */
|
|
293
|
+
function nextDivToken(html, from) {
|
|
294
|
+
let i = from;
|
|
295
|
+
while (i < html.length) {
|
|
296
|
+
if (html.startsWith("<!--", i)) {
|
|
297
|
+
const end = html.indexOf("-->", i);
|
|
298
|
+
if (end === -1) return null;
|
|
299
|
+
i = end + 3;
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
const skip = html.slice(i).match(K_PAGE_RAW_OPEN_RE);
|
|
303
|
+
if (skip && skip.index != null && skip.index >= 0) {
|
|
304
|
+
const rawStart = i + skip.index;
|
|
305
|
+
let skipComment = false;
|
|
306
|
+
let search = i;
|
|
307
|
+
while (search < rawStart) {
|
|
308
|
+
const commentStart = html.indexOf("<!--", search);
|
|
309
|
+
if (commentStart === -1 || commentStart >= rawStart) break;
|
|
310
|
+
const commentEnd = html.indexOf("-->", commentStart);
|
|
311
|
+
if (commentEnd === -1) return null;
|
|
312
|
+
if (rawStart < commentEnd + 3) {
|
|
313
|
+
i = commentEnd + 3;
|
|
314
|
+
skipComment = true;
|
|
315
|
+
break;
|
|
316
|
+
}
|
|
317
|
+
search = commentEnd + 3;
|
|
318
|
+
}
|
|
319
|
+
if (skipComment) continue;
|
|
320
|
+
const nextOpen = html.indexOf("<div", i);
|
|
321
|
+
const nextClose = html.indexOf("</div>", i);
|
|
322
|
+
if (!(nextOpen !== -1 && nextOpen < rawStart || nextClose !== -1 && nextClose < rawStart)) {
|
|
323
|
+
const tag = skip[1].toLowerCase();
|
|
324
|
+
const end = skipRawElement(html, rawStart + skip[0].length, tag);
|
|
325
|
+
if (end === null) return null;
|
|
326
|
+
i = end;
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
const nextOpen = html.indexOf("<div", i);
|
|
331
|
+
const nextClose = html.indexOf("</div>", i);
|
|
332
|
+
if (nextClose === -1 && nextOpen === -1) return null;
|
|
333
|
+
if (nextOpen === -1 || nextClose !== -1 && nextClose < nextOpen) return {
|
|
334
|
+
kind: "close",
|
|
335
|
+
index: nextClose
|
|
336
|
+
};
|
|
337
|
+
return {
|
|
338
|
+
kind: "open",
|
|
339
|
+
index: nextOpen
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
function findKPageSlotSpans(layoutHtml) {
|
|
345
|
+
const spans = [];
|
|
346
|
+
for (const m of layoutHtml.matchAll(/<div\s[^>]*data-ilha-slot="k:page"[^>]*>/g)) {
|
|
347
|
+
const openEnd = m.index + m[0].length;
|
|
348
|
+
let depth = 1;
|
|
349
|
+
let i = openEnd;
|
|
350
|
+
while (depth > 0) {
|
|
351
|
+
const token = nextDivToken(layoutHtml, i);
|
|
352
|
+
if (!token) break;
|
|
353
|
+
if (token.kind === "open") {
|
|
354
|
+
depth += 1;
|
|
355
|
+
i = token.index + 4;
|
|
356
|
+
} else {
|
|
357
|
+
depth -= 1;
|
|
358
|
+
if (depth === 0) {
|
|
359
|
+
spans.push({
|
|
360
|
+
openEnd,
|
|
361
|
+
closeStart: token.index
|
|
362
|
+
});
|
|
363
|
+
break;
|
|
364
|
+
}
|
|
365
|
+
i = token.index + 6;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return spans;
|
|
370
|
+
}
|
|
371
|
+
/** Replace inner HTML of the first or innermost `k:page` slot (empty or pre-filled). */
|
|
372
|
+
function injectKPageSlot(layoutHtml, slotInnerHtml, which) {
|
|
373
|
+
const spans = findKPageSlotSpans(layoutHtml);
|
|
374
|
+
if (spans.length === 0) return layoutHtml;
|
|
375
|
+
const target = which === "innermost" ? spans[spans.length - 1] : spans[0];
|
|
376
|
+
return layoutHtml.slice(0, target.openEnd) + slotInnerHtml + layoutHtml.slice(target.closeStart);
|
|
377
|
+
}
|
|
378
|
+
function layoutHtmlWithEmptyKPage(wrappedLayout, props) {
|
|
379
|
+
const handler = wrappedLayout[WRAP_LAYOUT_HANDLER];
|
|
380
|
+
if (!handler) return wrappedLayout.toString(props);
|
|
381
|
+
const rawKeyed = (wrappedLayout[WRAP_LAYOUT_LEAF] ?? wrappedLayout).key("page");
|
|
382
|
+
const shellChild = ((partial) => rawKeyed({
|
|
383
|
+
...props,
|
|
384
|
+
...partial ?? {}
|
|
385
|
+
}));
|
|
386
|
+
Object.assign(shellChild, { toString: () => "" });
|
|
387
|
+
shellChild[ISLAND_CALL] = true;
|
|
388
|
+
return handler(shellChild).toString(props);
|
|
389
|
+
}
|
|
390
|
+
async function wrapLayoutSlotMarkup(innerWrapped, leafPage, props, opts) {
|
|
391
|
+
const pageInner = extractHydratableInnerHtml(await leafPage.hydratable(props, opts));
|
|
392
|
+
return injectKPageSlot(injectKPageSlot(layoutHtmlWithEmptyKPage(innerWrapped, props), "", "innermost"), pageInner, "innermost");
|
|
393
|
+
}
|
|
394
|
+
function wrapLayout(layout, page) {
|
|
395
|
+
const leafPage = page[WRAP_LAYOUT_LEAF] ?? page;
|
|
396
|
+
const childWrapped = leafPage === page ? null : page;
|
|
397
|
+
const rawKeyedPage = page.key("page");
|
|
398
|
+
const layoutInputRef = {};
|
|
399
|
+
const KeyedPage = ((props) => {
|
|
400
|
+
const merged = layoutInputRef.merged;
|
|
401
|
+
const slotProps = merged && typeof merged === "object" ? {
|
|
402
|
+
...merged,
|
|
403
|
+
...props ?? {}
|
|
404
|
+
} : props;
|
|
405
|
+
return rawKeyedPage(slotProps);
|
|
406
|
+
});
|
|
407
|
+
Object.assign(KeyedPage, { toString: page.toString.bind(page) });
|
|
408
|
+
KeyedPage[ISLAND_CALL] = true;
|
|
409
|
+
const Wrapped = layout(KeyedPage);
|
|
410
|
+
Wrapped[WRAP_LAYOUT_LEAF] = leafPage;
|
|
411
|
+
Wrapped[WRAP_LAYOUT_HANDLER] = layout;
|
|
412
|
+
function setLayoutMergedInput(props) {
|
|
413
|
+
layoutInputRef.merged = props;
|
|
414
|
+
}
|
|
415
|
+
function pageMountHost(host) {
|
|
416
|
+
const slots = [...host.querySelectorAll("[data-ilha-slot=\"k:page\"]")].filter((slot) => {
|
|
417
|
+
const boundary = slot.closest("[data-ilha]");
|
|
418
|
+
return boundary === null || boundary === host;
|
|
419
|
+
});
|
|
420
|
+
if (slots.length === 0) return host;
|
|
421
|
+
return slots[slots.length - 1];
|
|
422
|
+
}
|
|
423
|
+
function preparePageMountHost(outer, mountHost) {
|
|
424
|
+
const applyOuterSnapshot = (snapshot) => {
|
|
425
|
+
delete snapshot._skipOnMount;
|
|
426
|
+
mountHost.setAttribute("data-ilha-state", JSON.stringify(snapshot));
|
|
427
|
+
};
|
|
428
|
+
if (mountHost.hasAttribute("data-ilha-state")) {
|
|
429
|
+
const outerState = outer.getAttribute("data-ilha-state");
|
|
430
|
+
if (outerState) try {
|
|
431
|
+
applyOuterSnapshot(JSON.parse(outerState));
|
|
432
|
+
return;
|
|
433
|
+
} catch {}
|
|
434
|
+
const slotState = mountHost.getAttribute("data-ilha-state");
|
|
435
|
+
if (slotState) try {
|
|
436
|
+
const snapshot = JSON.parse(slotState);
|
|
437
|
+
delete snapshot._skipOnMount;
|
|
438
|
+
mountHost.setAttribute("data-ilha-state", JSON.stringify(snapshot));
|
|
439
|
+
} catch {}
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
const outerState = outer.getAttribute("data-ilha-state");
|
|
443
|
+
if (outerState) try {
|
|
444
|
+
applyOuterSnapshot(JSON.parse(outerState));
|
|
445
|
+
return;
|
|
446
|
+
} catch {}
|
|
447
|
+
if (mountHost.childNodes.length > 0) mountHost.setAttribute("data-ilha-state", "{}");
|
|
448
|
+
}
|
|
449
|
+
function wrapLeafPageMountHooks(leaf) {
|
|
450
|
+
const leafInternal = leaf[ISLAND_MOUNT_INTERNAL];
|
|
451
|
+
if (typeof leafInternal !== "function") return;
|
|
452
|
+
leaf[ISLAND_MOUNT_INTERNAL] = (host, props) => {
|
|
453
|
+
const outer = host.closest("[data-ilha]");
|
|
454
|
+
if (outer && outer !== host) preparePageMountHost(outer, host);
|
|
455
|
+
return leafInternal(host, props);
|
|
456
|
+
};
|
|
457
|
+
const leafMount = leaf.mount.bind(leaf);
|
|
458
|
+
leaf.mount = (host, props) => {
|
|
459
|
+
const outer = host.closest("[data-ilha]");
|
|
460
|
+
if (outer && outer !== host) preparePageMountHost(outer, host);
|
|
461
|
+
return leafMount(host, props);
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
wrapLeafPageMountHooks(leafPage);
|
|
465
|
+
const pageHandles = /* @__PURE__ */ new Map();
|
|
466
|
+
const pageInternalBase = page[ISLAND_MOUNT_INTERNAL];
|
|
467
|
+
if (typeof pageInternalBase === "function") page[ISLAND_MOUNT_INTERNAL] = (host, props) => {
|
|
468
|
+
const handle = pageInternalBase(host, props);
|
|
469
|
+
const entry = {
|
|
470
|
+
handle,
|
|
471
|
+
mountProps: props
|
|
472
|
+
};
|
|
473
|
+
pageHandles.set(host, entry);
|
|
474
|
+
return {
|
|
475
|
+
unmount: () => {
|
|
476
|
+
if (pageHandles.get(host) === entry) pageHandles.delete(host);
|
|
477
|
+
return handle.unmount();
|
|
478
|
+
},
|
|
479
|
+
updateProps: (p) => {
|
|
480
|
+
entry.mountProps = p;
|
|
481
|
+
handle.updateProps(p);
|
|
482
|
+
}
|
|
483
|
+
};
|
|
484
|
+
};
|
|
485
|
+
/**
|
|
486
|
+
* In-place prop update for one mounted layout instance: refresh the
|
|
487
|
+
* merged-input ref (so any layout re-render passes fresh props to `k:page`),
|
|
488
|
+
* push the merged loader props directly into the page mounted under *this*
|
|
489
|
+
* layout host (spread over its latest slot props so layout-provided extras
|
|
490
|
+
* survive), then update the layout island's own input. Page-first ordering
|
|
491
|
+
* lets a layout that *does* read its input re-render afterwards and win with
|
|
492
|
+
* its own fresher slot props.
|
|
493
|
+
*/
|
|
494
|
+
const layoutUpdateProps = (layoutHost, coreUpdate) => (p) => {
|
|
495
|
+
setLayoutMergedInput(p);
|
|
496
|
+
for (const [pageHost, entry] of pageHandles) if (layoutHost.contains(pageHost)) entry.handle.updateProps({
|
|
497
|
+
...entry.mountProps ?? {},
|
|
498
|
+
...p ?? {}
|
|
499
|
+
});
|
|
500
|
+
coreUpdate?.(p);
|
|
501
|
+
};
|
|
502
|
+
const layoutMount = Wrapped.mount.bind(Wrapped);
|
|
503
|
+
const layoutInternal = Wrapped[ISLAND_MOUNT_INTERNAL];
|
|
504
|
+
function prepareLayoutMountHost(host) {
|
|
505
|
+
preparePageMountHost(host, pageMountHost(host));
|
|
506
|
+
}
|
|
507
|
+
Wrapped.mount = (host, props) => {
|
|
508
|
+
setLayoutMergedInput(props);
|
|
509
|
+
prepareLayoutMountHost(host);
|
|
510
|
+
const unmount = layoutMount(host, props);
|
|
511
|
+
const core = ISLAND_MOUNT_HANDLES.get(host);
|
|
512
|
+
ISLAND_MOUNT_HANDLES.set(host, {
|
|
513
|
+
unmount,
|
|
514
|
+
updateProps: layoutUpdateProps(host, core?.updateProps)
|
|
515
|
+
});
|
|
516
|
+
return unmount;
|
|
517
|
+
};
|
|
518
|
+
Wrapped[ISLAND_MOUNT_INTERNAL] = (host, props) => {
|
|
519
|
+
setLayoutMergedInput(props);
|
|
520
|
+
prepareLayoutMountHost(host);
|
|
521
|
+
const base = typeof layoutInternal === "function" ? layoutInternal(host, props) : {
|
|
522
|
+
unmount: layoutMount(host, props),
|
|
523
|
+
updateProps: () => {}
|
|
524
|
+
};
|
|
525
|
+
const enhanced = {
|
|
526
|
+
unmount: base.unmount,
|
|
527
|
+
updateProps: layoutUpdateProps(host, base.updateProps)
|
|
528
|
+
};
|
|
529
|
+
ISLAND_MOUNT_HANDLES.set(host, enhanced);
|
|
530
|
+
return enhanced;
|
|
531
|
+
};
|
|
532
|
+
Wrapped.hydratable = async (props, opts) => {
|
|
533
|
+
if (!opts?.name) throw new Error("wrapLayout: hydratable requires options.name");
|
|
534
|
+
const resolvedProps = props ?? {};
|
|
535
|
+
setLayoutMergedInput(resolvedProps);
|
|
536
|
+
const pageBlock = await leafPage.hydratable(resolvedProps, opts);
|
|
537
|
+
const open = parseHydratableOpenTag(pageBlock);
|
|
538
|
+
if (!open) return pageBlock;
|
|
539
|
+
let slotContent = extractHydratableInnerHtml(pageBlock);
|
|
540
|
+
if (childWrapped) slotContent = await wrapLayoutSlotMarkup(childWrapped, leafPage, resolvedProps, opts);
|
|
541
|
+
const layoutInnerOut = injectKPageSlot(injectKPageSlot(layoutHtmlWithEmptyKPage(Wrapped, resolvedProps), "", "first"), slotContent, "first");
|
|
542
|
+
return `<${open.tag} ${open.attrs}>${layoutInnerOut}</${open.tag}>`;
|
|
543
|
+
};
|
|
544
|
+
return Wrapped;
|
|
545
|
+
}
|
|
546
|
+
function wrapError(handler, page) {
|
|
547
|
+
const Wrapper = ilha.render(() => {
|
|
548
|
+
try {
|
|
549
|
+
return page.toString();
|
|
550
|
+
} catch (e) {
|
|
551
|
+
const route = {
|
|
552
|
+
path: routePath(),
|
|
553
|
+
params: routeParams(),
|
|
554
|
+
search: routeSearch(),
|
|
555
|
+
hash: routeHash()
|
|
556
|
+
};
|
|
557
|
+
return handler({
|
|
558
|
+
message: e.message,
|
|
559
|
+
status: e.status,
|
|
560
|
+
stack: e.stack
|
|
561
|
+
}, route).toString();
|
|
562
|
+
}
|
|
563
|
+
});
|
|
564
|
+
Wrapper.mount = (host, props) => {
|
|
565
|
+
try {
|
|
566
|
+
return page.mount(host, props);
|
|
567
|
+
} catch (e) {
|
|
568
|
+
const route = {
|
|
569
|
+
path: routePath(),
|
|
570
|
+
params: routeParams(),
|
|
571
|
+
search: routeSearch(),
|
|
572
|
+
hash: routeHash()
|
|
573
|
+
};
|
|
574
|
+
const errorIsland = handler({
|
|
575
|
+
message: e.message,
|
|
576
|
+
status: e.status,
|
|
577
|
+
stack: e.stack
|
|
578
|
+
}, route);
|
|
579
|
+
host.innerHTML = errorIsland.toString();
|
|
580
|
+
return errorIsland.mount(host, props);
|
|
581
|
+
}
|
|
582
|
+
};
|
|
583
|
+
Wrapper[ISLAND_MOUNT_INTERNAL] = (host, props) => {
|
|
584
|
+
try {
|
|
585
|
+
const pageInternal = page[ISLAND_MOUNT_INTERNAL];
|
|
586
|
+
if (typeof pageInternal === "function") return pageInternal(host, props);
|
|
587
|
+
return {
|
|
588
|
+
unmount: page.mount(host, props),
|
|
589
|
+
updateProps: () => {}
|
|
590
|
+
};
|
|
591
|
+
} catch (e) {
|
|
592
|
+
const route = {
|
|
593
|
+
path: routePath(),
|
|
594
|
+
params: routeParams(),
|
|
595
|
+
search: routeSearch(),
|
|
596
|
+
hash: routeHash()
|
|
597
|
+
};
|
|
598
|
+
const errorIsland = handler({
|
|
599
|
+
message: e.message,
|
|
600
|
+
status: e.status,
|
|
601
|
+
stack: e.stack
|
|
602
|
+
}, route);
|
|
603
|
+
host.innerHTML = errorIsland.toString();
|
|
604
|
+
return {
|
|
605
|
+
unmount: errorIsland.mount(host, props),
|
|
606
|
+
updateProps: () => {}
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
};
|
|
610
|
+
Wrapper.hydratable = async (props, opts) => {
|
|
611
|
+
if (!opts?.name) throw new Error("wrapError: hydratable requires options.name");
|
|
612
|
+
return page.hydratable(props ?? {}, opts);
|
|
613
|
+
};
|
|
614
|
+
return Wrapper;
|
|
615
|
+
}
|
|
616
|
+
function defineLayout(layout) {
|
|
617
|
+
return layout;
|
|
618
|
+
}
|
|
619
|
+
function buildReverseRegistry(registry) {
|
|
620
|
+
const map = /* @__PURE__ */ new Map();
|
|
621
|
+
for (const [name, island] of Object.entries(registry)) if (!map.has(island)) map.set(island, name);
|
|
622
|
+
return map;
|
|
623
|
+
}
|
|
624
|
+
/** Path of the loader endpoint served by the Vite plugin / production adapter. */
|
|
625
|
+
const LOADER_ENDPOINT = "/__ilha/loader";
|
|
626
|
+
/** In-memory cache for prefetched loader data, keyed by path+search. */
|
|
627
|
+
const prefetchCache = /* @__PURE__ */ new Map();
|
|
628
|
+
/** How long a hover prefetch stays servable. Consumed-on-use regardless. */
|
|
629
|
+
const PREFETCH_TTL_MS = 3e4;
|
|
630
|
+
/** Mirrors the last router's `viewTransitions` option (module-level like `_notFound`). */
|
|
631
|
+
let _viewTransitions = false;
|
|
632
|
+
/**
|
|
633
|
+
* Run a synchronous DOM mutation, wrapped in a view transition when enabled
|
|
634
|
+
* and supported (the callback runs async — after the browser snapshots the old
|
|
635
|
+
* view). Resolves with the mutation's result either way.
|
|
636
|
+
*/
|
|
637
|
+
async function withViewSwap(mutate) {
|
|
638
|
+
const start = document.startViewTransition?.bind(document);
|
|
639
|
+
if (!_viewTransitions || !start) return mutate();
|
|
640
|
+
let result;
|
|
641
|
+
let thrown;
|
|
642
|
+
let failed = false;
|
|
643
|
+
await start(() => {
|
|
644
|
+
try {
|
|
645
|
+
result = mutate();
|
|
646
|
+
} catch (e) {
|
|
647
|
+
failed = true;
|
|
648
|
+
thrown = e;
|
|
649
|
+
throw e;
|
|
650
|
+
}
|
|
651
|
+
}).updateCallbackDone?.catch(() => {});
|
|
652
|
+
if (failed) throw thrown;
|
|
653
|
+
return result;
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* Execute a loader registered in the browser (manual `.route(p, island, loader)`
|
|
657
|
+
* or FS-routing `loader.client`) and map its result to the loader endpoint's wire
|
|
658
|
+
* shape, so all client mount paths handle both sources identically. Loader
|
|
659
|
+
* `head` contributions are dropped, matching the endpoint fetch path.
|
|
660
|
+
*/
|
|
661
|
+
async function runLocalLoader(loader, matchParams, pathWithSearch, signal) {
|
|
662
|
+
const url = new URL(pathWithSearch, location.origin);
|
|
663
|
+
const params = extractParams(matchParams);
|
|
664
|
+
const headEntries = [];
|
|
665
|
+
const result = await executeLoader(loader, url, params, defaultRequest(url), signal ?? new AbortController().signal, (input) => headEntries.push(input));
|
|
666
|
+
signal?.throwIfAborted();
|
|
667
|
+
if (result.kind === "redirect") {
|
|
668
|
+
const safe = resolveRedirectTarget(result.to, url, _allowExternalRedirects);
|
|
669
|
+
if (!safe.ok) {
|
|
670
|
+
console.warn(`[ilha-router] Blocked unsafe redirect target "${result.to}". Set allowExternalRedirects: true to allow cross-origin redirects.`);
|
|
671
|
+
return {
|
|
672
|
+
kind: "error",
|
|
673
|
+
status: 500,
|
|
674
|
+
message: "Unsafe redirect target"
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
return {
|
|
678
|
+
kind: "redirect",
|
|
679
|
+
to: safe.to,
|
|
680
|
+
status: result.status
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
if (result.kind === "data") {
|
|
684
|
+
const data = envelopLoad(result.data);
|
|
685
|
+
return headEntries.length > 0 ? {
|
|
686
|
+
kind: "data",
|
|
687
|
+
data,
|
|
688
|
+
headEntries
|
|
689
|
+
} : {
|
|
690
|
+
kind: "data",
|
|
691
|
+
data
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
return result;
|
|
695
|
+
}
|
|
696
|
+
async function fetchLoaderData(pathWithSearch, signal) {
|
|
697
|
+
const cached = prefetchCache.get(pathWithSearch);
|
|
698
|
+
if (cached) {
|
|
699
|
+
prefetchCache.delete(pathWithSearch);
|
|
700
|
+
if (Date.now() <= cached.expires) try {
|
|
701
|
+
signal?.throwIfAborted();
|
|
702
|
+
const result = await cached.promise;
|
|
703
|
+
signal?.throwIfAborted();
|
|
704
|
+
return result;
|
|
705
|
+
} catch (e) {
|
|
706
|
+
if (e?.name === "AbortError") throw e;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
const pathOnly = pathWithSearch.split("?")[0] ?? "";
|
|
710
|
+
const localMatch = matchRoute(_routes, pathOnly);
|
|
711
|
+
const localLoader = localMatch?.data?.clientLoader ?? localMatch?.data?.loader;
|
|
712
|
+
if (localLoader) return runLocalLoader(localLoader, localMatch?.params, pathWithSearch, signal);
|
|
713
|
+
const url = `${LOADER_ENDPOINT}?path=${encodeURIComponent(pathWithSearch)}`;
|
|
714
|
+
try {
|
|
715
|
+
const res = await fetch(url, {
|
|
716
|
+
signal,
|
|
717
|
+
headers: { accept: "application/json" }
|
|
718
|
+
});
|
|
719
|
+
if (!res.ok) {
|
|
720
|
+
try {
|
|
721
|
+
const body = await res.json();
|
|
722
|
+
if (body && typeof body === "object" && "kind" in body) return body;
|
|
723
|
+
} catch {}
|
|
724
|
+
return {
|
|
725
|
+
kind: "error",
|
|
726
|
+
status: res.status,
|
|
727
|
+
message: res.statusText
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
const parsed = await res.json();
|
|
731
|
+
if (parsed.kind === "data" && !localMatch?.data?.clientLoader && parsed.data && typeof parsed.data === "object" && !("load" in parsed.data)) return {
|
|
732
|
+
...parsed,
|
|
733
|
+
data: envelopLoad(parsed.data)
|
|
734
|
+
};
|
|
735
|
+
return parsed;
|
|
736
|
+
} catch (e) {
|
|
737
|
+
if (e?.name === "AbortError") throw e;
|
|
738
|
+
return {
|
|
739
|
+
kind: "error",
|
|
740
|
+
status: 0,
|
|
741
|
+
message: e?.message ?? "network error"
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
/**
|
|
746
|
+
* Prefetch loader data for a given path. Safe to call repeatedly — a single
|
|
747
|
+
* inflight request is reused until it either resolves (and is consumed by
|
|
748
|
+
* navigation) or is superseded by another prefetch.
|
|
749
|
+
*/
|
|
750
|
+
function prefetch(pathWithSearch) {
|
|
751
|
+
if (!isBrowser) return;
|
|
752
|
+
const existing = prefetchCache.get(pathWithSearch);
|
|
753
|
+
if (existing && Date.now() <= existing.expires) return;
|
|
754
|
+
const pathOnly = pathWithSearch.split("?")[0] ?? "";
|
|
755
|
+
if (!matchRoute(_routes, pathOnly)?.data?.hasLoader) return;
|
|
756
|
+
const promise = fetchLoaderData(pathWithSearch).catch((e) => {
|
|
757
|
+
return {
|
|
758
|
+
kind: "error",
|
|
759
|
+
status: 0,
|
|
760
|
+
message: e?.message ?? "prefetch failed"
|
|
761
|
+
};
|
|
762
|
+
});
|
|
763
|
+
prefetchCache.set(pathWithSearch, {
|
|
764
|
+
promise,
|
|
765
|
+
expires: Date.now() + PREFETCH_TTL_MS
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
const noopRouteHandle = () => ({
|
|
769
|
+
unmount: () => {},
|
|
770
|
+
updateProps: null
|
|
771
|
+
});
|
|
772
|
+
/** Mount an island keeping the full internal handle so later same-island
|
|
773
|
+
* navigations can push new loader props instead of remounting. */
|
|
774
|
+
function mountIslandWithHandle(island, host, props) {
|
|
775
|
+
const internal = island[ISLAND_MOUNT_INTERNAL];
|
|
776
|
+
if (typeof internal === "function") {
|
|
777
|
+
const h = internal(host, props);
|
|
778
|
+
return {
|
|
779
|
+
unmount: () => void h.unmount(),
|
|
780
|
+
updateProps: (p) => h.updateProps(p)
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
return {
|
|
784
|
+
unmount: island.mount(host, props),
|
|
785
|
+
updateProps: null
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
/**
|
|
789
|
+
* Same-island fast path: fetch fresh loader data and push it into the mounted
|
|
790
|
+
* island via `updateProps` — ilha's fine-grained morph reconciles the DOM, so
|
|
791
|
+
* focus, caret, selection, and scroll survive (the reason persistQuery-driven
|
|
792
|
+
* filter inputs don't blur while typing). Returns "updated" when applied (or
|
|
793
|
+
* redirected), "remount" when the caller must run the full teardown + mount
|
|
794
|
+
* path (loader error / not-found need boundary DOM; the full path re-fetches,
|
|
795
|
+
* accepted for these rare cases). Throws AbortError when superseded.
|
|
796
|
+
*/
|
|
797
|
+
async function updateRouteInPlace(handle, pathWithSearch, signal) {
|
|
798
|
+
if (!handle.updateProps) return "remount";
|
|
799
|
+
const result = !!matchRoute(_routes, pathWithSearch.split("?")[0] ?? "")?.data?.hasLoader ? await fetchLoaderData(pathWithSearch, signal) : {
|
|
800
|
+
kind: "data",
|
|
801
|
+
data: {}
|
|
802
|
+
};
|
|
803
|
+
signal.throwIfAborted();
|
|
804
|
+
if (result.kind === "redirect") {
|
|
805
|
+
clientRedirect(result.to);
|
|
806
|
+
return "updated";
|
|
807
|
+
}
|
|
808
|
+
if (result.kind !== "data") return "remount";
|
|
809
|
+
if (result.headEntries?.length) applyHeadEntriesToDocument([...result.headEntries]);
|
|
810
|
+
handle.updateProps(result.data);
|
|
811
|
+
return "updated";
|
|
812
|
+
}
|
|
813
|
+
/**
|
|
814
|
+
* Mounts a route island with proper hydration for client-side navigation.
|
|
815
|
+
* Looks up the island in the reverse registry, runs the loader (via fetch),
|
|
816
|
+
* renders it with hydration markers, and mounts it for interactivity.
|
|
817
|
+
*/
|
|
818
|
+
async function mountRouteWithHydration(island, host, pathWithSearch, signal, registry, reverseRegistry) {
|
|
819
|
+
if (!island) {
|
|
820
|
+
if (_notFound) {
|
|
821
|
+
const nf = _notFound;
|
|
822
|
+
return withViewSwap(() => {
|
|
823
|
+
host.innerHTML = `<div data-router-view data-router-not-found>${nf.toString()}</div>`;
|
|
824
|
+
const nfHost = host.firstElementChild;
|
|
825
|
+
return {
|
|
826
|
+
unmount: nfHost ? nf.mount(nfHost) : () => {},
|
|
827
|
+
updateProps: null
|
|
828
|
+
};
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
await withViewSwap(() => {
|
|
832
|
+
host.innerHTML = `<div data-router-empty></div>`;
|
|
833
|
+
});
|
|
834
|
+
return noopRouteHandle();
|
|
835
|
+
}
|
|
836
|
+
const clientMatch = matchRoute(_routes, pathWithSearch.split("?")[0] ?? "");
|
|
837
|
+
const hasLoader = !!clientMatch?.data?.hasLoader;
|
|
838
|
+
let props = {};
|
|
839
|
+
const loaderResult = hasLoader ? await fetchLoaderData(pathWithSearch, signal) : {
|
|
840
|
+
kind: "data",
|
|
841
|
+
data: {}
|
|
842
|
+
};
|
|
843
|
+
if (loaderResult.kind === "redirect") {
|
|
844
|
+
clientRedirect(loaderResult.to);
|
|
845
|
+
return noopRouteHandle();
|
|
846
|
+
}
|
|
847
|
+
if (loaderResult.kind === "error") {
|
|
848
|
+
const boundary = clientMatch?.data?.errorHandler;
|
|
849
|
+
if (boundary) return withViewSwap(() => ({
|
|
850
|
+
unmount: mountLoaderErrorBoundary(boundary, host, loaderResult.status, loaderResult.message),
|
|
851
|
+
updateProps: null
|
|
852
|
+
}));
|
|
853
|
+
const escaped = escapeHtml(loaderResult.message);
|
|
854
|
+
await withViewSwap(() => {
|
|
855
|
+
host.innerHTML = `<div data-router-view data-router-error="${loaderResult.status}">${escaped}</div>`;
|
|
856
|
+
});
|
|
857
|
+
return noopRouteHandle();
|
|
858
|
+
}
|
|
859
|
+
if (loaderResult.kind === "not-found") {
|
|
860
|
+
await withViewSwap(() => {
|
|
861
|
+
host.innerHTML = `<div data-router-empty></div>`;
|
|
862
|
+
});
|
|
863
|
+
return noopRouteHandle();
|
|
864
|
+
}
|
|
865
|
+
props = loaderResult.data;
|
|
866
|
+
const headStore = { entries: [...loaderResult.headEntries ?? []] };
|
|
867
|
+
if (!registry) {
|
|
868
|
+
console.warn("[ilha-router] No registry provided for client-side navigation. Island will not be interactive.");
|
|
869
|
+
const html = await withHeadStore(headStore, () => island.toString(props));
|
|
870
|
+
await withViewSwap(() => {
|
|
871
|
+
applyHeadEntriesToDocument(headStore.entries);
|
|
872
|
+
host.innerHTML = `<div data-router-view>${html}</div>`;
|
|
873
|
+
});
|
|
874
|
+
return noopRouteHandle();
|
|
875
|
+
}
|
|
876
|
+
const name = reverseRegistry?.get(island) ?? Object.entries(registry).find(([, v]) => v === island)?.[0];
|
|
877
|
+
if (!name) {
|
|
878
|
+
console.warn("[ilha-router] Island not found in registry for client-side navigation.");
|
|
879
|
+
const html = await withHeadStore(headStore, () => island.toString(props));
|
|
880
|
+
await withViewSwap(() => {
|
|
881
|
+
applyHeadEntriesToDocument(headStore.entries);
|
|
882
|
+
host.innerHTML = `<div data-router-view>${html}</div>`;
|
|
883
|
+
});
|
|
884
|
+
return noopRouteHandle();
|
|
885
|
+
}
|
|
886
|
+
const html = await withHeadStore(headStore, () => island.hydratable(props, {
|
|
887
|
+
name,
|
|
888
|
+
as: "div",
|
|
889
|
+
snapshot: true
|
|
890
|
+
}));
|
|
891
|
+
return withViewSwap(() => {
|
|
892
|
+
applyHeadEntriesToDocument(headStore.entries);
|
|
893
|
+
host.innerHTML = `<div data-router-view>${html}</div>`;
|
|
894
|
+
const islandHost = host.querySelector(`[data-ilha="${name}"]`);
|
|
895
|
+
return islandHost ? mountIslandWithHandle(island, islandHost) : noopRouteHandle();
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
/** Render + mount a `+error` boundary island for a failed loader. */
|
|
899
|
+
function mountLoaderErrorBoundary(boundary, host, status, message) {
|
|
900
|
+
try {
|
|
901
|
+
const errorIsland = boundary({
|
|
902
|
+
message,
|
|
903
|
+
status
|
|
904
|
+
}, {
|
|
905
|
+
path: routePath(),
|
|
906
|
+
params: routeParams(),
|
|
907
|
+
search: routeSearch(),
|
|
908
|
+
hash: routeHash()
|
|
909
|
+
});
|
|
910
|
+
host.innerHTML = `<div data-router-view data-router-error="${status}">${errorIsland.toString()}</div>`;
|
|
911
|
+
const ehHost = host.firstElementChild;
|
|
912
|
+
return ehHost ? errorIsland.mount(ehHost) : () => {};
|
|
913
|
+
} catch (e) {
|
|
914
|
+
console.error("[ilha-router] error boundary threw while rendering a loader error:", e);
|
|
915
|
+
host.innerHTML = `<div data-router-view data-router-error="${status}"></div>`;
|
|
916
|
+
return () => {};
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
let _routeAls = null;
|
|
920
|
+
let _routeAlsInit = null;
|
|
921
|
+
async function getRouteAlsAsync() {
|
|
922
|
+
if (_routeAls) return _routeAls;
|
|
923
|
+
if (!_routeAlsInit) _routeAlsInit = import("node:async_hooks").then(({ AsyncLocalStorage }) => {
|
|
924
|
+
_routeAls = new AsyncLocalStorage();
|
|
925
|
+
return _routeAls;
|
|
926
|
+
});
|
|
927
|
+
return _routeAlsInit;
|
|
928
|
+
}
|
|
929
|
+
if (!isBrowser) getRouteAlsAsync().catch(() => {});
|
|
930
|
+
function activeRouteStore() {
|
|
931
|
+
if (isBrowser) return null;
|
|
932
|
+
return _routeAls?.getStore() ?? null;
|
|
933
|
+
}
|
|
934
|
+
function freshRouteStore() {
|
|
935
|
+
return {
|
|
936
|
+
path: "",
|
|
937
|
+
params: {},
|
|
938
|
+
search: "",
|
|
939
|
+
hash: "",
|
|
940
|
+
island: null
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
const _routePathSig = context("router.path", "");
|
|
944
|
+
const _routeParamsSig = context("router.params", {});
|
|
945
|
+
const _routeSearchSig = context("router.search", "");
|
|
946
|
+
const _routeHashSig = context("router.hash", "");
|
|
947
|
+
function routePath(value) {
|
|
948
|
+
const store = activeRouteStore();
|
|
949
|
+
if (arguments.length > 0) {
|
|
950
|
+
if (store) return store.path = value;
|
|
951
|
+
return _routePathSig(value), value;
|
|
952
|
+
}
|
|
953
|
+
return store ? store.path : _routePathSig();
|
|
954
|
+
}
|
|
955
|
+
function routeParams(value) {
|
|
956
|
+
const store = activeRouteStore();
|
|
957
|
+
if (arguments.length > 0) {
|
|
958
|
+
if (store) return store.params = value;
|
|
959
|
+
return _routeParamsSig(value), value;
|
|
960
|
+
}
|
|
961
|
+
return store ? store.params : _routeParamsSig();
|
|
962
|
+
}
|
|
963
|
+
function routeSearch(value) {
|
|
964
|
+
const store = activeRouteStore();
|
|
965
|
+
if (arguments.length > 0) {
|
|
966
|
+
if (store) return store.search = value;
|
|
967
|
+
return _routeSearchSig(value), value;
|
|
968
|
+
}
|
|
969
|
+
return store ? store.search : _routeSearchSig();
|
|
970
|
+
}
|
|
971
|
+
function routeHash(value) {
|
|
972
|
+
const store = activeRouteStore();
|
|
973
|
+
if (arguments.length > 0) {
|
|
974
|
+
if (store) return store.hash = value;
|
|
975
|
+
return _routeHashSig(value), value;
|
|
976
|
+
}
|
|
977
|
+
return store ? store.hash : _routeHashSig();
|
|
978
|
+
}
|
|
979
|
+
const _navigatingSig = context("router.navigating", 0);
|
|
980
|
+
/** Reactive: `true` while a client navigation (loader fetch + view swap) is in flight. */
|
|
981
|
+
function navigating() {
|
|
982
|
+
return _navigatingSig() > 0;
|
|
983
|
+
}
|
|
984
|
+
let _revalidate = null;
|
|
985
|
+
/**
|
|
986
|
+
* Re-run the current route's loader and re-render the view with fresh data —
|
|
987
|
+
* e.g. after a mutation. Resolves when the view has updated. No-op on the
|
|
988
|
+
* server or when no router is mounted.
|
|
989
|
+
*/
|
|
990
|
+
function invalidate() {
|
|
991
|
+
if (!isBrowser || !_revalidate) return Promise.resolve();
|
|
992
|
+
return _revalidate();
|
|
993
|
+
}
|
|
994
|
+
/** Mark a navigation as started; returns an idempotent settle callback. */
|
|
995
|
+
function beginNavigation() {
|
|
996
|
+
if (!isBrowser) return () => {};
|
|
997
|
+
_navigatingSig(_navigatingSig() + 1);
|
|
998
|
+
document.documentElement.setAttribute("data-router-pending", "");
|
|
999
|
+
let done = false;
|
|
1000
|
+
return () => {
|
|
1001
|
+
if (done) return;
|
|
1002
|
+
done = true;
|
|
1003
|
+
_navigatingSig(Math.max(0, _navigatingSig() - 1));
|
|
1004
|
+
if (_navigatingSig() === 0) document.documentElement.removeAttribute("data-router-pending");
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
1007
|
+
const REQUEST_ALS_KEY = Symbol.for("ilha.requestAls");
|
|
1008
|
+
/**
|
|
1009
|
+
* The context seeding the current server-island render. Always returns an
|
|
1010
|
+
* object — check `.request` rather than the context itself. Never throws, so
|
|
1011
|
+
* render functions stay safe under plain `toString()` calls and in tests.
|
|
1012
|
+
*
|
|
1013
|
+
* Only meaningful inside `.server.tsx` render functions (page SSR or frame
|
|
1014
|
+
* rendering); client code never has a reason to call it.
|
|
1015
|
+
*/
|
|
1016
|
+
function useContext() {
|
|
1017
|
+
return { request: globalThis[REQUEST_ALS_KEY]?.getStore?.() };
|
|
1018
|
+
}
|
|
1019
|
+
function useRoute() {
|
|
1020
|
+
return {
|
|
1021
|
+
path: routePath,
|
|
1022
|
+
params: routeParams,
|
|
1023
|
+
search: routeSearch,
|
|
1024
|
+
hash: routeHash,
|
|
1025
|
+
navigating
|
|
1026
|
+
};
|
|
1027
|
+
}
|
|
1028
|
+
const _activeIslandSig = context("router.active", null);
|
|
1029
|
+
function activeIsland(value) {
|
|
1030
|
+
const store = activeRouteStore();
|
|
1031
|
+
if (arguments.length > 0) {
|
|
1032
|
+
const next = value ?? null;
|
|
1033
|
+
if (store) return store.island = next;
|
|
1034
|
+
if (next === null) _activeIslandSig(null);
|
|
1035
|
+
else _activeIslandSig(() => next);
|
|
1036
|
+
return next;
|
|
1037
|
+
}
|
|
1038
|
+
return store ? store.island : _activeIslandSig();
|
|
1039
|
+
}
|
|
1040
|
+
let _routes = createRouteRegistry();
|
|
1041
|
+
/** Wrap loader data in the load envelope (plain, serializable). */
|
|
1042
|
+
function envelopLoad(data) {
|
|
1043
|
+
return { load: {
|
|
1044
|
+
loading: false,
|
|
1045
|
+
value: data ?? {},
|
|
1046
|
+
error: void 0
|
|
1047
|
+
} };
|
|
1048
|
+
}
|
|
1049
|
+
function createRouteRegistry() {
|
|
1050
|
+
return [];
|
|
1051
|
+
}
|
|
1052
|
+
function addRouteEntry(registry, pattern, data) {
|
|
1053
|
+
const { segments, kinds } = parsePattern(pattern);
|
|
1054
|
+
let index = 0;
|
|
1055
|
+
while (index < registry.length) {
|
|
1056
|
+
const other = registry[index].kinds;
|
|
1057
|
+
let cmp = 0;
|
|
1058
|
+
for (let i = 0; i < Math.min(kinds.length, other.length); i++) if (kinds[i] !== other[i]) {
|
|
1059
|
+
cmp = kinds[i] - other[i];
|
|
1060
|
+
break;
|
|
1061
|
+
}
|
|
1062
|
+
if (cmp === 0) cmp = other.length - kinds.length;
|
|
1063
|
+
if (cmp > 0) break;
|
|
1064
|
+
index++;
|
|
1065
|
+
}
|
|
1066
|
+
registry.splice(index, 0, {
|
|
1067
|
+
pattern,
|
|
1068
|
+
segments,
|
|
1069
|
+
kinds,
|
|
1070
|
+
data
|
|
1071
|
+
});
|
|
1072
|
+
}
|
|
1073
|
+
function matchRoute(registry, pathname) {
|
|
1074
|
+
for (const entry of registry) {
|
|
1075
|
+
const params = matchSegments(entry.segments, pathname);
|
|
1076
|
+
if (params) return {
|
|
1077
|
+
data: entry.data,
|
|
1078
|
+
params
|
|
1079
|
+
};
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
function extractParams(matchParams) {
|
|
1083
|
+
const params = {};
|
|
1084
|
+
if (matchParams) for (const [k, v] of Object.entries(matchParams)) params[k] = safeDecode(v);
|
|
1085
|
+
return params;
|
|
1086
|
+
}
|
|
1087
|
+
function syncRouteFromURL(url, routes = _routes) {
|
|
1088
|
+
const parsed = typeof url === "string" ? new URL(url, "http://localhost") : url;
|
|
1089
|
+
const match = matchRoute(routes, parsed.pathname);
|
|
1090
|
+
routePath(parsed.pathname);
|
|
1091
|
+
routeParams(extractParams(match?.params));
|
|
1092
|
+
routeSearch(parsed.search);
|
|
1093
|
+
routeHash(parsed.hash);
|
|
1094
|
+
activeIsland(match?.data?.island ?? null);
|
|
1095
|
+
}
|
|
1096
|
+
/** Client-only fast path — reads directly from the history adapter (location in history mode, hash content in hash mode). */
|
|
1097
|
+
function syncRouteFromLocation() {
|
|
1098
|
+
const loc = getAdapter().readLocation();
|
|
1099
|
+
const match = matchRoute(_routes, loc.pathname);
|
|
1100
|
+
routePath(loc.pathname);
|
|
1101
|
+
routeParams(extractParams(match?.params));
|
|
1102
|
+
routeSearch(loc.search);
|
|
1103
|
+
routeHash(loc.hash);
|
|
1104
|
+
activeIsland(match?.data?.island ?? null);
|
|
1105
|
+
}
|
|
1106
|
+
/**
|
|
1107
|
+
* Prime route context signals from the current `location` so that islands
|
|
1108
|
+
* hydrated by `ilha.mount()` see the correct route values on their first
|
|
1109
|
+
* render — preventing a mismatch morph that would destroy hydrated bindings.
|
|
1110
|
+
*/
|
|
1111
|
+
function prime() {
|
|
1112
|
+
if (isBrowser) syncRouteFromLocation();
|
|
1113
|
+
}
|
|
1114
|
+
const _beforeNavigateHooks = /* @__PURE__ */ new Set();
|
|
1115
|
+
const _afterNavigateHooks = /* @__PURE__ */ new Set();
|
|
1116
|
+
/**
|
|
1117
|
+
* Run before a programmatic navigation commits. Call `nav.cancel()` to keep
|
|
1118
|
+
* the current URL (e.g. unsaved-changes guards). Not invoked for browser
|
|
1119
|
+
* back/forward — the URL has already changed by the time `popstate` fires.
|
|
1120
|
+
* Returns an unsubscribe function.
|
|
1121
|
+
*/
|
|
1122
|
+
function beforeNavigate(fn) {
|
|
1123
|
+
_beforeNavigateHooks.add(fn);
|
|
1124
|
+
return () => _beforeNavigateHooks.delete(fn);
|
|
1125
|
+
}
|
|
1126
|
+
/** Run after a navigation (push, replace, or pop) has committed. Returns an unsubscribe function. */
|
|
1127
|
+
function afterNavigate(fn) {
|
|
1128
|
+
_afterNavigateHooks.add(fn);
|
|
1129
|
+
return () => _afterNavigateHooks.delete(fn);
|
|
1130
|
+
}
|
|
1131
|
+
function runAfterNavigateHooks(nav) {
|
|
1132
|
+
for (const fn of _afterNavigateHooks) try {
|
|
1133
|
+
fn(nav);
|
|
1134
|
+
} catch (e) {
|
|
1135
|
+
console.error("[ilha-router] afterNavigate hook threw:", e);
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
const _scrollPositions = /* @__PURE__ */ new Map();
|
|
1139
|
+
let _navKeyCounter = 0;
|
|
1140
|
+
let _lastNavKey = 0;
|
|
1141
|
+
function currentNavKey() {
|
|
1142
|
+
if (!isBrowser) return 0;
|
|
1143
|
+
const s = history.state;
|
|
1144
|
+
return typeof s?.__ilhaNavKey === "number" ? s.__ilhaNavKey : 0;
|
|
1145
|
+
}
|
|
1146
|
+
function saveScrollPosition() {
|
|
1147
|
+
_scrollPositions.set(currentNavKey(), {
|
|
1148
|
+
x: window.scrollX,
|
|
1149
|
+
y: window.scrollY
|
|
1150
|
+
});
|
|
1151
|
+
}
|
|
1152
|
+
function scrollAfterNavigate(hash) {
|
|
1153
|
+
requestAnimationFrame(() => {
|
|
1154
|
+
if (hash && hash !== "#") {
|
|
1155
|
+
const el = document.getElementById(hash.slice(1)) ?? document.querySelector(`a[name="${cssEscapeAttr(hash.slice(1))}"]`);
|
|
1156
|
+
if (el) {
|
|
1157
|
+
el.scrollIntoView();
|
|
1158
|
+
return;
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
window.scrollTo(0, 0);
|
|
1162
|
+
});
|
|
1163
|
+
}
|
|
1164
|
+
/** Restore the saved position for the current history entry (popstate). */
|
|
1165
|
+
function restoreScrollPosition() {
|
|
1166
|
+
const pos = _scrollPositions.get(currentNavKey());
|
|
1167
|
+
if (!pos) return;
|
|
1168
|
+
requestAnimationFrame(() => window.scrollTo(pos.x, pos.y));
|
|
1169
|
+
}
|
|
1170
|
+
function navigate(to, opts = {}) {
|
|
1171
|
+
if (!isBrowser) return;
|
|
1172
|
+
const adapter = getAdapter();
|
|
1173
|
+
const cur = adapter.readLocation();
|
|
1174
|
+
const current = cur.pathname + cur.search + cur.hash;
|
|
1175
|
+
if (to === current) return;
|
|
1176
|
+
const type = opts.replace ? "replace" : "push";
|
|
1177
|
+
let cancelled = false;
|
|
1178
|
+
for (const fn of _beforeNavigateHooks) try {
|
|
1179
|
+
fn({
|
|
1180
|
+
from: current,
|
|
1181
|
+
to,
|
|
1182
|
+
type,
|
|
1183
|
+
cancel: () => cancelled = true
|
|
1184
|
+
});
|
|
1185
|
+
} catch (e) {
|
|
1186
|
+
console.error("[ilha-router] beforeNavigate hook threw:", e);
|
|
1187
|
+
}
|
|
1188
|
+
if (cancelled) return;
|
|
1189
|
+
if (opts.replace) adapter.replace(to, { __ilhaNavKey: currentNavKey() });
|
|
1190
|
+
else {
|
|
1191
|
+
saveScrollPosition();
|
|
1192
|
+
_navKeyCounter = Math.max(_navKeyCounter + 1, currentNavKey() + 1);
|
|
1193
|
+
adapter.push(to, { __ilhaNavKey: _navKeyCounter });
|
|
1194
|
+
}
|
|
1195
|
+
_lastNavKey = currentNavKey();
|
|
1196
|
+
syncRouteFromLocation();
|
|
1197
|
+
if (opts.scroll !== false) {
|
|
1198
|
+
const dest = adapter.readLocation();
|
|
1199
|
+
const fromMatch = matchRoute(_routes, cur.pathname);
|
|
1200
|
+
const destMatch = matchRoute(_routes, dest.pathname);
|
|
1201
|
+
if (!(fromMatch?.data?.island != null && destMatch?.data?.island === fromMatch.data.island) || dest.hash && dest.hash !== "#") scrollAfterNavigate(dest.hash);
|
|
1202
|
+
}
|
|
1203
|
+
runAfterNavigateHooks({
|
|
1204
|
+
from: current,
|
|
1205
|
+
to,
|
|
1206
|
+
type
|
|
1207
|
+
});
|
|
1208
|
+
}
|
|
1209
|
+
/**
|
|
1210
|
+
* Follow a loader redirect on the client. Same-origin absolute URLs collapse
|
|
1211
|
+
* to a logical path; external URLs perform a full document navigation (the
|
|
1212
|
+
* server has already applied the external-redirect policy).
|
|
1213
|
+
*/
|
|
1214
|
+
function clientRedirect(to) {
|
|
1215
|
+
if (/^https?:\/\//i.test(to)) {
|
|
1216
|
+
try {
|
|
1217
|
+
const u = new URL(to);
|
|
1218
|
+
if (u.origin === location.origin) {
|
|
1219
|
+
navigate(u.pathname + u.search + u.hash, { replace: true });
|
|
1220
|
+
return;
|
|
1221
|
+
}
|
|
1222
|
+
} catch {
|
|
1223
|
+
return;
|
|
1224
|
+
}
|
|
1225
|
+
location.assign(to);
|
|
1226
|
+
return;
|
|
1227
|
+
}
|
|
1228
|
+
navigate(to, { replace: true });
|
|
1229
|
+
}
|
|
1230
|
+
function enableLinkInterception(root = document, options = {}) {
|
|
1231
|
+
if (!isBrowser) return () => {};
|
|
1232
|
+
const prefetchEnabled = options.prefetch !== false;
|
|
1233
|
+
/**
|
|
1234
|
+
* Determine whether this anchor is a same-origin in-app link we should handle,
|
|
1235
|
+
* and if so, the logical path to navigate to. Returns null when the link
|
|
1236
|
+
* should be left to the browser (external, modifier held, target=_blank, etc).
|
|
1237
|
+
*/
|
|
1238
|
+
function logicalPathFor(target, e) {
|
|
1239
|
+
const isBlank = target.getAttribute("target") === "_blank";
|
|
1240
|
+
const hasModifier = !!e && (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey);
|
|
1241
|
+
const hasNoIntercept = target.hasAttribute("data-no-intercept");
|
|
1242
|
+
const isDownload = target.hasAttribute("download");
|
|
1243
|
+
const isExternalRel = /\bexternal\b/i.test(target.getAttribute("rel") ?? "");
|
|
1244
|
+
if (isBlank || hasModifier || hasNoIntercept || isDownload || isExternalRel) return null;
|
|
1245
|
+
return getAdapter().extractLogicalPath(target);
|
|
1246
|
+
}
|
|
1247
|
+
const clickHandler = (e) => {
|
|
1248
|
+
if (e.defaultPrevented) return;
|
|
1249
|
+
if (typeof e.button === "number" && e.button !== 0) return;
|
|
1250
|
+
const target = e.target.closest("a");
|
|
1251
|
+
if (!target) return;
|
|
1252
|
+
const path = logicalPathFor(target, e);
|
|
1253
|
+
if (path === null) return;
|
|
1254
|
+
e.preventDefault();
|
|
1255
|
+
navigate(path);
|
|
1256
|
+
};
|
|
1257
|
+
const hoverHandler = (e) => {
|
|
1258
|
+
const target = e.target.closest("a");
|
|
1259
|
+
if (!target) return;
|
|
1260
|
+
const flag = target.getAttribute("data-prefetch");
|
|
1261
|
+
if (flag === null || flag === "false") return;
|
|
1262
|
+
const path = logicalPathFor(target);
|
|
1263
|
+
if (path === null) return;
|
|
1264
|
+
prefetch(path.split("#")[0] ?? path);
|
|
1265
|
+
};
|
|
1266
|
+
root.addEventListener("click", clickHandler);
|
|
1267
|
+
if (prefetchEnabled) root.addEventListener("mouseover", hoverHandler, { passive: true });
|
|
1268
|
+
return () => {
|
|
1269
|
+
root.removeEventListener("click", clickHandler);
|
|
1270
|
+
if (prefetchEnabled) root.removeEventListener("mouseover", hoverHandler);
|
|
1271
|
+
};
|
|
1272
|
+
}
|
|
1273
|
+
/** Custom 404 island — set via `router({ notFound })`. Module-level because
|
|
1274
|
+
* RouterView is a module-level island (single active router per document). */
|
|
1275
|
+
let _notFound = null;
|
|
1276
|
+
/** Redirect policy for browser-executed loaders — mirrors the last router's
|
|
1277
|
+
* `allowExternalRedirects` option (module-level for the same reason as `_notFound`). */
|
|
1278
|
+
let _allowExternalRedirects = false;
|
|
1279
|
+
const RouterView = ilha.render(() => {
|
|
1280
|
+
const island = activeIsland();
|
|
1281
|
+
if (!island) {
|
|
1282
|
+
if (_notFound) return `<div data-router-view data-router-not-found>${_notFound.toString()}</div>`;
|
|
1283
|
+
return `<div data-router-empty></div>`;
|
|
1284
|
+
}
|
|
1285
|
+
return `<div data-router-view>${island.toString()}</div>`;
|
|
1286
|
+
});
|
|
1287
|
+
const RouterLink = ilha.state("href", "").state("label", "").on("[data-link]@click", ({ state, event }) => {
|
|
1288
|
+
event.preventDefault();
|
|
1289
|
+
navigate(state.href());
|
|
1290
|
+
}).on("[data-link]@mouseenter", ({ state }) => {
|
|
1291
|
+
const href = state.href();
|
|
1292
|
+
if (!href) return;
|
|
1293
|
+
if (/^https?:\/\//i.test(href)) try {
|
|
1294
|
+
const u = new URL(href);
|
|
1295
|
+
if (u.origin !== location.origin) return;
|
|
1296
|
+
prefetch(u.pathname + u.search);
|
|
1297
|
+
return;
|
|
1298
|
+
} catch {
|
|
1299
|
+
return;
|
|
1300
|
+
}
|
|
1301
|
+
prefetch(href);
|
|
1302
|
+
}).render(({ state }) => html`<a data-link data-prefetch href="${() => getAdapter().toLinkHref(state.href())}"
|
|
1303
|
+
>${state.label}</a
|
|
1304
|
+
>`);
|
|
1305
|
+
function isActive(pattern, options = {}) {
|
|
1306
|
+
if (options.exact === false) {
|
|
1307
|
+
const path = routePath();
|
|
1308
|
+
const base = pattern.endsWith("/") ? pattern.slice(0, -1) : pattern;
|
|
1309
|
+
return path === base || path === base + "/" || path.startsWith(base + "/");
|
|
1310
|
+
}
|
|
1311
|
+
const match = matchRoute(_routes, routePath());
|
|
1312
|
+
if (!match) return false;
|
|
1313
|
+
return match.data.pattern === pattern;
|
|
1314
|
+
}
|
|
1315
|
+
const ILHA_HEAD_ATTR = "data-ilha-head";
|
|
1316
|
+
const ILHA_ROUTER_HTML_ATTR = "data-ilha-router-html";
|
|
1317
|
+
const ILHA_ROUTER_BODY_ATTR = "data-ilha-router-body";
|
|
1318
|
+
/** Browser-only fallback; SSR uses AsyncLocalStorage (see `withHeadStore`). */
|
|
1319
|
+
let _browserHeadStore = null;
|
|
1320
|
+
let _headAls = null;
|
|
1321
|
+
let _headAlsInit = null;
|
|
1322
|
+
/** ESM dynamic import — Nitro/Vite SSR workers have no `require`. */
|
|
1323
|
+
async function getHeadAlsAsync() {
|
|
1324
|
+
if (_headAls) return _headAls;
|
|
1325
|
+
if (!_headAlsInit) _headAlsInit = import("node:async_hooks").then(({ AsyncLocalStorage }) => {
|
|
1326
|
+
_headAls = new AsyncLocalStorage();
|
|
1327
|
+
return _headAls;
|
|
1328
|
+
});
|
|
1329
|
+
return _headAlsInit;
|
|
1330
|
+
}
|
|
1331
|
+
function activeHeadStore() {
|
|
1332
|
+
if (isBrowser) return _browserHeadStore;
|
|
1333
|
+
return _headAls?.getStore() ?? null;
|
|
1334
|
+
}
|
|
1335
|
+
/**
|
|
1336
|
+
* Contribute `<head>` data from inside an island's `.render()` body or a
|
|
1337
|
+
* layout. During SSR this collects into the active render window; on the
|
|
1338
|
+
* client, entries are collected when the router re-renders a route inside
|
|
1339
|
+
* `withHeadStore` and then applied to `document`. Prefer a loader's `ctx.head`
|
|
1340
|
+
* for data that depends on the request.
|
|
1341
|
+
*/
|
|
1342
|
+
function head(input) {
|
|
1343
|
+
const store = activeHeadStore();
|
|
1344
|
+
if (!store) {
|
|
1345
|
+
if (!isBrowser) console.warn("[ilha-router] head() called outside an SSR render window — ignored.");
|
|
1346
|
+
return;
|
|
1347
|
+
}
|
|
1348
|
+
store.entries.push(input);
|
|
1349
|
+
}
|
|
1350
|
+
function cssEscapeAttr(value) {
|
|
1351
|
+
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") return CSS.escape(value);
|
|
1352
|
+
return value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
|
|
1353
|
+
}
|
|
1354
|
+
function headManagedMetaSelector(tag) {
|
|
1355
|
+
if ("charset" in tag) return `meta[charset][${ILHA_HEAD_ATTR}]`;
|
|
1356
|
+
if ("name" in tag) return `meta[name="${cssEscapeAttr(tag.name)}"][${ILHA_HEAD_ATTR}]`;
|
|
1357
|
+
if ("property" in tag) return `meta[property="${cssEscapeAttr(tag.property)}"][${ILHA_HEAD_ATTR}]`;
|
|
1358
|
+
if ("http-equiv" in tag) return `meta[http-equiv="${cssEscapeAttr(tag["http-equiv"])}"][${ILHA_HEAD_ATTR}]`;
|
|
1359
|
+
return null;
|
|
1360
|
+
}
|
|
1361
|
+
function headManagedLinkSelector(tag) {
|
|
1362
|
+
if (tag.rel && tag.href) return `link[rel="${cssEscapeAttr(tag.rel)}"][href="${cssEscapeAttr(tag.href)}"][${ILHA_HEAD_ATTR}]`;
|
|
1363
|
+
return null;
|
|
1364
|
+
}
|
|
1365
|
+
/**
|
|
1366
|
+
* Apply merged head entries on client navigations. Updates `document.title` and
|
|
1367
|
+
* managed meta/link nodes (`data-ilha-head`). Script tags from HeadInput are
|
|
1368
|
+
* SSR-only and are not re-injected here. Removes managed tags from the previous
|
|
1369
|
+
* route that are not part of this navigation's set.
|
|
1370
|
+
*/
|
|
1371
|
+
function applyHeadEntriesToDocument(entries) {
|
|
1372
|
+
if (!isBrowser) return;
|
|
1373
|
+
let title;
|
|
1374
|
+
let titleTemplate;
|
|
1375
|
+
const meta = [];
|
|
1376
|
+
const link = [];
|
|
1377
|
+
let htmlAttrs = {};
|
|
1378
|
+
let bodyAttrs = {};
|
|
1379
|
+
for (const entry of entries) {
|
|
1380
|
+
if (entry.title !== void 0) title = entry.title;
|
|
1381
|
+
if (entry.titleTemplate !== void 0) titleTemplate = entry.titleTemplate;
|
|
1382
|
+
if (entry.meta) meta.push(...entry.meta);
|
|
1383
|
+
if (entry.link) link.push(...entry.link);
|
|
1384
|
+
if (entry.htmlAttrs) htmlAttrs = {
|
|
1385
|
+
...htmlAttrs,
|
|
1386
|
+
...entry.htmlAttrs
|
|
1387
|
+
};
|
|
1388
|
+
if (entry.bodyAttrs) bodyAttrs = {
|
|
1389
|
+
...bodyAttrs,
|
|
1390
|
+
...entry.bodyAttrs
|
|
1391
|
+
};
|
|
1392
|
+
}
|
|
1393
|
+
const resolvedTitle = applyTitleTemplate(title, titleTemplate);
|
|
1394
|
+
if (resolvedTitle !== void 0) document.title = resolvedTitle;
|
|
1395
|
+
const metaTags = dedupByKey(meta, metaDedupKey);
|
|
1396
|
+
const linkTags = dedupByKey(link, (t) => `${t.rel ?? ""}:${t.href ?? ""}`);
|
|
1397
|
+
const keepManaged = /* @__PURE__ */ new Set();
|
|
1398
|
+
for (const tag of metaTags) {
|
|
1399
|
+
const selector = headManagedMetaSelector(tag);
|
|
1400
|
+
if (!selector) continue;
|
|
1401
|
+
let el = document.querySelector(selector);
|
|
1402
|
+
if (!el) {
|
|
1403
|
+
el = document.createElement("meta");
|
|
1404
|
+
el.setAttribute(ILHA_HEAD_ATTR, "");
|
|
1405
|
+
document.head.appendChild(el);
|
|
1406
|
+
}
|
|
1407
|
+
for (const [k, v] of Object.entries(tag)) el.setAttribute(k, v);
|
|
1408
|
+
keepManaged.add(el);
|
|
1409
|
+
}
|
|
1410
|
+
for (const tag of linkTags) {
|
|
1411
|
+
const selector = headManagedLinkSelector(tag);
|
|
1412
|
+
let el = selector ? document.querySelector(selector) : null;
|
|
1413
|
+
if (!el) {
|
|
1414
|
+
el = document.createElement("link");
|
|
1415
|
+
el.setAttribute(ILHA_HEAD_ATTR, "");
|
|
1416
|
+
document.head.appendChild(el);
|
|
1417
|
+
}
|
|
1418
|
+
for (const [k, v] of Object.entries(tag)) el.setAttribute(k, v);
|
|
1419
|
+
keepManaged.add(el);
|
|
1420
|
+
}
|
|
1421
|
+
for (const el of [...document.head.querySelectorAll(`[${ILHA_HEAD_ATTR}]`)]) if (!keepManaged.has(el)) el.remove();
|
|
1422
|
+
const htmlEl = document.documentElement;
|
|
1423
|
+
const prevHtmlKeys = (htmlEl.getAttribute(ILHA_ROUTER_HTML_ATTR) ?? "").split(/\s+/).filter(Boolean);
|
|
1424
|
+
for (const k of prevHtmlKeys) htmlEl.removeAttribute(k);
|
|
1425
|
+
const nextHtmlKeys = Object.keys(htmlAttrs);
|
|
1426
|
+
for (const [k, v] of Object.entries(htmlAttrs)) htmlEl.setAttribute(k, v);
|
|
1427
|
+
if (nextHtmlKeys.length) htmlEl.setAttribute(ILHA_ROUTER_HTML_ATTR, nextHtmlKeys.join(" "));
|
|
1428
|
+
else htmlEl.removeAttribute(ILHA_ROUTER_HTML_ATTR);
|
|
1429
|
+
const bodyEl = document.body;
|
|
1430
|
+
const prevBodyKeys = (bodyEl.getAttribute(ILHA_ROUTER_BODY_ATTR) ?? "").split(/\s+/).filter(Boolean);
|
|
1431
|
+
for (const k of prevBodyKeys) bodyEl.removeAttribute(k);
|
|
1432
|
+
const nextBodyKeys = Object.keys(bodyAttrs);
|
|
1433
|
+
for (const [k, v] of Object.entries(bodyAttrs)) bodyEl.setAttribute(k, v);
|
|
1434
|
+
if (nextBodyKeys.length) bodyEl.setAttribute(ILHA_ROUTER_BODY_ATTR, nextBodyKeys.join(" "));
|
|
1435
|
+
else bodyEl.removeAttribute(ILHA_ROUTER_BODY_ATTR);
|
|
1436
|
+
}
|
|
1437
|
+
async function withHeadStore(store, fn) {
|
|
1438
|
+
if (isBrowser) {
|
|
1439
|
+
const prev = _browserHeadStore;
|
|
1440
|
+
_browserHeadStore = store;
|
|
1441
|
+
try {
|
|
1442
|
+
return await fn();
|
|
1443
|
+
} finally {
|
|
1444
|
+
_browserHeadStore = prev;
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
return await (await getHeadAlsAsync()).run(store, () => Promise.resolve(fn()));
|
|
1448
|
+
}
|
|
1449
|
+
const HEAD_ESC = {
|
|
1450
|
+
"&": "&",
|
|
1451
|
+
"<": "<",
|
|
1452
|
+
">": ">",
|
|
1453
|
+
"\"": """,
|
|
1454
|
+
"'": "'"
|
|
1455
|
+
};
|
|
1456
|
+
function escapeHeadAttr(value) {
|
|
1457
|
+
return String(value).replace(/[&<>"']/g, (c) => HEAD_ESC[c]);
|
|
1458
|
+
}
|
|
1459
|
+
/** Escape text content for inline HTML (loader error messages etc.). */
|
|
1460
|
+
function escapeHtml(value) {
|
|
1461
|
+
return String(value).replace(/[&<>]/g, (c) => HEAD_ESC[c]);
|
|
1462
|
+
}
|
|
1463
|
+
function serializeAttrs(attrs) {
|
|
1464
|
+
const parts = [];
|
|
1465
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
1466
|
+
if (!/^[A-Za-z_:][A-Za-z0-9:._-]*$/.test(k) || /^on[a-z]/i.test(k)) {
|
|
1467
|
+
if (isDevEnv()) console.warn(`[ilha-router] Dropping unsafe head attribute "${k}".`);
|
|
1468
|
+
continue;
|
|
1469
|
+
}
|
|
1470
|
+
parts.push(` ${k}="${escapeHeadAttr(v)}"`);
|
|
1471
|
+
}
|
|
1472
|
+
return parts.join("");
|
|
1473
|
+
}
|
|
1474
|
+
function isSafeUrl(value) {
|
|
1475
|
+
const v = value.trim();
|
|
1476
|
+
if (v === "") return true;
|
|
1477
|
+
if (v.startsWith("//")) return false;
|
|
1478
|
+
if (v.startsWith("#") || v.startsWith("/") || v.startsWith("./")) return true;
|
|
1479
|
+
if (/^(?:javascript|vbscript|data):/i.test(v)) return false;
|
|
1480
|
+
try {
|
|
1481
|
+
const u = new URL(v, "http://localhost");
|
|
1482
|
+
return u.protocol === "http:" || u.protocol === "https:";
|
|
1483
|
+
} catch {
|
|
1484
|
+
return false;
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
function metaDedupKey(tag) {
|
|
1488
|
+
if ("charset" in tag) return "charset";
|
|
1489
|
+
if ("name" in tag) return `name:${tag.name}`;
|
|
1490
|
+
if ("property" in tag) return `property:${tag.property}`;
|
|
1491
|
+
if ("http-equiv" in tag) return `http-equiv:${tag["http-equiv"]}`;
|
|
1492
|
+
return JSON.stringify(tag);
|
|
1493
|
+
}
|
|
1494
|
+
function dedupByKey(tags, keyOf) {
|
|
1495
|
+
const map = /* @__PURE__ */ new Map();
|
|
1496
|
+
for (const tag of tags) map.set(keyOf(tag), tag);
|
|
1497
|
+
return [...map.values()];
|
|
1498
|
+
}
|
|
1499
|
+
function applyTitleTemplate(title, template) {
|
|
1500
|
+
if (template === void 0) return title;
|
|
1501
|
+
if (typeof template === "function") return template(title);
|
|
1502
|
+
return template.replace(/%s/g, title ?? "");
|
|
1503
|
+
}
|
|
1504
|
+
/**
|
|
1505
|
+
* Merge head entries in contribution order (loader first as the base, then
|
|
1506
|
+
* render-time outer→inner layouts, then the page) and serialize. Later entries
|
|
1507
|
+
* win on collision; the last `titleTemplate` wraps the resolved title.
|
|
1508
|
+
*/
|
|
1509
|
+
function serializeHead(entries) {
|
|
1510
|
+
let title;
|
|
1511
|
+
let titleTemplate;
|
|
1512
|
+
const meta = [];
|
|
1513
|
+
const link = [];
|
|
1514
|
+
const script = [];
|
|
1515
|
+
let htmlAttrs = {};
|
|
1516
|
+
let bodyAttrs = {};
|
|
1517
|
+
for (const entry of entries) {
|
|
1518
|
+
if (entry.title !== void 0) title = entry.title;
|
|
1519
|
+
if (entry.titleTemplate !== void 0) titleTemplate = entry.titleTemplate;
|
|
1520
|
+
if (entry.meta) meta.push(...entry.meta);
|
|
1521
|
+
if (entry.link) link.push(...entry.link);
|
|
1522
|
+
if (entry.script) script.push(...entry.script);
|
|
1523
|
+
if (entry.htmlAttrs) htmlAttrs = {
|
|
1524
|
+
...htmlAttrs,
|
|
1525
|
+
...entry.htmlAttrs
|
|
1526
|
+
};
|
|
1527
|
+
if (entry.bodyAttrs) bodyAttrs = {
|
|
1528
|
+
...bodyAttrs,
|
|
1529
|
+
...entry.bodyAttrs
|
|
1530
|
+
};
|
|
1531
|
+
}
|
|
1532
|
+
const resolvedTitle = applyTitleTemplate(title, titleTemplate);
|
|
1533
|
+
const parts = [];
|
|
1534
|
+
if (resolvedTitle !== void 0) parts.push(`<title>${escapeHeadAttr(resolvedTitle)}</title>`);
|
|
1535
|
+
for (const tag of dedupByKey(meta, metaDedupKey)) {
|
|
1536
|
+
if (/^refresh$/i.test(tag["http-equiv"] ?? "") && !isSafeRefreshTarget(tag.content ?? "")) {
|
|
1537
|
+
if (isDevEnv()) console.warn(`[ilha-router] Dropping unsafe meta refresh target "${tag.content}".`);
|
|
1538
|
+
continue;
|
|
1539
|
+
}
|
|
1540
|
+
parts.push(`<meta${serializeAttrs({
|
|
1541
|
+
...tag,
|
|
1542
|
+
[ILHA_HEAD_ATTR]: ""
|
|
1543
|
+
})} />`);
|
|
1544
|
+
}
|
|
1545
|
+
for (const tag of dedupByKey(link, (t) => `${t.rel ?? ""}:${t.href ?? ""}`)) {
|
|
1546
|
+
if (tag.href !== void 0 && !isSafeUrl(tag.href)) {
|
|
1547
|
+
if (isDevEnv()) console.warn(`[ilha-router] Dropping unsafe link href "${tag.href}".`);
|
|
1548
|
+
continue;
|
|
1549
|
+
}
|
|
1550
|
+
parts.push(`<link${serializeAttrs({
|
|
1551
|
+
...tag,
|
|
1552
|
+
[ILHA_HEAD_ATTR]: ""
|
|
1553
|
+
})} />`);
|
|
1554
|
+
}
|
|
1555
|
+
for (const tag of script) {
|
|
1556
|
+
if (tag.src !== void 0 && !isSafeUrl(tag.src)) {
|
|
1557
|
+
if (isDevEnv()) console.warn(`[ilha-router] Dropping unsafe script src "${tag.src}".`);
|
|
1558
|
+
continue;
|
|
1559
|
+
}
|
|
1560
|
+
const { children, ...attrs } = tag;
|
|
1561
|
+
const body = (children ?? "").replace(/<\/script/gi, "<\\/script");
|
|
1562
|
+
parts.push(`<script${serializeAttrs(attrs)}>${body}<\/script>`);
|
|
1563
|
+
}
|
|
1564
|
+
return {
|
|
1565
|
+
headTags: parts.join("\n "),
|
|
1566
|
+
htmlAttrs: serializeAttrs(htmlAttrs),
|
|
1567
|
+
bodyAttrs: serializeAttrs(bodyAttrs)
|
|
1568
|
+
};
|
|
1569
|
+
}
|
|
1570
|
+
function isSafeRefreshTarget(content) {
|
|
1571
|
+
const match = /url\s*=\s*(['"]?)([^'";\s]+)\1/i.exec(content);
|
|
1572
|
+
if (!match) return true;
|
|
1573
|
+
return isSafeUrl(match[2] ?? "");
|
|
1574
|
+
}
|
|
1575
|
+
/**
|
|
1576
|
+
* Build an HTTP `Response` for SSR output with sensible security headers:
|
|
1577
|
+
* `Content-Type: text/html`, `X-Content-Type-Options: nosniff`,
|
|
1578
|
+
* `Referrer-Policy`, `Cache-Control: no-store`, and an optional CSP. This is
|
|
1579
|
+
* a low-level helper — prefer {@link RouterBuilder.respond} for the full
|
|
1580
|
+
* render+head+headers pipeline.
|
|
1581
|
+
*/
|
|
1582
|
+
function httpResponse(body, options = {}) {
|
|
1583
|
+
const headers = new Headers(options.headers);
|
|
1584
|
+
if (body != null && !headers.has("content-type")) headers.set("content-type", "text/html; charset=utf-8");
|
|
1585
|
+
if (!headers.has("x-content-type-options")) headers.set("x-content-type-options", "nosniff");
|
|
1586
|
+
if (!headers.has("referrer-policy")) headers.set("referrer-policy", "no-referrer");
|
|
1587
|
+
if (!headers.has("cache-control")) headers.set("cache-control", "no-store");
|
|
1588
|
+
const csp = options.contentSecurityPolicy ?? (options.cspNonce ? `default-src 'self'; script-src 'self' 'nonce-${options.cspNonce}'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; object-src 'none'; base-uri 'self'; frame-ancestors 'self'` : void 0);
|
|
1589
|
+
if (csp) headers.set("content-security-policy", csp);
|
|
1590
|
+
return new Response(body, {
|
|
1591
|
+
status: options.status ?? 200,
|
|
1592
|
+
headers
|
|
1593
|
+
});
|
|
1594
|
+
}
|
|
1595
|
+
const EMPTY_HEAD = {
|
|
1596
|
+
headTags: "",
|
|
1597
|
+
htmlAttrs: "",
|
|
1598
|
+
bodyAttrs: ""
|
|
1599
|
+
};
|
|
1600
|
+
function resolveRequestUrl(urlOrRequest, request) {
|
|
1601
|
+
if (urlOrRequest instanceof Request) return {
|
|
1602
|
+
url: urlOrRequest.url,
|
|
1603
|
+
request: urlOrRequest
|
|
1604
|
+
};
|
|
1605
|
+
return {
|
|
1606
|
+
url: urlOrRequest,
|
|
1607
|
+
request
|
|
1608
|
+
};
|
|
1609
|
+
}
|
|
1610
|
+
function parsedURL(url) {
|
|
1611
|
+
return typeof url === "string" ? new URL(url, "http://localhost") : url;
|
|
1612
|
+
}
|
|
1613
|
+
/** Dev detection for error-message redaction. Browser builds map `process` to `false`. */
|
|
1614
|
+
function isDevEnv() {
|
|
1615
|
+
try {
|
|
1616
|
+
return typeof process !== "undefined" && !!process.env && process.env.NODE_ENV !== "production";
|
|
1617
|
+
} catch {
|
|
1618
|
+
return false;
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
/**
|
|
1622
|
+
* Validate a loader redirect target. Relative paths always pass; same-origin
|
|
1623
|
+
* absolute URLs collapse to a path; cross-origin targets are rejected unless
|
|
1624
|
+
* `allowExternal`. Protocol-relative (`//host`) and unparsable targets are
|
|
1625
|
+
* always rejected.
|
|
1626
|
+
*/
|
|
1627
|
+
function resolveRedirectTarget(to, base, allowExternal) {
|
|
1628
|
+
if (to.startsWith("/") && !to.startsWith("//")) return {
|
|
1629
|
+
ok: true,
|
|
1630
|
+
to
|
|
1631
|
+
};
|
|
1632
|
+
try {
|
|
1633
|
+
const u = new URL(to, base);
|
|
1634
|
+
if (!/^https?:$/.test(u.protocol)) return { ok: false };
|
|
1635
|
+
if (u.origin === base.origin) return {
|
|
1636
|
+
ok: true,
|
|
1637
|
+
to: u.pathname + u.search + u.hash
|
|
1638
|
+
};
|
|
1639
|
+
return allowExternal ? {
|
|
1640
|
+
ok: true,
|
|
1641
|
+
to: u.href
|
|
1642
|
+
} : { ok: false };
|
|
1643
|
+
} catch {
|
|
1644
|
+
return { ok: false };
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
/**
|
|
1648
|
+
* Build the loader's AbortSignal: aborts when the incoming request aborts or
|
|
1649
|
+
* when `timeout` (ms) elapses. Call `done()` when the loader settles.
|
|
1650
|
+
*/
|
|
1651
|
+
function loaderAbort(request, timeout) {
|
|
1652
|
+
const ctrl = new AbortController();
|
|
1653
|
+
const abort = () => ctrl.abort();
|
|
1654
|
+
const reqSignal = request?.signal;
|
|
1655
|
+
if (reqSignal) {
|
|
1656
|
+
if (reqSignal.aborted) abort();
|
|
1657
|
+
else reqSignal.addEventListener("abort", abort, { once: true });
|
|
1658
|
+
}
|
|
1659
|
+
let timer;
|
|
1660
|
+
if (timeout && timeout > 0) timer = setTimeout(abort, timeout);
|
|
1661
|
+
return {
|
|
1662
|
+
signal: ctrl.signal,
|
|
1663
|
+
done: () => {
|
|
1664
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
1665
|
+
reqSignal?.removeEventListener("abort", abort);
|
|
1666
|
+
}
|
|
1667
|
+
};
|
|
1668
|
+
}
|
|
1669
|
+
function defaultRequest(url) {
|
|
1670
|
+
try {
|
|
1671
|
+
return new Request(url.toString());
|
|
1672
|
+
} catch {
|
|
1673
|
+
return {
|
|
1674
|
+
url: url.toString(),
|
|
1675
|
+
headers: new Headers()
|
|
1676
|
+
};
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
async function executeLoader(loader, url, params, request, signal, onHead) {
|
|
1680
|
+
const headEntries = [];
|
|
1681
|
+
const head = onHead ?? ((input) => headEntries.push(input));
|
|
1682
|
+
try {
|
|
1683
|
+
const loaderPromise = Promise.resolve(loader({
|
|
1684
|
+
params,
|
|
1685
|
+
request,
|
|
1686
|
+
url,
|
|
1687
|
+
signal,
|
|
1688
|
+
head
|
|
1689
|
+
}));
|
|
1690
|
+
loaderPromise.catch(() => {});
|
|
1691
|
+
const out = {
|
|
1692
|
+
kind: "data",
|
|
1693
|
+
data: await Promise.race([loaderPromise, new Promise((_, reject) => {
|
|
1694
|
+
const onAbort = () => reject(new LoaderError(504, "Loader aborted or timed out"));
|
|
1695
|
+
if (signal.aborted) onAbort();
|
|
1696
|
+
else signal.addEventListener("abort", onAbort, { once: true });
|
|
1697
|
+
})]) ?? {}
|
|
1698
|
+
};
|
|
1699
|
+
if (headEntries.length > 0) out.head = serializeHead(headEntries);
|
|
1700
|
+
return out;
|
|
1701
|
+
} catch (e) {
|
|
1702
|
+
if (e instanceof Redirect) return {
|
|
1703
|
+
kind: "redirect",
|
|
1704
|
+
to: e.to,
|
|
1705
|
+
status: e.status
|
|
1706
|
+
};
|
|
1707
|
+
if (e instanceof LoaderError) return {
|
|
1708
|
+
kind: "error",
|
|
1709
|
+
status: e.status,
|
|
1710
|
+
message: e.message
|
|
1711
|
+
};
|
|
1712
|
+
console.error("[ilha-router] loader failed:", e);
|
|
1713
|
+
return {
|
|
1714
|
+
kind: "error",
|
|
1715
|
+
status: typeof e?.status === "number" ? e.status : 500,
|
|
1716
|
+
message: isDevEnv() ? e?.message ?? "Loader failed" : "Internal error"
|
|
1717
|
+
};
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
function router(options = {}) {
|
|
1721
|
+
const mode = options.mode ?? "spa";
|
|
1722
|
+
const defaultInterceptLinks = options.interceptLinks !== false;
|
|
1723
|
+
const allowExternalRedirects = options.allowExternalRedirects === true;
|
|
1724
|
+
const loaderTimeout = options.loaderTimeout;
|
|
1725
|
+
const records = [];
|
|
1726
|
+
const routes = createRouteRegistry();
|
|
1727
|
+
const patternToData = /* @__PURE__ */ new Map();
|
|
1728
|
+
const notFound = options.notFound ?? null;
|
|
1729
|
+
_routes = routes;
|
|
1730
|
+
_notFound = notFound;
|
|
1731
|
+
_allowExternalRedirects = allowExternalRedirects;
|
|
1732
|
+
_viewTransitions = options.viewTransitions === true;
|
|
1733
|
+
let _navChangeCleanup = null;
|
|
1734
|
+
let _linkCleanup = null;
|
|
1735
|
+
const builder = {
|
|
1736
|
+
route(pattern, island, loader) {
|
|
1737
|
+
const hasLoader = !!loader;
|
|
1738
|
+
const data = {
|
|
1739
|
+
island,
|
|
1740
|
+
pattern,
|
|
1741
|
+
loader,
|
|
1742
|
+
hasLoader
|
|
1743
|
+
};
|
|
1744
|
+
records.push({
|
|
1745
|
+
pattern,
|
|
1746
|
+
island,
|
|
1747
|
+
loader,
|
|
1748
|
+
hasLoader
|
|
1749
|
+
});
|
|
1750
|
+
addRouteEntry(routes, pattern, data);
|
|
1751
|
+
patternToData.set(pattern, data);
|
|
1752
|
+
return builder;
|
|
1753
|
+
},
|
|
1754
|
+
attachLoader(pattern, loader) {
|
|
1755
|
+
const data = patternToData.get(pattern);
|
|
1756
|
+
if (!data) {
|
|
1757
|
+
console.warn(`[ilha-router] attachLoader("${pattern}", …): pattern was never registered via .route(). The loader will be ignored.`);
|
|
1758
|
+
return builder;
|
|
1759
|
+
}
|
|
1760
|
+
data.loader = loader;
|
|
1761
|
+
data.hasLoader = true;
|
|
1762
|
+
const rec = records.find((r) => r.pattern === pattern);
|
|
1763
|
+
if (rec) {
|
|
1764
|
+
rec.loader = loader;
|
|
1765
|
+
rec.hasLoader = true;
|
|
1766
|
+
}
|
|
1767
|
+
return builder;
|
|
1768
|
+
},
|
|
1769
|
+
clientLoader(pattern, loader) {
|
|
1770
|
+
const data = patternToData.get(pattern);
|
|
1771
|
+
if (!data) {
|
|
1772
|
+
console.warn(`[ilha-router] clientLoader("${pattern}", …): pattern was never registered via .route(). The loader will be ignored.`);
|
|
1773
|
+
return builder;
|
|
1774
|
+
}
|
|
1775
|
+
data.clientLoader = loader;
|
|
1776
|
+
data.hasLoader = true;
|
|
1777
|
+
const rec = records.find((r) => r.pattern === pattern);
|
|
1778
|
+
if (rec) rec.hasLoader = true;
|
|
1779
|
+
return builder;
|
|
1780
|
+
},
|
|
1781
|
+
errorBoundary(pattern, handler) {
|
|
1782
|
+
const data = patternToData.get(pattern);
|
|
1783
|
+
if (!data) {
|
|
1784
|
+
console.warn(`[ilha-router] errorBoundary("${pattern}", …): pattern was never registered via .route(). The boundary will be ignored.`);
|
|
1785
|
+
return builder;
|
|
1786
|
+
}
|
|
1787
|
+
data.errorHandler = handler;
|
|
1788
|
+
return builder;
|
|
1789
|
+
},
|
|
1790
|
+
markLoader(pattern) {
|
|
1791
|
+
const data = patternToData.get(pattern);
|
|
1792
|
+
if (!data) {
|
|
1793
|
+
console.warn(`[ilha-router] markLoader("${pattern}"): pattern was never registered via .route(). The loader marker will be ignored.`);
|
|
1794
|
+
return builder;
|
|
1795
|
+
}
|
|
1796
|
+
data.hasLoader = true;
|
|
1797
|
+
const rec = records.find((r) => r.pattern === pattern);
|
|
1798
|
+
if (rec) rec.hasLoader = true;
|
|
1799
|
+
return builder;
|
|
1800
|
+
},
|
|
1801
|
+
routes() {
|
|
1802
|
+
return records.map((record) => ({ ...record }));
|
|
1803
|
+
},
|
|
1804
|
+
prime,
|
|
1805
|
+
hydrateStatic(registry, options = {}) {
|
|
1806
|
+
if (!isBrowser) return () => {};
|
|
1807
|
+
const root = options.root ?? document.body;
|
|
1808
|
+
prime();
|
|
1809
|
+
const { unmount } = mount(registry, { root });
|
|
1810
|
+
return unmount;
|
|
1811
|
+
},
|
|
1812
|
+
mount(target, { hydrate = false, registry, interceptLinks: mountInterceptLinks } = {}) {
|
|
1813
|
+
if (!isBrowser) {
|
|
1814
|
+
console.warn("[ilha-router] mount() called in a non-browser environment");
|
|
1815
|
+
return () => {};
|
|
1816
|
+
}
|
|
1817
|
+
const host = typeof target === "string" ? document.querySelector(target) : target;
|
|
1818
|
+
if (!host) {
|
|
1819
|
+
console.warn(`[ilha-router] No element found for selector "${target}"`);
|
|
1820
|
+
return () => {};
|
|
1821
|
+
}
|
|
1822
|
+
syncRouteFromLocation();
|
|
1823
|
+
_lastNavKey = currentNavKey();
|
|
1824
|
+
if (mode === "static") {
|
|
1825
|
+
console.warn("[ilha-router] router.mount() called in static mode. Use router.hydrateStatic(registry) instead.");
|
|
1826
|
+
return () => {};
|
|
1827
|
+
}
|
|
1828
|
+
let mounted = true;
|
|
1829
|
+
const prevScrollRestoration = "scrollRestoration" in history ? history.scrollRestoration : null;
|
|
1830
|
+
if (prevScrollRestoration !== null) history.scrollRestoration = "manual";
|
|
1831
|
+
const popHandler = () => {
|
|
1832
|
+
if (!mounted) return;
|
|
1833
|
+
const prevPath = routePath() + routeSearch() + routeHash();
|
|
1834
|
+
_scrollPositions.set(_lastNavKey, {
|
|
1835
|
+
x: window.scrollX,
|
|
1836
|
+
y: window.scrollY
|
|
1837
|
+
});
|
|
1838
|
+
_lastNavKey = currentNavKey();
|
|
1839
|
+
syncRouteFromLocation();
|
|
1840
|
+
restoreScrollPosition();
|
|
1841
|
+
runAfterNavigateHooks({
|
|
1842
|
+
from: prevPath,
|
|
1843
|
+
to: routePath() + routeSearch() + routeHash(),
|
|
1844
|
+
type: "pop"
|
|
1845
|
+
});
|
|
1846
|
+
};
|
|
1847
|
+
_navChangeCleanup = getAdapter().onChange(popHandler);
|
|
1848
|
+
_linkCleanup = mountInterceptLinks ?? defaultInterceptLinks ? enableLinkInterception(document) : null;
|
|
1849
|
+
let unmountView = null;
|
|
1850
|
+
let navAbort = null;
|
|
1851
|
+
if (hydrate) {
|
|
1852
|
+
if (getHistoryMode() === "hash") console.warn("[ilha-router] mount({ hydrate: true }) was called in hash mode. SSR + hydration assumes the server can render the active route, but in hash mode the server only ever sees the document URL. Use plain SPA mode (`mount(target)` without `hydrate: true`) for hash-mode apps.");
|
|
1853
|
+
const viewHost = host.querySelector("[data-router-view]") ?? host;
|
|
1854
|
+
let currentMountedIsland = activeIsland();
|
|
1855
|
+
let currentMountedPath = routePath() + routeSearch();
|
|
1856
|
+
let viewHandle = null;
|
|
1857
|
+
const adoptHydratedHandle = () => {
|
|
1858
|
+
const el = viewHost.querySelector("[data-ilha]");
|
|
1859
|
+
if (!el) return null;
|
|
1860
|
+
const h = ISLAND_MOUNT_HANDLES.get(el);
|
|
1861
|
+
return h ? {
|
|
1862
|
+
unmount: () => void h.unmount(),
|
|
1863
|
+
updateProps: (p) => h.updateProps(p)
|
|
1864
|
+
} : null;
|
|
1865
|
+
};
|
|
1866
|
+
const reverseRegistry = registry ? buildReverseRegistry(registry) : void 0;
|
|
1867
|
+
let navVersion = 0;
|
|
1868
|
+
const NavHandler = ilha.render(() => {
|
|
1869
|
+
const current = activeIsland();
|
|
1870
|
+
const pathWithSearch = routePath() + routeSearch();
|
|
1871
|
+
if (current !== currentMountedIsland || pathWithSearch !== currentMountedPath) {
|
|
1872
|
+
const thisNav = ++navVersion;
|
|
1873
|
+
navAbort?.abort();
|
|
1874
|
+
navAbort = new AbortController();
|
|
1875
|
+
const signal = navAbort.signal;
|
|
1876
|
+
queueMicrotask(async () => {
|
|
1877
|
+
if (thisNav !== navVersion) return;
|
|
1878
|
+
const settle = beginNavigation();
|
|
1879
|
+
try {
|
|
1880
|
+
if (current !== null && current === currentMountedIsland) {
|
|
1881
|
+
const handle = viewHandle ?? adoptHydratedHandle();
|
|
1882
|
+
if (handle?.updateProps) {
|
|
1883
|
+
if (await updateRouteInPlace(handle, pathWithSearch, signal) === "updated") {
|
|
1884
|
+
viewHandle = handle;
|
|
1885
|
+
currentMountedPath = pathWithSearch;
|
|
1886
|
+
return;
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
viewHandle?.unmount();
|
|
1891
|
+
viewHandle = null;
|
|
1892
|
+
viewHandle = await mountRouteWithHydration(current, viewHost, pathWithSearch, signal, registry, reverseRegistry);
|
|
1893
|
+
} catch (e) {
|
|
1894
|
+
if (e?.name === "AbortError") return;
|
|
1895
|
+
console.error("[ilha-router] navigation failed:", e);
|
|
1896
|
+
viewHost.innerHTML = `<div data-router-view data-router-error="500"></div>`;
|
|
1897
|
+
return;
|
|
1898
|
+
} finally {
|
|
1899
|
+
settle();
|
|
1900
|
+
}
|
|
1901
|
+
currentMountedIsland = current;
|
|
1902
|
+
currentMountedPath = pathWithSearch;
|
|
1903
|
+
});
|
|
1904
|
+
}
|
|
1905
|
+
return "";
|
|
1906
|
+
});
|
|
1907
|
+
const navHost = document.createElement("div");
|
|
1908
|
+
navHost.style.display = "none";
|
|
1909
|
+
host.appendChild(navHost);
|
|
1910
|
+
const unmountNavHandler = NavHandler.mount(navHost);
|
|
1911
|
+
(async () => {
|
|
1912
|
+
const island = activeIsland();
|
|
1913
|
+
if (!island) return;
|
|
1914
|
+
const loc = getAdapter().readLocation();
|
|
1915
|
+
const pathWithSearch = loc.pathname + loc.search;
|
|
1916
|
+
const clientMatch = matchRoute(_routes, loc.pathname);
|
|
1917
|
+
if (clientMatch?.data?.clientLoader) {
|
|
1918
|
+
const thisNav = ++navVersion;
|
|
1919
|
+
const ac = new AbortController();
|
|
1920
|
+
navAbort = ac;
|
|
1921
|
+
try {
|
|
1922
|
+
const um = await mountRouteWithHydration(island, viewHost, pathWithSearch, ac.signal, registry, reverseRegistry);
|
|
1923
|
+
if (thisNav === navVersion) viewHandle = um;
|
|
1924
|
+
else um.unmount();
|
|
1925
|
+
} catch (e) {
|
|
1926
|
+
if (e?.name !== "AbortError") console.error("[ilha-router] initial client loader render failed:", e);
|
|
1927
|
+
}
|
|
1928
|
+
return;
|
|
1929
|
+
}
|
|
1930
|
+
const loaderResult = !!clientMatch?.data?.hasLoader ? await fetchLoaderData(pathWithSearch) : {
|
|
1931
|
+
kind: "data",
|
|
1932
|
+
data: {}
|
|
1933
|
+
};
|
|
1934
|
+
if (loaderResult.kind === "redirect" || loaderResult.kind === "error") return;
|
|
1935
|
+
const props = loaderResult.kind === "data" ? loaderResult.data : {};
|
|
1936
|
+
const headStore = { entries: [...loaderResult.kind === "data" ? loaderResult.headEntries ?? [] : []] };
|
|
1937
|
+
await withHeadStore(headStore, () => island.toString(props));
|
|
1938
|
+
if (!mounted) return;
|
|
1939
|
+
applyHeadEntriesToDocument(headStore.entries);
|
|
1940
|
+
})();
|
|
1941
|
+
_revalidate = async () => {
|
|
1942
|
+
const island = activeIsland();
|
|
1943
|
+
const thisNav = ++navVersion;
|
|
1944
|
+
navAbort?.abort();
|
|
1945
|
+
const ac = new AbortController();
|
|
1946
|
+
navAbort = ac;
|
|
1947
|
+
const settle = beginNavigation();
|
|
1948
|
+
try {
|
|
1949
|
+
const loc = getAdapter().readLocation();
|
|
1950
|
+
const pathWithSearch = loc.pathname + loc.search;
|
|
1951
|
+
if (island !== null && island === currentMountedIsland) {
|
|
1952
|
+
const handle = viewHandle ?? adoptHydratedHandle();
|
|
1953
|
+
if (handle?.updateProps) {
|
|
1954
|
+
if (await updateRouteInPlace(handle, pathWithSearch, ac.signal) === "updated") {
|
|
1955
|
+
if (thisNav === navVersion) {
|
|
1956
|
+
viewHandle = handle;
|
|
1957
|
+
currentMountedPath = pathWithSearch;
|
|
1958
|
+
}
|
|
1959
|
+
return;
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
const um = await mountRouteWithHydration(island, viewHost, pathWithSearch, ac.signal, registry, reverseRegistry);
|
|
1964
|
+
if (thisNav === navVersion) {
|
|
1965
|
+
viewHandle?.unmount();
|
|
1966
|
+
viewHandle = um;
|
|
1967
|
+
currentMountedIsland = island;
|
|
1968
|
+
currentMountedPath = pathWithSearch;
|
|
1969
|
+
} else um.unmount();
|
|
1970
|
+
} catch (e) {
|
|
1971
|
+
if (e?.name !== "AbortError") console.error("[ilha-router] invalidate failed:", e);
|
|
1972
|
+
} finally {
|
|
1973
|
+
settle();
|
|
1974
|
+
}
|
|
1975
|
+
};
|
|
1976
|
+
return () => {
|
|
1977
|
+
mounted = false;
|
|
1978
|
+
++navVersion;
|
|
1979
|
+
_revalidate = null;
|
|
1980
|
+
navAbort?.abort();
|
|
1981
|
+
unmountNavHandler();
|
|
1982
|
+
navHost.remove();
|
|
1983
|
+
viewHandle?.unmount();
|
|
1984
|
+
_linkCleanup?.();
|
|
1985
|
+
_navChangeCleanup?.();
|
|
1986
|
+
_linkCleanup = null;
|
|
1987
|
+
_navChangeCleanup = null;
|
|
1988
|
+
if (prevScrollRestoration !== null) history.scrollRestoration = prevScrollRestoration;
|
|
1989
|
+
};
|
|
1990
|
+
}
|
|
1991
|
+
let mountedHandle = null;
|
|
1992
|
+
let mountedHandleIsland = null;
|
|
1993
|
+
let currentMountedIsland = null;
|
|
1994
|
+
let currentMountedPath = null;
|
|
1995
|
+
let navVersion = 0;
|
|
1996
|
+
unmountView = RouterView.mount(host);
|
|
1997
|
+
/**
|
|
1998
|
+
* Fetch loader data and mount the active island. SPA mode also fetches
|
|
1999
|
+
* from the loader endpoint — otherwise navigation after the initial SSR
|
|
2000
|
+
* render would have no access to loader data.
|
|
2001
|
+
*/
|
|
2002
|
+
async function mountActiveIsland(island, signal) {
|
|
2003
|
+
const sameIsland = island !== null && island === mountedHandleIsland && mountedHandle?.updateProps != null;
|
|
2004
|
+
const teardown = () => {
|
|
2005
|
+
mountedHandle?.unmount();
|
|
2006
|
+
mountedHandle = null;
|
|
2007
|
+
mountedHandleIsland = null;
|
|
2008
|
+
};
|
|
2009
|
+
if (!sameIsland) teardown();
|
|
2010
|
+
currentMountedIsland = island;
|
|
2011
|
+
currentMountedPath = routePath() + routeSearch();
|
|
2012
|
+
if (!island) {
|
|
2013
|
+
const nfHost = host?.querySelector("[data-router-not-found]");
|
|
2014
|
+
if (_notFound && nfHost) mountedHandle = {
|
|
2015
|
+
unmount: _notFound.mount(nfHost),
|
|
2016
|
+
updateProps: null
|
|
2017
|
+
};
|
|
2018
|
+
return;
|
|
2019
|
+
}
|
|
2020
|
+
const viewHost = host?.querySelector("[data-router-view]");
|
|
2021
|
+
if (!viewHost) return;
|
|
2022
|
+
const loc = getAdapter().readLocation();
|
|
2023
|
+
const clientMatch = matchRoute(_routes, loc.pathname);
|
|
2024
|
+
const result = !!clientMatch?.data?.hasLoader ? await fetchLoaderData(loc.pathname + loc.search, signal) : {
|
|
2025
|
+
kind: "data",
|
|
2026
|
+
data: {}
|
|
2027
|
+
};
|
|
2028
|
+
if (signal.aborted) return;
|
|
2029
|
+
if (result.kind === "redirect") {
|
|
2030
|
+
clientRedirect(result.to);
|
|
2031
|
+
return;
|
|
2032
|
+
}
|
|
2033
|
+
if (result.kind === "error") {
|
|
2034
|
+
if (sameIsland) teardown();
|
|
2035
|
+
const boundary = clientMatch?.data?.errorHandler;
|
|
2036
|
+
if (boundary) {
|
|
2037
|
+
mountedHandle = await withViewSwap(() => ({
|
|
2038
|
+
unmount: mountLoaderErrorBoundary(boundary, viewHost, result.status, result.message),
|
|
2039
|
+
updateProps: null
|
|
2040
|
+
}));
|
|
2041
|
+
return;
|
|
2042
|
+
}
|
|
2043
|
+
const escaped = escapeHtml(result.message);
|
|
2044
|
+
await withViewSwap(() => {
|
|
2045
|
+
viewHost.innerHTML = `<div data-router-error="${result.status}">${escaped}</div>`;
|
|
2046
|
+
});
|
|
2047
|
+
return;
|
|
2048
|
+
}
|
|
2049
|
+
const props = result.kind === "data" ? result.data : {};
|
|
2050
|
+
if (sameIsland && mountedHandle?.updateProps) {
|
|
2051
|
+
if (result.kind === "data" && result.headEntries?.length) applyHeadEntriesToDocument([...result.headEntries]);
|
|
2052
|
+
mountedHandle.updateProps(props);
|
|
2053
|
+
return;
|
|
2054
|
+
}
|
|
2055
|
+
const headStore = { entries: [...result.kind === "data" ? result.headEntries ?? [] : []] };
|
|
2056
|
+
const html = await withHeadStore(headStore, () => island.toString(props));
|
|
2057
|
+
mountedHandle = await withViewSwap(() => {
|
|
2058
|
+
applyHeadEntriesToDocument(headStore.entries);
|
|
2059
|
+
viewHost.innerHTML = html;
|
|
2060
|
+
return mountIslandWithHandle(island, viewHost, props);
|
|
2061
|
+
});
|
|
2062
|
+
mountedHandleIsland = island;
|
|
2063
|
+
}
|
|
2064
|
+
navAbort = new AbortController();
|
|
2065
|
+
mountActiveIsland(activeIsland(), navAbort.signal).catch((e) => {
|
|
2066
|
+
if (e?.name === "AbortError") return;
|
|
2067
|
+
console.error("[ilha-router] initial mount failed:", e);
|
|
2068
|
+
});
|
|
2069
|
+
const NavHandler = ilha.render(() => {
|
|
2070
|
+
const current = activeIsland();
|
|
2071
|
+
const pathWithSearch = routePath() + routeSearch();
|
|
2072
|
+
if (current !== currentMountedIsland || pathWithSearch !== currentMountedPath) {
|
|
2073
|
+
const thisNav = ++navVersion;
|
|
2074
|
+
navAbort?.abort();
|
|
2075
|
+
navAbort = new AbortController();
|
|
2076
|
+
const signal = navAbort.signal;
|
|
2077
|
+
queueMicrotask(() => {
|
|
2078
|
+
if (thisNav !== navVersion) return;
|
|
2079
|
+
const settle = beginNavigation();
|
|
2080
|
+
mountActiveIsland(current, signal).catch((e) => {
|
|
2081
|
+
if (e?.name === "AbortError") return;
|
|
2082
|
+
console.error("[ilha-router] navigation failed:", e);
|
|
2083
|
+
}).finally(settle);
|
|
2084
|
+
});
|
|
2085
|
+
}
|
|
2086
|
+
return "";
|
|
2087
|
+
});
|
|
2088
|
+
const navHost = document.createElement("div");
|
|
2089
|
+
navHost.style.display = "none";
|
|
2090
|
+
host.appendChild(navHost);
|
|
2091
|
+
const unmountNavHandler = NavHandler.mount(navHost);
|
|
2092
|
+
_revalidate = async () => {
|
|
2093
|
+
++navVersion;
|
|
2094
|
+
navAbort?.abort();
|
|
2095
|
+
navAbort = new AbortController();
|
|
2096
|
+
const settle = beginNavigation();
|
|
2097
|
+
try {
|
|
2098
|
+
await mountActiveIsland(activeIsland(), navAbort.signal);
|
|
2099
|
+
} catch (e) {
|
|
2100
|
+
if (e?.name !== "AbortError") console.error("[ilha-router] invalidate failed:", e);
|
|
2101
|
+
} finally {
|
|
2102
|
+
settle();
|
|
2103
|
+
}
|
|
2104
|
+
};
|
|
2105
|
+
return () => {
|
|
2106
|
+
mounted = false;
|
|
2107
|
+
++navVersion;
|
|
2108
|
+
_revalidate = null;
|
|
2109
|
+
navAbort?.abort();
|
|
2110
|
+
mountedHandle?.unmount();
|
|
2111
|
+
unmountNavHandler();
|
|
2112
|
+
navHost.remove();
|
|
2113
|
+
unmountView?.();
|
|
2114
|
+
_linkCleanup?.();
|
|
2115
|
+
_navChangeCleanup?.();
|
|
2116
|
+
_linkCleanup = null;
|
|
2117
|
+
_navChangeCleanup = null;
|
|
2118
|
+
if (prevScrollRestoration !== null) history.scrollRestoration = prevScrollRestoration;
|
|
2119
|
+
};
|
|
2120
|
+
},
|
|
2121
|
+
render(url) {
|
|
2122
|
+
const doRender = () => {
|
|
2123
|
+
syncRouteFromURL(url, routes);
|
|
2124
|
+
return RouterView.toString();
|
|
2125
|
+
};
|
|
2126
|
+
if (!isBrowser && _routeAls) return _routeAls.run(freshRouteStore(), doRender);
|
|
2127
|
+
return doRender();
|
|
2128
|
+
},
|
|
2129
|
+
async renderHydratable(urlOrRequest, registry, options = {}, request) {
|
|
2130
|
+
const { url, request: req } = resolveRequestUrl(urlOrRequest, request);
|
|
2131
|
+
const response = await this.renderResponse(url, registry, options, req);
|
|
2132
|
+
if (response.kind === "html") return response.html;
|
|
2133
|
+
if (response.kind === "error") return response.html;
|
|
2134
|
+
return `<meta http-equiv="refresh" content="0; url=${escapeHeadAttr(response.to)}">`;
|
|
2135
|
+
},
|
|
2136
|
+
async renderResponse(urlOrRequest, registry, options = {}, request) {
|
|
2137
|
+
const { url, request: req } = resolveRequestUrl(urlOrRequest, request);
|
|
2138
|
+
if (!isBrowser) {
|
|
2139
|
+
const als = await getRouteAlsAsync();
|
|
2140
|
+
if (!als.getStore()) return als.run(freshRouteStore(), () => renderResponseInner(url, registry, options, req));
|
|
2141
|
+
}
|
|
2142
|
+
return renderResponseInner(url, registry, options, req);
|
|
2143
|
+
},
|
|
2144
|
+
async respond(urlOrRequest, registry, options = {}) {
|
|
2145
|
+
const { shell, headers, cspNonce, contentSecurityPolicy, status, ...renderOptions } = options;
|
|
2146
|
+
const res = await this.renderResponse(urlOrRequest, registry, renderOptions, urlOrRequest instanceof Request ? urlOrRequest : void 0);
|
|
2147
|
+
if (res.kind === "redirect") {
|
|
2148
|
+
const h = new Headers(headers);
|
|
2149
|
+
h.set("location", res.to);
|
|
2150
|
+
return httpResponse(null, {
|
|
2151
|
+
status: res.status,
|
|
2152
|
+
headers: h,
|
|
2153
|
+
cspNonce,
|
|
2154
|
+
contentSecurityPolicy
|
|
2155
|
+
});
|
|
2156
|
+
}
|
|
2157
|
+
const head = res.head ?? EMPTY_HEAD;
|
|
2158
|
+
return httpResponse(shell ? shell(head, res.html) : res.html, {
|
|
2159
|
+
status: status ?? res.status ?? 200,
|
|
2160
|
+
headers,
|
|
2161
|
+
cspNonce,
|
|
2162
|
+
contentSecurityPolicy
|
|
2163
|
+
});
|
|
2164
|
+
},
|
|
2165
|
+
async runLoader(urlOrRequest, request) {
|
|
2166
|
+
const { url, request: req } = resolveRequestUrl(urlOrRequest, request);
|
|
2167
|
+
const parsed = parsedURL(url);
|
|
2168
|
+
const match = matchRoute(routes, parsed.pathname);
|
|
2169
|
+
if (!match?.data?.island) return { kind: "not-found" };
|
|
2170
|
+
if (!match.data.loader) return {
|
|
2171
|
+
kind: "data",
|
|
2172
|
+
data: {}
|
|
2173
|
+
};
|
|
2174
|
+
const params = extractParams(match.params);
|
|
2175
|
+
const renderReq = req ?? defaultRequest(parsed);
|
|
2176
|
+
const abort = loaderAbort(req, loaderTimeout);
|
|
2177
|
+
const headStore = { entries: [] };
|
|
2178
|
+
try {
|
|
2179
|
+
const result = await executeLoader(match.data.loader, parsed, params, renderReq, abort.signal, (input) => headStore.entries.push(input));
|
|
2180
|
+
if (result.kind === "redirect") {
|
|
2181
|
+
const safe = resolveRedirectTarget(result.to, parsed, allowExternalRedirects);
|
|
2182
|
+
if (!safe.ok) {
|
|
2183
|
+
console.warn(`[ilha-router] Blocked unsafe redirect target "${result.to}". Set allowExternalRedirects: true to allow cross-origin redirects.`);
|
|
2184
|
+
return {
|
|
2185
|
+
kind: "error",
|
|
2186
|
+
status: 500,
|
|
2187
|
+
message: "Unsafe redirect target"
|
|
2188
|
+
};
|
|
2189
|
+
}
|
|
2190
|
+
return {
|
|
2191
|
+
...result,
|
|
2192
|
+
to: safe.to
|
|
2193
|
+
};
|
|
2194
|
+
}
|
|
2195
|
+
if (result.kind !== "data") return result;
|
|
2196
|
+
const enveloped = {
|
|
2197
|
+
...result,
|
|
2198
|
+
data: { load: {
|
|
2199
|
+
loading: false,
|
|
2200
|
+
value: result.data ?? {},
|
|
2201
|
+
error: void 0
|
|
2202
|
+
} }
|
|
2203
|
+
};
|
|
2204
|
+
if (headStore.entries.length === 0) return enveloped;
|
|
2205
|
+
return {
|
|
2206
|
+
...enveloped,
|
|
2207
|
+
head: serializeHead(headStore.entries),
|
|
2208
|
+
headEntries: headStore.entries
|
|
2209
|
+
};
|
|
2210
|
+
} finally {
|
|
2211
|
+
abort.done();
|
|
2212
|
+
}
|
|
2213
|
+
},
|
|
2214
|
+
hydrate(registry, options = {}) {
|
|
2215
|
+
if (!isBrowser) {
|
|
2216
|
+
console.warn("[ilha-router] hydrate() called in a non-browser environment");
|
|
2217
|
+
return () => {};
|
|
2218
|
+
}
|
|
2219
|
+
const root = options.root ?? document.body;
|
|
2220
|
+
const target = options.target ?? root;
|
|
2221
|
+
prime();
|
|
2222
|
+
const { unmount } = mount(registry, { root });
|
|
2223
|
+
const unmountRouter = this.mount(target, {
|
|
2224
|
+
hydrate: true,
|
|
2225
|
+
registry,
|
|
2226
|
+
interceptLinks: options.interceptLinks
|
|
2227
|
+
});
|
|
2228
|
+
return () => {
|
|
2229
|
+
unmount();
|
|
2230
|
+
unmountRouter();
|
|
2231
|
+
};
|
|
2232
|
+
}
|
|
2233
|
+
};
|
|
2234
|
+
async function renderResponseInner(url, registry, options = {}, request) {
|
|
2235
|
+
const { baseHead, ...renderOptions } = options;
|
|
2236
|
+
const parsed = parsedURL(url);
|
|
2237
|
+
syncRouteFromURL(parsed, routes);
|
|
2238
|
+
const match = matchRoute(routes, parsed.pathname);
|
|
2239
|
+
const island = match?.data?.island ?? null;
|
|
2240
|
+
if (!island) {
|
|
2241
|
+
const headStore = { entries: baseHead ? [baseHead] : [] };
|
|
2242
|
+
if (notFound) return {
|
|
2243
|
+
kind: "html",
|
|
2244
|
+
html: `<div data-router-view data-router-not-found>${await withHeadStore(headStore, () => notFound.toString())}</div>`,
|
|
2245
|
+
status: 404,
|
|
2246
|
+
head: serializeHead(headStore.entries)
|
|
2247
|
+
};
|
|
2248
|
+
return {
|
|
2249
|
+
kind: "html",
|
|
2250
|
+
html: `<div data-router-empty></div>`,
|
|
2251
|
+
status: 404,
|
|
2252
|
+
head: baseHead ? serializeHead([baseHead]) : void 0
|
|
2253
|
+
};
|
|
2254
|
+
}
|
|
2255
|
+
const headStore = { entries: baseHead ? [baseHead] : [] };
|
|
2256
|
+
let props = {};
|
|
2257
|
+
if (match?.data?.loader) {
|
|
2258
|
+
const req = request ?? defaultRequest(parsed);
|
|
2259
|
+
const abort = loaderAbort(request, loaderTimeout);
|
|
2260
|
+
let result;
|
|
2261
|
+
try {
|
|
2262
|
+
result = await executeLoader(match.data.loader, parsed, routeParams(), req, abort.signal, (input) => headStore.entries.push(input));
|
|
2263
|
+
} finally {
|
|
2264
|
+
abort.done();
|
|
2265
|
+
}
|
|
2266
|
+
if (result.kind === "redirect") {
|
|
2267
|
+
const safe = resolveRedirectTarget(result.to, parsed, allowExternalRedirects);
|
|
2268
|
+
if (safe.ok) return {
|
|
2269
|
+
kind: "redirect",
|
|
2270
|
+
to: safe.to,
|
|
2271
|
+
status: result.status
|
|
2272
|
+
};
|
|
2273
|
+
else {
|
|
2274
|
+
console.warn(`[ilha-router] Blocked unsafe redirect target "${result.to}". Set allowExternalRedirects: true to allow cross-origin redirects.`);
|
|
2275
|
+
result = {
|
|
2276
|
+
kind: "error",
|
|
2277
|
+
status: 500,
|
|
2278
|
+
message: "Unsafe redirect target"
|
|
2279
|
+
};
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
if (result.kind === "error") {
|
|
2283
|
+
const boundary = match.data.errorHandler;
|
|
2284
|
+
if (boundary) try {
|
|
2285
|
+
const errorIsland = boundary({
|
|
2286
|
+
message: result.message,
|
|
2287
|
+
status: result.status
|
|
2288
|
+
}, {
|
|
2289
|
+
path: routePath(),
|
|
2290
|
+
params: routeParams(),
|
|
2291
|
+
search: routeSearch(),
|
|
2292
|
+
hash: routeHash()
|
|
2293
|
+
});
|
|
2294
|
+
const html = await withHeadStore(headStore, () => errorIsland.toString());
|
|
2295
|
+
return {
|
|
2296
|
+
kind: "error",
|
|
2297
|
+
status: result.status,
|
|
2298
|
+
message: result.message,
|
|
2299
|
+
html: `<div data-router-view data-router-error="${result.status}">${html}</div>`,
|
|
2300
|
+
head: serializeHead(headStore.entries)
|
|
2301
|
+
};
|
|
2302
|
+
} catch (e) {
|
|
2303
|
+
console.error("[ilha-router] error boundary threw while rendering a loader error:", e);
|
|
2304
|
+
}
|
|
2305
|
+
const escapedMessage = escapeHtml(result.message);
|
|
2306
|
+
const html = `<div data-router-view data-router-error="${result.status}">${escapedMessage}</div>`;
|
|
2307
|
+
return {
|
|
2308
|
+
kind: "error",
|
|
2309
|
+
status: result.status,
|
|
2310
|
+
message: result.message,
|
|
2311
|
+
html,
|
|
2312
|
+
head: serializeHead(headStore.entries)
|
|
2313
|
+
};
|
|
2314
|
+
}
|
|
2315
|
+
props = envelopLoad(result.data);
|
|
2316
|
+
}
|
|
2317
|
+
const name = buildReverseRegistry(registry).get(island);
|
|
2318
|
+
if (!name) {
|
|
2319
|
+
console.warn(`[ilha-router] renderHydratable: active island for "${routePath()}" is not in the registry. Falling back to plain SSR — the island will not be interactive on the client.`);
|
|
2320
|
+
return {
|
|
2321
|
+
kind: "html",
|
|
2322
|
+
html: `<div data-router-view>${await withHeadStore(headStore, () => island.toString(props))}</div>`,
|
|
2323
|
+
head: serializeHead(headStore.entries)
|
|
2324
|
+
};
|
|
2325
|
+
}
|
|
2326
|
+
const islandAls = globalThis[REQUEST_ALS_KEY];
|
|
2327
|
+
return {
|
|
2328
|
+
kind: "html",
|
|
2329
|
+
html: `<div data-router-view>${await (islandAls?.run && request ? islandAls.run(request, () => withHeadStore(headStore, () => island.hydratable(props, {
|
|
2330
|
+
name,
|
|
2331
|
+
as: "div",
|
|
2332
|
+
snapshot: false,
|
|
2333
|
+
...renderOptions
|
|
2334
|
+
}))) : withHeadStore(headStore, () => island.hydratable(props, {
|
|
2335
|
+
name,
|
|
2336
|
+
as: "div",
|
|
2337
|
+
snapshot: false,
|
|
2338
|
+
...renderOptions
|
|
2339
|
+
})))}</div>`,
|
|
2340
|
+
head: serializeHead(headStore.entries)
|
|
2341
|
+
};
|
|
2342
|
+
}
|
|
2343
|
+
return builder;
|
|
2344
|
+
}
|
|
2345
|
+
var src_default = {
|
|
2346
|
+
router,
|
|
2347
|
+
navigate,
|
|
2348
|
+
useRoute,
|
|
2349
|
+
isActive,
|
|
2350
|
+
enableLinkInterception,
|
|
2351
|
+
prime,
|
|
2352
|
+
prefetch,
|
|
2353
|
+
beforeNavigate,
|
|
2354
|
+
afterNavigate,
|
|
2355
|
+
RouterView,
|
|
2356
|
+
RouterLink,
|
|
2357
|
+
loader,
|
|
2358
|
+
redirect,
|
|
2359
|
+
error,
|
|
2360
|
+
composeLoaders,
|
|
2361
|
+
head
|
|
2362
|
+
};
|
|
2363
|
+
|
|
2364
|
+
//#endregion
|
|
2365
|
+
export { useContext as A, routeHash as C, router as D, routeSearch as E, parsePattern as F, safeDecode as I, getHistoryMode as L, wrapError as M, wrapLayout as N, serializeHead as O, matchSegments as P, setHistoryMode as R, resolveRedirectTarget as S, routePath as T, navigate as _, RouterView as a, prime as b, composeLoaders as c, error as d, head as f, loader as g, isActive as h, RouterLink as i, useRoute as j, src_default as k, defineLayout as l, invalidate as m, LoaderError as n, afterNavigate as o, httpResponse as p, Redirect as r, beforeNavigate as s, LOADER_ENDPOINT as t, enableLinkInterception as u, navigating as v, routeParams as w, redirect as x, prefetch as y };
|