@ilha/router 0.2.5 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +86 -0
- package/dist/index-BsCpWQzC.d.ts +254 -0
- package/dist/index.d.ts +2 -236
- package/dist/index.js +2 -637
- package/dist/src-C2qmhASZ.js +776 -0
- package/dist/vite.d.ts +1 -1
- package/dist/vite.js +13 -4
- package/package.json +1 -1
|
@@ -0,0 +1,776 @@
|
|
|
1
|
+
import ilha, { context, html, mount } from "ilha";
|
|
2
|
+
import { addRoute, createRouter, findRoute } from "rou3";
|
|
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) {
|
|
19
|
+
if (!isBrowser$1) return;
|
|
20
|
+
history.pushState(null, "", to);
|
|
21
|
+
},
|
|
22
|
+
replace(to) {
|
|
23
|
+
if (!isBrowser$1) return;
|
|
24
|
+
history.replaceState(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) {
|
|
63
|
+
if (!isBrowser$1) return;
|
|
64
|
+
history.pushState(null, "", to.startsWith("#") ? to : "#" + to);
|
|
65
|
+
},
|
|
66
|
+
replace(to) {
|
|
67
|
+
if (!isBrowser$1) return;
|
|
68
|
+
history.replaceState(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
|
+
//#endregion
|
|
132
|
+
//#region src/index.ts
|
|
133
|
+
const isBrowser = typeof window !== "undefined" && typeof document !== "undefined";
|
|
134
|
+
/**
|
|
135
|
+
* Identity function for declaring a loader. Exists purely as a type anchor and
|
|
136
|
+
* a marker for the Vite plugin to detect by export name.
|
|
137
|
+
*/
|
|
138
|
+
function loader(fn) {
|
|
139
|
+
return fn;
|
|
140
|
+
}
|
|
141
|
+
var Redirect = class {
|
|
142
|
+
__ilhaRedirect = true;
|
|
143
|
+
to;
|
|
144
|
+
status;
|
|
145
|
+
constructor(to, status = 302) {
|
|
146
|
+
this.to = to;
|
|
147
|
+
this.status = status;
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
var LoaderError = class {
|
|
151
|
+
__ilhaLoaderError = true;
|
|
152
|
+
status;
|
|
153
|
+
message;
|
|
154
|
+
constructor(status, message) {
|
|
155
|
+
this.status = status;
|
|
156
|
+
this.message = message;
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
function redirect(to, status = 302) {
|
|
160
|
+
throw new Redirect(to, status);
|
|
161
|
+
}
|
|
162
|
+
function error(status, message) {
|
|
163
|
+
throw new LoaderError(status, message);
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Compose a list of loaders into a single loader. Later loaders win on key
|
|
167
|
+
* collision (page loader overrides layout loader for the same key). All loaders
|
|
168
|
+
* run concurrently within a chain since they share the same abort signal and
|
|
169
|
+
* request — re-fetching is cheap with a request-scoped cache (future work).
|
|
170
|
+
*
|
|
171
|
+
* For v1 we run them in parallel via `Promise.all`. If a loader throws a
|
|
172
|
+
* `Redirect` or `LoaderError`, the composed loader re-throws it unchanged.
|
|
173
|
+
*/
|
|
174
|
+
function composeLoaders(loaders) {
|
|
175
|
+
if (loaders.length === 0) return async () => ({});
|
|
176
|
+
if (loaders.length === 1) return loaders[0];
|
|
177
|
+
return async (ctx) => {
|
|
178
|
+
const results = await Promise.all(loaders.map((l) => l(ctx)));
|
|
179
|
+
return Object.assign({}, ...results);
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
function wrapLayout(layout, page) {
|
|
183
|
+
return layout(page);
|
|
184
|
+
}
|
|
185
|
+
function wrapError(handler, page) {
|
|
186
|
+
const Wrapper = ilha.render(() => {
|
|
187
|
+
try {
|
|
188
|
+
return page.toString();
|
|
189
|
+
} catch (e) {
|
|
190
|
+
const route = {
|
|
191
|
+
path: routePath(),
|
|
192
|
+
params: routeParams(),
|
|
193
|
+
search: routeSearch(),
|
|
194
|
+
hash: routeHash()
|
|
195
|
+
};
|
|
196
|
+
return handler({
|
|
197
|
+
message: e.message,
|
|
198
|
+
status: e.status,
|
|
199
|
+
stack: e.stack
|
|
200
|
+
}, route).toString();
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
Wrapper.mount = (host, props) => {
|
|
204
|
+
try {
|
|
205
|
+
return page.mount(host, props);
|
|
206
|
+
} catch (e) {
|
|
207
|
+
const route = {
|
|
208
|
+
path: routePath(),
|
|
209
|
+
params: routeParams(),
|
|
210
|
+
search: routeSearch(),
|
|
211
|
+
hash: routeHash()
|
|
212
|
+
};
|
|
213
|
+
const errorIsland = handler({
|
|
214
|
+
message: e.message,
|
|
215
|
+
status: e.status,
|
|
216
|
+
stack: e.stack
|
|
217
|
+
}, route);
|
|
218
|
+
host.innerHTML = errorIsland.toString();
|
|
219
|
+
return errorIsland.mount(host, props);
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
return Wrapper;
|
|
223
|
+
}
|
|
224
|
+
function defineLayout(layout) {
|
|
225
|
+
return layout;
|
|
226
|
+
}
|
|
227
|
+
function buildReverseRegistry(registry) {
|
|
228
|
+
const map = /* @__PURE__ */ new Map();
|
|
229
|
+
for (const [name, island] of Object.entries(registry)) if (!map.has(island)) map.set(island, name);
|
|
230
|
+
return map;
|
|
231
|
+
}
|
|
232
|
+
/** Path of the loader endpoint served by the Vite plugin / production adapter. */
|
|
233
|
+
const LOADER_ENDPOINT = "/__ilha/loader";
|
|
234
|
+
/** In-memory cache for prefetched loader data, keyed by path+search. */
|
|
235
|
+
const prefetchCache = /* @__PURE__ */ new Map();
|
|
236
|
+
async function fetchLoaderData(pathWithSearch, signal) {
|
|
237
|
+
const cached = prefetchCache.get(pathWithSearch);
|
|
238
|
+
if (cached) {
|
|
239
|
+
prefetchCache.delete(pathWithSearch);
|
|
240
|
+
try {
|
|
241
|
+
return await cached;
|
|
242
|
+
} catch {}
|
|
243
|
+
}
|
|
244
|
+
const url = `${LOADER_ENDPOINT}?path=${encodeURIComponent(pathWithSearch)}`;
|
|
245
|
+
try {
|
|
246
|
+
const res = await fetch(url, {
|
|
247
|
+
signal,
|
|
248
|
+
headers: { accept: "application/json" }
|
|
249
|
+
});
|
|
250
|
+
if (!res.ok) {
|
|
251
|
+
try {
|
|
252
|
+
const body = await res.json();
|
|
253
|
+
if (body && typeof body === "object" && "kind" in body) return body;
|
|
254
|
+
} catch {}
|
|
255
|
+
return {
|
|
256
|
+
kind: "error",
|
|
257
|
+
status: res.status,
|
|
258
|
+
message: res.statusText
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
return await res.json();
|
|
262
|
+
} catch (e) {
|
|
263
|
+
if (e?.name === "AbortError") throw e;
|
|
264
|
+
return {
|
|
265
|
+
kind: "error",
|
|
266
|
+
status: 0,
|
|
267
|
+
message: e?.message ?? "network error"
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Prefetch loader data for a given path. Safe to call repeatedly — a single
|
|
273
|
+
* inflight request is reused until it either resolves (and is consumed by
|
|
274
|
+
* navigation) or is superseded by another prefetch.
|
|
275
|
+
*/
|
|
276
|
+
function prefetch(pathWithSearch) {
|
|
277
|
+
if (!isBrowser) return;
|
|
278
|
+
if (prefetchCache.has(pathWithSearch)) return;
|
|
279
|
+
const pathOnly = pathWithSearch.split("?")[0] ?? "";
|
|
280
|
+
if (!findRoute(_rou3, "GET", pathOnly)?.data?.loader) return;
|
|
281
|
+
const promise = fetchLoaderData(pathWithSearch).catch((e) => {
|
|
282
|
+
return {
|
|
283
|
+
kind: "error",
|
|
284
|
+
status: 0,
|
|
285
|
+
message: e?.message ?? "prefetch failed"
|
|
286
|
+
};
|
|
287
|
+
});
|
|
288
|
+
prefetchCache.set(pathWithSearch, promise);
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Mounts a route island with proper hydration for client-side navigation.
|
|
292
|
+
* Looks up the island in the reverse registry, runs the loader (via fetch),
|
|
293
|
+
* renders it with hydration markers, and mounts it for interactivity.
|
|
294
|
+
*/
|
|
295
|
+
async function mountRouteWithHydration(island, host, pathWithSearch, signal, registry, reverseRegistry) {
|
|
296
|
+
if (!island) {
|
|
297
|
+
host.innerHTML = `<div data-router-empty></div>`;
|
|
298
|
+
return () => {};
|
|
299
|
+
}
|
|
300
|
+
const hasLoader = !!findRoute(_rou3, "GET", pathWithSearch.split("?")[0] ?? "")?.data?.loader;
|
|
301
|
+
let props = {};
|
|
302
|
+
const loaderResult = hasLoader ? await fetchLoaderData(pathWithSearch, signal) : {
|
|
303
|
+
kind: "data",
|
|
304
|
+
data: {}
|
|
305
|
+
};
|
|
306
|
+
if (loaderResult.kind === "redirect") {
|
|
307
|
+
navigate(loaderResult.to, { replace: true });
|
|
308
|
+
return () => {};
|
|
309
|
+
}
|
|
310
|
+
if (loaderResult.kind === "error") {
|
|
311
|
+
const escaped = String(loaderResult.message).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
312
|
+
host.innerHTML = `<div data-router-view data-router-error="${loaderResult.status}">${escaped}</div>`;
|
|
313
|
+
return () => {};
|
|
314
|
+
}
|
|
315
|
+
if (loaderResult.kind === "not-found") {
|
|
316
|
+
host.innerHTML = `<div data-router-empty></div>`;
|
|
317
|
+
return () => {};
|
|
318
|
+
}
|
|
319
|
+
props = loaderResult.data;
|
|
320
|
+
if (!registry) {
|
|
321
|
+
console.warn("[ilha-router] No registry provided for client-side navigation. Island will not be interactive.");
|
|
322
|
+
host.innerHTML = `<div data-router-view>${island.toString(props)}</div>`;
|
|
323
|
+
return () => {};
|
|
324
|
+
}
|
|
325
|
+
const name = reverseRegistry?.get(island) ?? Object.entries(registry).find(([, v]) => v === island)?.[0];
|
|
326
|
+
if (!name) {
|
|
327
|
+
console.warn("[ilha-router] Island not found in registry for client-side navigation.");
|
|
328
|
+
host.innerHTML = `<div data-router-view>${island.toString(props)}</div>`;
|
|
329
|
+
return () => {};
|
|
330
|
+
}
|
|
331
|
+
host.innerHTML = `<div data-router-view>${await island.hydratable(props, {
|
|
332
|
+
name,
|
|
333
|
+
as: "div",
|
|
334
|
+
snapshot: true
|
|
335
|
+
})}</div>`;
|
|
336
|
+
const islandHost = host.querySelector(`[data-ilha="${name}"]`);
|
|
337
|
+
if (islandHost) return island.mount(islandHost);
|
|
338
|
+
return () => {};
|
|
339
|
+
}
|
|
340
|
+
const routePath = context("router.path", "");
|
|
341
|
+
const routeParams = context("router.params", {});
|
|
342
|
+
const routeSearch = context("router.search", "");
|
|
343
|
+
const routeHash = context("router.hash", "");
|
|
344
|
+
function useRoute() {
|
|
345
|
+
return {
|
|
346
|
+
path: routePath,
|
|
347
|
+
params: routeParams,
|
|
348
|
+
search: routeSearch,
|
|
349
|
+
hash: routeHash
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
const activeIsland = context("router.active", null);
|
|
353
|
+
let _records = [];
|
|
354
|
+
let _rou3 = createRouter();
|
|
355
|
+
let _islandToPattern = /* @__PURE__ */ new Map();
|
|
356
|
+
let _patternToData = /* @__PURE__ */ new Map();
|
|
357
|
+
function extractParams(matchParams) {
|
|
358
|
+
const params = {};
|
|
359
|
+
if (matchParams) for (const [k, v] of Object.entries(matchParams)) params[k] = decodeURIComponent(v);
|
|
360
|
+
return params;
|
|
361
|
+
}
|
|
362
|
+
function syncRouteFromURL(url) {
|
|
363
|
+
const parsed = typeof url === "string" ? new URL(url, "http://localhost") : url;
|
|
364
|
+
const match = findRoute(_rou3, "GET", parsed.pathname);
|
|
365
|
+
routePath(parsed.pathname);
|
|
366
|
+
routeParams(extractParams(match?.params));
|
|
367
|
+
routeSearch(parsed.search);
|
|
368
|
+
routeHash(parsed.hash);
|
|
369
|
+
activeIsland(match?.data?.island ?? null);
|
|
370
|
+
}
|
|
371
|
+
/** Client-only fast path — reads directly from the history adapter (location in history mode, hash content in hash mode). */
|
|
372
|
+
function syncRouteFromLocation() {
|
|
373
|
+
const loc = getAdapter().readLocation();
|
|
374
|
+
const match = findRoute(_rou3, "GET", loc.pathname);
|
|
375
|
+
routePath(loc.pathname);
|
|
376
|
+
routeParams(extractParams(match?.params));
|
|
377
|
+
routeSearch(loc.search);
|
|
378
|
+
routeHash(loc.hash);
|
|
379
|
+
activeIsland(match?.data?.island ?? null);
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Prime route context signals from the current `location` so that islands
|
|
383
|
+
* hydrated by `ilha.mount()` see the correct route values on their first
|
|
384
|
+
* render — preventing a mismatch morph that would destroy hydrated bindings.
|
|
385
|
+
*/
|
|
386
|
+
function prime() {
|
|
387
|
+
if (isBrowser) syncRouteFromLocation();
|
|
388
|
+
}
|
|
389
|
+
function navigate(to, opts = {}) {
|
|
390
|
+
if (!isBrowser) return;
|
|
391
|
+
const adapter = getAdapter();
|
|
392
|
+
const cur = adapter.readLocation();
|
|
393
|
+
if (to === cur.pathname + cur.search + cur.hash) return;
|
|
394
|
+
if (opts.replace) adapter.replace(to);
|
|
395
|
+
else adapter.push(to);
|
|
396
|
+
syncRouteFromLocation();
|
|
397
|
+
}
|
|
398
|
+
function enableLinkInterception(root = document, options = {}) {
|
|
399
|
+
if (!isBrowser) return () => {};
|
|
400
|
+
const prefetchEnabled = options.prefetch !== false;
|
|
401
|
+
/**
|
|
402
|
+
* Determine whether this anchor is a same-origin in-app link we should handle,
|
|
403
|
+
* and if so, the logical path to navigate to. Returns null when the link
|
|
404
|
+
* should be left to the browser (external, modifier held, target=_blank, etc).
|
|
405
|
+
*/
|
|
406
|
+
function logicalPathFor(target, e) {
|
|
407
|
+
const isBlank = target.getAttribute("target") === "_blank";
|
|
408
|
+
const hasModifier = !!e && (e.ctrlKey || e.metaKey || e.shiftKey);
|
|
409
|
+
const hasNoIntercept = target.hasAttribute("data-no-intercept");
|
|
410
|
+
if (isBlank || hasModifier || hasNoIntercept) return null;
|
|
411
|
+
return getAdapter().extractLogicalPath(target);
|
|
412
|
+
}
|
|
413
|
+
const clickHandler = (e) => {
|
|
414
|
+
if (e.defaultPrevented) return;
|
|
415
|
+
const target = e.target.closest("a");
|
|
416
|
+
if (!target) return;
|
|
417
|
+
const path = logicalPathFor(target, e);
|
|
418
|
+
if (path === null) return;
|
|
419
|
+
e.preventDefault();
|
|
420
|
+
navigate(path);
|
|
421
|
+
};
|
|
422
|
+
const hoverHandler = (e) => {
|
|
423
|
+
const target = e.target.closest("a");
|
|
424
|
+
if (!target) return;
|
|
425
|
+
const flag = target.getAttribute("data-prefetch");
|
|
426
|
+
if (flag === null || flag === "false") return;
|
|
427
|
+
const path = logicalPathFor(target);
|
|
428
|
+
if (path === null) return;
|
|
429
|
+
prefetch(path.split("#")[0] ?? path);
|
|
430
|
+
};
|
|
431
|
+
root.addEventListener("click", clickHandler);
|
|
432
|
+
if (prefetchEnabled) root.addEventListener("mouseover", hoverHandler, { passive: true });
|
|
433
|
+
return () => {
|
|
434
|
+
root.removeEventListener("click", clickHandler);
|
|
435
|
+
if (prefetchEnabled) root.removeEventListener("mouseover", hoverHandler);
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
const RouterView = ilha.render(() => {
|
|
439
|
+
const island = activeIsland();
|
|
440
|
+
if (!island) return `<div data-router-empty></div>`;
|
|
441
|
+
return `<div data-router-view>${island.toString()}</div>`;
|
|
442
|
+
});
|
|
443
|
+
const RouterLink = ilha.state("href", "").state("label", "").on("[data-link]@click", ({ state, event }) => {
|
|
444
|
+
event.preventDefault();
|
|
445
|
+
navigate(state.href());
|
|
446
|
+
}).on("[data-link]@mouseenter", ({ state }) => {
|
|
447
|
+
const href = state.href();
|
|
448
|
+
if (!href) return;
|
|
449
|
+
if (/^https?:\/\//i.test(href)) try {
|
|
450
|
+
const u = new URL(href);
|
|
451
|
+
if (u.origin !== location.origin) return;
|
|
452
|
+
prefetch(u.pathname + u.search);
|
|
453
|
+
return;
|
|
454
|
+
} catch {
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
prefetch(href);
|
|
458
|
+
}).render(({ state }) => html`<a data-link data-prefetch href="${() => getAdapter().toLinkHref(state.href())}"
|
|
459
|
+
>${state.label}</a
|
|
460
|
+
>`);
|
|
461
|
+
function isActive(pattern) {
|
|
462
|
+
const match = findRoute(_rou3, "GET", routePath());
|
|
463
|
+
if (!match) return false;
|
|
464
|
+
return _islandToPattern.get(match.data.island) === pattern;
|
|
465
|
+
}
|
|
466
|
+
function parsedURL(url) {
|
|
467
|
+
return typeof url === "string" ? new URL(url, "http://localhost") : url;
|
|
468
|
+
}
|
|
469
|
+
function defaultRequest(url) {
|
|
470
|
+
try {
|
|
471
|
+
return new Request(url.toString());
|
|
472
|
+
} catch {
|
|
473
|
+
return {
|
|
474
|
+
url: url.toString(),
|
|
475
|
+
headers: new Headers()
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
async function executeLoader(loader, url, params, request, signal) {
|
|
480
|
+
try {
|
|
481
|
+
return {
|
|
482
|
+
kind: "data",
|
|
483
|
+
data: await loader({
|
|
484
|
+
params,
|
|
485
|
+
request,
|
|
486
|
+
url,
|
|
487
|
+
signal
|
|
488
|
+
}) ?? {}
|
|
489
|
+
};
|
|
490
|
+
} catch (e) {
|
|
491
|
+
if (e instanceof Redirect) return {
|
|
492
|
+
kind: "redirect",
|
|
493
|
+
to: e.to,
|
|
494
|
+
status: e.status
|
|
495
|
+
};
|
|
496
|
+
if (e instanceof LoaderError) return {
|
|
497
|
+
kind: "error",
|
|
498
|
+
status: e.status,
|
|
499
|
+
message: e.message
|
|
500
|
+
};
|
|
501
|
+
return {
|
|
502
|
+
kind: "error",
|
|
503
|
+
status: e?.status ?? 500,
|
|
504
|
+
message: e?.message ?? "Loader failed"
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
function router() {
|
|
509
|
+
_records = [];
|
|
510
|
+
_rou3 = createRouter();
|
|
511
|
+
_islandToPattern = /* @__PURE__ */ new Map();
|
|
512
|
+
_patternToData = /* @__PURE__ */ new Map();
|
|
513
|
+
let _navChangeCleanup = null;
|
|
514
|
+
let _linkCleanup = null;
|
|
515
|
+
const builder = {
|
|
516
|
+
route(pattern, island, loader) {
|
|
517
|
+
const data = {
|
|
518
|
+
island,
|
|
519
|
+
loader
|
|
520
|
+
};
|
|
521
|
+
_records.push({
|
|
522
|
+
pattern,
|
|
523
|
+
island,
|
|
524
|
+
loader
|
|
525
|
+
});
|
|
526
|
+
addRoute(_rou3, "GET", pattern, data);
|
|
527
|
+
_patternToData.set(pattern, data);
|
|
528
|
+
if (!_islandToPattern.has(island)) _islandToPattern.set(island, pattern);
|
|
529
|
+
return builder;
|
|
530
|
+
},
|
|
531
|
+
attachLoader(pattern, loader) {
|
|
532
|
+
const data = _patternToData.get(pattern);
|
|
533
|
+
if (!data) {
|
|
534
|
+
console.warn(`[ilha-router] attachLoader("${pattern}", …): pattern was never registered via .route(). The loader will be ignored.`);
|
|
535
|
+
return builder;
|
|
536
|
+
}
|
|
537
|
+
data.loader = loader;
|
|
538
|
+
const rec = _records.find((r) => r.pattern === pattern);
|
|
539
|
+
if (rec) rec.loader = loader;
|
|
540
|
+
return builder;
|
|
541
|
+
},
|
|
542
|
+
prime,
|
|
543
|
+
mount(target, { hydrate = false, registry } = {}) {
|
|
544
|
+
if (!isBrowser) {
|
|
545
|
+
console.warn("[ilha-router] mount() called in a non-browser environment");
|
|
546
|
+
return () => {};
|
|
547
|
+
}
|
|
548
|
+
const host = typeof target === "string" ? document.querySelector(target) : target;
|
|
549
|
+
if (!host) {
|
|
550
|
+
console.warn(`[ilha-router] No element found for selector "${target}"`);
|
|
551
|
+
return () => {};
|
|
552
|
+
}
|
|
553
|
+
syncRouteFromLocation();
|
|
554
|
+
const popHandler = () => syncRouteFromLocation();
|
|
555
|
+
_navChangeCleanup = getAdapter().onChange(popHandler);
|
|
556
|
+
_linkCleanup = enableLinkInterception(document);
|
|
557
|
+
let unmountView = null;
|
|
558
|
+
let navAbort = null;
|
|
559
|
+
if (hydrate) {
|
|
560
|
+
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.");
|
|
561
|
+
const viewHost = host.querySelector("[data-router-view]") ?? host;
|
|
562
|
+
let currentMountedIsland = activeIsland();
|
|
563
|
+
const reverseRegistry = registry ? buildReverseRegistry(registry) : void 0;
|
|
564
|
+
let navVersion = 0;
|
|
565
|
+
const NavHandler = ilha.render(() => {
|
|
566
|
+
const current = activeIsland();
|
|
567
|
+
if (current !== currentMountedIsland) {
|
|
568
|
+
const thisNav = ++navVersion;
|
|
569
|
+
navAbort?.abort();
|
|
570
|
+
navAbort = new AbortController();
|
|
571
|
+
const signal = navAbort.signal;
|
|
572
|
+
queueMicrotask(async () => {
|
|
573
|
+
if (thisNav !== navVersion) return;
|
|
574
|
+
unmountView?.();
|
|
575
|
+
try {
|
|
576
|
+
const loc = getAdapter().readLocation();
|
|
577
|
+
unmountView = await mountRouteWithHydration(current, viewHost, loc.pathname + loc.search, signal, registry, reverseRegistry);
|
|
578
|
+
} catch (e) {
|
|
579
|
+
if (e?.name === "AbortError") return;
|
|
580
|
+
throw e;
|
|
581
|
+
}
|
|
582
|
+
currentMountedIsland = current;
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
return "";
|
|
586
|
+
});
|
|
587
|
+
const navHost = document.createElement("div");
|
|
588
|
+
navHost.style.display = "none";
|
|
589
|
+
host.appendChild(navHost);
|
|
590
|
+
const unmountNavHandler = NavHandler.mount(navHost);
|
|
591
|
+
return () => {
|
|
592
|
+
++navVersion;
|
|
593
|
+
navAbort?.abort();
|
|
594
|
+
unmountNavHandler();
|
|
595
|
+
navHost.remove();
|
|
596
|
+
unmountView?.();
|
|
597
|
+
_navChangeCleanup?.();
|
|
598
|
+
_linkCleanup?.();
|
|
599
|
+
_navChangeCleanup = null;
|
|
600
|
+
_linkCleanup = null;
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
let unmountIsland = null;
|
|
604
|
+
let currentMountedIsland = null;
|
|
605
|
+
let navVersion = 0;
|
|
606
|
+
unmountView = RouterView.mount(host);
|
|
607
|
+
/**
|
|
608
|
+
* Fetch loader data and mount the active island. SPA mode also fetches
|
|
609
|
+
* from the loader endpoint — otherwise navigation after the initial SSR
|
|
610
|
+
* render would have no access to loader data.
|
|
611
|
+
*/
|
|
612
|
+
async function mountActiveIsland(island, signal) {
|
|
613
|
+
unmountIsland?.();
|
|
614
|
+
unmountIsland = null;
|
|
615
|
+
currentMountedIsland = island;
|
|
616
|
+
if (!island) return;
|
|
617
|
+
const viewHost = host?.querySelector("[data-router-view]");
|
|
618
|
+
if (!viewHost) return;
|
|
619
|
+
const loc = getAdapter().readLocation();
|
|
620
|
+
const result = !!findRoute(_rou3, "GET", loc.pathname)?.data?.loader ? await fetchLoaderData(loc.pathname + loc.search, signal) : {
|
|
621
|
+
kind: "data",
|
|
622
|
+
data: {}
|
|
623
|
+
};
|
|
624
|
+
if (signal.aborted) return;
|
|
625
|
+
if (result.kind === "redirect") {
|
|
626
|
+
navigate(result.to, { replace: true });
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
const props = result.kind === "data" ? result.data : {};
|
|
630
|
+
viewHost.innerHTML = island.toString(props);
|
|
631
|
+
unmountIsland = island.mount(viewHost, props);
|
|
632
|
+
}
|
|
633
|
+
navAbort = new AbortController();
|
|
634
|
+
mountActiveIsland(activeIsland(), navAbort.signal);
|
|
635
|
+
const NavHandler = ilha.render(() => {
|
|
636
|
+
const current = activeIsland();
|
|
637
|
+
if (current !== currentMountedIsland) {
|
|
638
|
+
const thisNav = ++navVersion;
|
|
639
|
+
navAbort?.abort();
|
|
640
|
+
navAbort = new AbortController();
|
|
641
|
+
const signal = navAbort.signal;
|
|
642
|
+
queueMicrotask(() => {
|
|
643
|
+
if (thisNav !== navVersion) return;
|
|
644
|
+
mountActiveIsland(current, signal);
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
return "";
|
|
648
|
+
});
|
|
649
|
+
const navHost = document.createElement("div");
|
|
650
|
+
navHost.style.display = "none";
|
|
651
|
+
host.appendChild(navHost);
|
|
652
|
+
const unmountNavHandler = NavHandler.mount(navHost);
|
|
653
|
+
return () => {
|
|
654
|
+
++navVersion;
|
|
655
|
+
navAbort?.abort();
|
|
656
|
+
unmountIsland?.();
|
|
657
|
+
unmountNavHandler();
|
|
658
|
+
navHost.remove();
|
|
659
|
+
unmountView?.();
|
|
660
|
+
_navChangeCleanup?.();
|
|
661
|
+
_linkCleanup?.();
|
|
662
|
+
_navChangeCleanup = null;
|
|
663
|
+
_linkCleanup = null;
|
|
664
|
+
};
|
|
665
|
+
},
|
|
666
|
+
render(url) {
|
|
667
|
+
syncRouteFromURL(url);
|
|
668
|
+
return RouterView.toString();
|
|
669
|
+
},
|
|
670
|
+
async renderHydratable(url, registry, options = {}, request) {
|
|
671
|
+
const response = await this.renderResponse(url, registry, options, request);
|
|
672
|
+
if (response.kind === "html") return response.html;
|
|
673
|
+
if (response.kind === "error") return response.html;
|
|
674
|
+
return `<meta http-equiv="refresh" content="0; url=${response.to}">`;
|
|
675
|
+
},
|
|
676
|
+
async renderResponse(url, registry, options = {}, request) {
|
|
677
|
+
const parsed = parsedURL(url);
|
|
678
|
+
syncRouteFromURL(parsed);
|
|
679
|
+
const match = findRoute(_rou3, "GET", parsed.pathname);
|
|
680
|
+
const island = match?.data?.island ?? null;
|
|
681
|
+
if (!island) return {
|
|
682
|
+
kind: "html",
|
|
683
|
+
html: `<div data-router-empty></div>`,
|
|
684
|
+
status: 404
|
|
685
|
+
};
|
|
686
|
+
let props = {};
|
|
687
|
+
if (match?.data?.loader) {
|
|
688
|
+
const req = request ?? defaultRequest(parsed);
|
|
689
|
+
const ctrl = new AbortController();
|
|
690
|
+
const result = await executeLoader(match.data.loader, parsed, routeParams(), req, ctrl.signal);
|
|
691
|
+
if (result.kind === "redirect") return {
|
|
692
|
+
kind: "redirect",
|
|
693
|
+
to: result.to,
|
|
694
|
+
status: result.status
|
|
695
|
+
};
|
|
696
|
+
if (result.kind === "error") {
|
|
697
|
+
const escapedMessage = String(result.message).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
698
|
+
const html = `<div data-router-view data-router-error="${result.status}">${escapedMessage}</div>`;
|
|
699
|
+
return {
|
|
700
|
+
kind: "error",
|
|
701
|
+
status: result.status,
|
|
702
|
+
message: result.message,
|
|
703
|
+
html
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
props = result.data;
|
|
707
|
+
}
|
|
708
|
+
const name = buildReverseRegistry(registry).get(island);
|
|
709
|
+
if (!name) {
|
|
710
|
+
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.`);
|
|
711
|
+
return {
|
|
712
|
+
kind: "html",
|
|
713
|
+
html: `<div data-router-view>${island.toString(props)}</div>`
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
return {
|
|
717
|
+
kind: "html",
|
|
718
|
+
html: `<div data-router-view>${await island.hydratable(props, {
|
|
719
|
+
name,
|
|
720
|
+
as: "div",
|
|
721
|
+
snapshot: true,
|
|
722
|
+
...options
|
|
723
|
+
})}</div>`
|
|
724
|
+
};
|
|
725
|
+
},
|
|
726
|
+
async runLoader(url, request) {
|
|
727
|
+
const parsed = parsedURL(url);
|
|
728
|
+
const match = findRoute(_rou3, "GET", parsed.pathname);
|
|
729
|
+
if (!match?.data?.island) return { kind: "not-found" };
|
|
730
|
+
if (!match.data.loader) return {
|
|
731
|
+
kind: "data",
|
|
732
|
+
data: {}
|
|
733
|
+
};
|
|
734
|
+
const params = extractParams(match.params);
|
|
735
|
+
const req = request ?? defaultRequest(parsed);
|
|
736
|
+
const ctrl = new AbortController();
|
|
737
|
+
return executeLoader(match.data.loader, parsed, params, req, ctrl.signal);
|
|
738
|
+
},
|
|
739
|
+
hydrate(registry, options = {}) {
|
|
740
|
+
if (!isBrowser) {
|
|
741
|
+
console.warn("[ilha-router] hydrate() called in a non-browser environment");
|
|
742
|
+
return () => {};
|
|
743
|
+
}
|
|
744
|
+
const root = options.root ?? document.body;
|
|
745
|
+
const target = options.target ?? root;
|
|
746
|
+
prime();
|
|
747
|
+
const { unmount } = mount(registry, { root });
|
|
748
|
+
const unmountRouter = this.mount(target, {
|
|
749
|
+
hydrate: true,
|
|
750
|
+
registry
|
|
751
|
+
});
|
|
752
|
+
return () => {
|
|
753
|
+
unmount();
|
|
754
|
+
unmountRouter();
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
};
|
|
758
|
+
return builder;
|
|
759
|
+
}
|
|
760
|
+
var src_default = {
|
|
761
|
+
router,
|
|
762
|
+
navigate,
|
|
763
|
+
useRoute,
|
|
764
|
+
isActive,
|
|
765
|
+
enableLinkInterception,
|
|
766
|
+
prime,
|
|
767
|
+
prefetch,
|
|
768
|
+
RouterView,
|
|
769
|
+
RouterLink,
|
|
770
|
+
loader,
|
|
771
|
+
redirect,
|
|
772
|
+
error,
|
|
773
|
+
composeLoaders
|
|
774
|
+
};
|
|
775
|
+
//#endregion
|
|
776
|
+
export { wrapError as C, setHistoryMode as E, useRoute as S, getHistoryMode as T, routeParams as _, RouterView as a, router as b, enableLinkInterception as c, loader as d, navigate as f, routeHash as g, redirect as h, RouterLink as i, error as l, prime as m, LoaderError as n, composeLoaders as o, prefetch as p, Redirect as r, defineLayout as s, LOADER_ENDPOINT as t, isActive as u, routePath as v, wrapLayout as w, src_default as x, routeSearch as y };
|