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