@ilha/router 0.10.3 → 0.11.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 +233 -1010
- package/dist/als-key-CVxbuM3z.js +5 -0
- package/dist/als-key.d.ts +1 -0
- package/dist/codegen.d.ts +0 -2
- package/dist/{snapshot-CsEaY6h_.js → head-DpYV3bHH.js} +5 -57
- package/dist/index.d.ts +58 -340
- package/dist/index.js +700 -3
- package/dist/{plugin-DBbbaeFm.js → plugin-C3AAI3e_.js} +67 -193
- package/dist/plugin.d.ts +0 -10
- package/dist/request-scope.d.ts +2 -1
- package/dist/rsbuild.js +1 -1
- package/dist/server-island.d.ts +0 -3
- package/dist/server-island.js +47 -10
- package/dist/server-islands.d.ts +0 -3
- package/dist/snapshot-C0E2OGwL.js +55 -0
- package/dist/snapshot.d.ts +1 -0
- package/dist/{ssr-CTd2quoA.js → ssr-WA3bSsCo.js} +146 -215
- package/dist/ssr.d.ts +29 -64
- package/dist/ssr.js +2 -2
- package/dist/vite.js +1 -1
- package/oxlint.cjs +63 -241
- package/package.json +5 -7
- package/dist/server.d.ts +0 -8
- package/dist/server.js +0 -14
- package/dist/src-B5dHU24f.js +0 -2161
package/dist/index.js
CHANGED
|
@@ -1,4 +1,701 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { t as REQUEST_ALS_KEY } from "./als-key-CVxbuM3z.js";
|
|
2
|
+
import { i as withHeadStore, n as head, r as serializeHead, t as applyHeadEntriesToDocument } from "./head-DpYV3bHH.js";
|
|
3
|
+
import { h, mount, renderToString } from "ilha";
|
|
3
4
|
|
|
4
|
-
|
|
5
|
+
//#region src/hash.ts
|
|
6
|
+
const isBrowser$1 = typeof window !== "undefined" && typeof document !== "undefined";
|
|
7
|
+
const historyAdapter = {
|
|
8
|
+
readLocation() {
|
|
9
|
+
if (!isBrowser$1) return {
|
|
10
|
+
pathname: "/",
|
|
11
|
+
search: "",
|
|
12
|
+
hash: ""
|
|
13
|
+
};
|
|
14
|
+
return {
|
|
15
|
+
pathname: location.pathname,
|
|
16
|
+
search: location.search,
|
|
17
|
+
hash: location.hash
|
|
18
|
+
};
|
|
19
|
+
},
|
|
20
|
+
push(to, state) {
|
|
21
|
+
if (!isBrowser$1) return;
|
|
22
|
+
history.pushState(state ?? null, "", to);
|
|
23
|
+
},
|
|
24
|
+
replace(to, state) {
|
|
25
|
+
if (!isBrowser$1) return;
|
|
26
|
+
history.replaceState(state ?? null, "", to);
|
|
27
|
+
},
|
|
28
|
+
onChange(handler) {
|
|
29
|
+
if (!isBrowser$1) return () => {};
|
|
30
|
+
window.addEventListener("popstate", handler);
|
|
31
|
+
return () => window.removeEventListener("popstate", handler);
|
|
32
|
+
},
|
|
33
|
+
toLinkHref(p) {
|
|
34
|
+
return p;
|
|
35
|
+
},
|
|
36
|
+
extractLogicalPath(anchor) {
|
|
37
|
+
const href = anchor.getAttribute("href");
|
|
38
|
+
if (!href) return null;
|
|
39
|
+
if (anchor.protocol && !/^(http:|https:)$/.test(anchor.protocol)) return null;
|
|
40
|
+
if (href.startsWith("#")) return null;
|
|
41
|
+
if (!!anchor.hostname && (anchor.hostname !== location.hostname || anchor.protocol !== location.protocol)) return null;
|
|
42
|
+
return anchor.pathname + anchor.search + anchor.hash;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
function parseHash(rawHash) {
|
|
46
|
+
const stripped = rawHash.startsWith("#") ? rawHash.slice(1) : rawHash;
|
|
47
|
+
const path = stripped === "" ? "/" : stripped.startsWith("/") ? stripped : "/" + stripped;
|
|
48
|
+
const u = new URL(path, "http://_");
|
|
49
|
+
return {
|
|
50
|
+
pathname: u.pathname,
|
|
51
|
+
search: u.search,
|
|
52
|
+
hash: u.hash
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
const hashAdapter = {
|
|
56
|
+
readLocation() {
|
|
57
|
+
if (!isBrowser$1) return {
|
|
58
|
+
pathname: "/",
|
|
59
|
+
search: "",
|
|
60
|
+
hash: ""
|
|
61
|
+
};
|
|
62
|
+
return parseHash(location.hash);
|
|
63
|
+
},
|
|
64
|
+
push(to, state) {
|
|
65
|
+
if (!isBrowser$1) return;
|
|
66
|
+
history.pushState(state ?? null, "", to.startsWith("#") ? to : "#" + to);
|
|
67
|
+
},
|
|
68
|
+
replace(to, state) {
|
|
69
|
+
if (!isBrowser$1) return;
|
|
70
|
+
history.replaceState(state ?? null, "", to.startsWith("#") ? to : "#" + to);
|
|
71
|
+
},
|
|
72
|
+
onChange(handler) {
|
|
73
|
+
if (!isBrowser$1) return () => {};
|
|
74
|
+
window.addEventListener("popstate", handler);
|
|
75
|
+
window.addEventListener("hashchange", handler);
|
|
76
|
+
return () => {
|
|
77
|
+
window.removeEventListener("popstate", handler);
|
|
78
|
+
window.removeEventListener("hashchange", handler);
|
|
79
|
+
};
|
|
80
|
+
},
|
|
81
|
+
toLinkHref(p) {
|
|
82
|
+
if (p.startsWith("#")) return p;
|
|
83
|
+
return "#" + p;
|
|
84
|
+
},
|
|
85
|
+
extractLogicalPath(anchor) {
|
|
86
|
+
const href = anchor.getAttribute("href");
|
|
87
|
+
if (!href) return null;
|
|
88
|
+
if (href.startsWith("//")) return null;
|
|
89
|
+
if (anchor.protocol && !/^(http:|https:)$/.test(anchor.protocol)) return null;
|
|
90
|
+
if (href.startsWith("#")) {
|
|
91
|
+
const inner = href.slice(1);
|
|
92
|
+
if (inner === "" || !inner.startsWith("/")) return null;
|
|
93
|
+
return inner;
|
|
94
|
+
}
|
|
95
|
+
if (/^https?:\/\//i.test(href)) try {
|
|
96
|
+
const u = new URL(href);
|
|
97
|
+
if (u.origin !== location.origin) return null;
|
|
98
|
+
if (!u.hash || u.hash === "#") return null;
|
|
99
|
+
const inner = u.hash.slice(1);
|
|
100
|
+
if (!inner.startsWith("/")) return null;
|
|
101
|
+
return inner;
|
|
102
|
+
} catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
return href;
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
let _mode = "history";
|
|
109
|
+
let _adapter = historyAdapter;
|
|
110
|
+
/**
|
|
111
|
+
* Set the router's history mode. Call this once at app entry, before
|
|
112
|
+
* mounting any router. Defaults to "history" (HTML5 History API).
|
|
113
|
+
*
|
|
114
|
+
* Use "hash" when the document is loaded over file:// (Electron, Tauri, etc.)
|
|
115
|
+
* or any time there's no server able to serve a SPA fallback at arbitrary
|
|
116
|
+
* pathnames.
|
|
117
|
+
*
|
|
118
|
+
* Switching modes mid-session is supported but not common — listeners
|
|
119
|
+
* registered before the switch will keep using their original adapter
|
|
120
|
+
* until they're re-attached (typically by unmounting and remounting
|
|
121
|
+
* the router).
|
|
122
|
+
*/
|
|
123
|
+
function setHistoryMode(mode) {
|
|
124
|
+
_mode = mode;
|
|
125
|
+
_adapter = mode === "hash" ? hashAdapter : historyAdapter;
|
|
126
|
+
}
|
|
127
|
+
function getHistoryMode() {
|
|
128
|
+
return _mode;
|
|
129
|
+
}
|
|
130
|
+
/** Internal — used by index.ts. Not part of the public API. */
|
|
131
|
+
function getAdapter() {
|
|
132
|
+
return _adapter;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
//#endregion
|
|
136
|
+
//#region src/http.ts
|
|
137
|
+
/**
|
|
138
|
+
* Build an HTTP `Response` for SSR output with sensible security headers:
|
|
139
|
+
* `Content-Type: text/html`, `X-Content-Type-Options: nosniff`,
|
|
140
|
+
* `Referrer-Policy`, `Cache-Control: no-store`, and an optional CSP. This is
|
|
141
|
+
* a low-level helper — prefer {@link RouterBuilder.respond} for the full
|
|
142
|
+
* render+head+headers pipeline.
|
|
143
|
+
*/
|
|
144
|
+
function httpResponse(body, options = {}) {
|
|
145
|
+
const headers = new Headers(options.headers);
|
|
146
|
+
if (body != null && !headers.has("content-type")) headers.set("content-type", "text/html; charset=utf-8");
|
|
147
|
+
if (!headers.has("x-content-type-options")) headers.set("x-content-type-options", "nosniff");
|
|
148
|
+
if (!headers.has("referrer-policy")) headers.set("referrer-policy", "no-referrer");
|
|
149
|
+
if (!headers.has("cache-control")) headers.set("cache-control", "no-store");
|
|
150
|
+
const csp = options.contentSecurityPolicy ?? (options.cspNonce ? `default-src 'self'; script-src 'self' 'nonce-${options.cspNonce}'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; object-src 'none'; base-uri 'self'; frame-ancestors 'self'` : void 0);
|
|
151
|
+
if (csp) headers.set("content-security-policy", csp);
|
|
152
|
+
return new Response(body, {
|
|
153
|
+
status: options.status ?? 200,
|
|
154
|
+
headers
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
const EMPTY_HEAD = {
|
|
158
|
+
headTags: "",
|
|
159
|
+
htmlAttrs: "",
|
|
160
|
+
bodyAttrs: ""
|
|
161
|
+
};
|
|
162
|
+
/**
|
|
163
|
+
* Options for {@link RouterBuilder.respond}.
|
|
164
|
+
*/
|
|
165
|
+
|
|
166
|
+
//#endregion
|
|
167
|
+
//#region src/route-match.ts
|
|
168
|
+
function parsePattern(pattern) {
|
|
169
|
+
const segments = pattern.split("/").filter(Boolean);
|
|
170
|
+
return {
|
|
171
|
+
segments,
|
|
172
|
+
kinds: segments.map((segment) => segment.startsWith("*") ? 0 : segment.startsWith(":") ? 1 : 2)
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Match `segments` against a pathname. Returns raw (still-encoded) captured
|
|
177
|
+
* params, or `null` when the path doesn't match — the caller decides whether to
|
|
178
|
+
* decode via {@link safeDecode}.
|
|
179
|
+
*/
|
|
180
|
+
function matchSegments(segments, pathname) {
|
|
181
|
+
const pathSegments = pathname.split("/").filter(Boolean);
|
|
182
|
+
const params = {};
|
|
183
|
+
let cursor = 0;
|
|
184
|
+
for (let i = 0; i < segments.length; i++) {
|
|
185
|
+
const segment = segments[i];
|
|
186
|
+
const isLast = i === segments.length - 1;
|
|
187
|
+
if (segment.startsWith("**") && isLast) {
|
|
188
|
+
const name = segment.slice(2).replace(/^:/, "");
|
|
189
|
+
if (name) params[name] = pathSegments.slice(cursor).join("/");
|
|
190
|
+
cursor = pathSegments.length;
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
if (segment.startsWith("*")) {
|
|
194
|
+
if (pathSegments[cursor] === void 0) return null;
|
|
195
|
+
cursor++;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (segment.startsWith(":")) {
|
|
199
|
+
const value = pathSegments[cursor];
|
|
200
|
+
if (value === void 0) return null;
|
|
201
|
+
params[segment.slice(1)] = value;
|
|
202
|
+
cursor++;
|
|
203
|
+
} else if (pathSegments[cursor] === segment) cursor++;
|
|
204
|
+
else return null;
|
|
205
|
+
}
|
|
206
|
+
return cursor === pathSegments.length ? params : null;
|
|
207
|
+
}
|
|
208
|
+
/** Decode a route-param value, tolerating malformed percent-encoding. */
|
|
209
|
+
function safeDecode(value) {
|
|
210
|
+
try {
|
|
211
|
+
return decodeURIComponent(value);
|
|
212
|
+
} catch {
|
|
213
|
+
return value;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
//#endregion
|
|
218
|
+
//#region src/index.ts
|
|
219
|
+
const isBrowser = typeof window !== "undefined" && typeof document !== "undefined";
|
|
220
|
+
var Redirect = class {
|
|
221
|
+
__ilhaRedirect = true;
|
|
222
|
+
to;
|
|
223
|
+
status;
|
|
224
|
+
constructor(to, status = 302) {
|
|
225
|
+
this.to = to;
|
|
226
|
+
this.status = status >= 300 && status <= 399 ? status : 302;
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
var RouteError = class {
|
|
230
|
+
__ilhaRouteError = true;
|
|
231
|
+
status;
|
|
232
|
+
message;
|
|
233
|
+
constructor(status, message) {
|
|
234
|
+
this.message = message;
|
|
235
|
+
this.status = status >= 400 && status <= 599 ? status : 500;
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
function redirect(to, status = 302) {
|
|
239
|
+
throw new Redirect(to, status);
|
|
240
|
+
}
|
|
241
|
+
function error(status, message) {
|
|
242
|
+
throw new RouteError(status, message);
|
|
243
|
+
}
|
|
244
|
+
function wrapLayout(layout, page) {
|
|
245
|
+
return function Wrapped() {
|
|
246
|
+
return h(layout, null, h(page, null));
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
function wrapError(handler, page) {
|
|
250
|
+
return async function Wrapped() {
|
|
251
|
+
try {
|
|
252
|
+
const out = page();
|
|
253
|
+
return out instanceof Promise ? await out : out;
|
|
254
|
+
} catch (e) {
|
|
255
|
+
const view = handler(e instanceof RouteError ? {
|
|
256
|
+
message: e.message,
|
|
257
|
+
status: e.status
|
|
258
|
+
} : { message: e instanceof Error ? e.message : String(e) }, snapshot());
|
|
259
|
+
return typeof view === "function" ? view() : view;
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
function defineLayout(layout) {
|
|
264
|
+
return layout;
|
|
265
|
+
}
|
|
266
|
+
function createRouteRegistry() {
|
|
267
|
+
return [];
|
|
268
|
+
}
|
|
269
|
+
function addRouteEntry(registry, pattern, data) {
|
|
270
|
+
registry.push({
|
|
271
|
+
pattern,
|
|
272
|
+
parsed: parsePattern(pattern),
|
|
273
|
+
data
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
function matchRoute(registry, pathname) {
|
|
277
|
+
let best = null;
|
|
278
|
+
let bestScore = -1;
|
|
279
|
+
for (const entry of registry) {
|
|
280
|
+
if (matchSegments(entry.parsed.segments, pathname) == null) continue;
|
|
281
|
+
const score = entry.parsed.kinds.reduce((s, k) => s + k, 0);
|
|
282
|
+
if (score > bestScore) {
|
|
283
|
+
best = entry;
|
|
284
|
+
bestScore = score;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return best;
|
|
288
|
+
}
|
|
289
|
+
function extractParams(match, pathname) {
|
|
290
|
+
if (!match) return {};
|
|
291
|
+
const raw = matchSegments(match.parsed.segments, pathname) ?? {};
|
|
292
|
+
const out = {};
|
|
293
|
+
for (const [k, v] of Object.entries(raw)) out[k] = safeDecode(v);
|
|
294
|
+
return out;
|
|
295
|
+
}
|
|
296
|
+
let _routes = [];
|
|
297
|
+
let _path = "/";
|
|
298
|
+
let _params = {};
|
|
299
|
+
let _search = "";
|
|
300
|
+
let _hash = "";
|
|
301
|
+
const _navigating = false;
|
|
302
|
+
let _navKeyCounter = 0;
|
|
303
|
+
let _lastNavKey = 0;
|
|
304
|
+
const _scrollPositions = /* @__PURE__ */ new Map();
|
|
305
|
+
const _beforeNavigateHooks = [];
|
|
306
|
+
const _afterNavigateHooks = [];
|
|
307
|
+
function snapshot() {
|
|
308
|
+
return {
|
|
309
|
+
path: _path,
|
|
310
|
+
params: _params,
|
|
311
|
+
search: _search,
|
|
312
|
+
hash: _hash
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
function routePath() {
|
|
316
|
+
return _path;
|
|
317
|
+
}
|
|
318
|
+
function routeParams() {
|
|
319
|
+
return _params;
|
|
320
|
+
}
|
|
321
|
+
function routeSearch() {
|
|
322
|
+
return _search;
|
|
323
|
+
}
|
|
324
|
+
function routeHash() {
|
|
325
|
+
return _hash;
|
|
326
|
+
}
|
|
327
|
+
function navigating() {
|
|
328
|
+
return _navigating;
|
|
329
|
+
}
|
|
330
|
+
function useRoute() {
|
|
331
|
+
return {
|
|
332
|
+
path: routePath,
|
|
333
|
+
params: routeParams,
|
|
334
|
+
search: routeSearch,
|
|
335
|
+
hash: routeHash,
|
|
336
|
+
navigating
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
function useContext() {
|
|
340
|
+
return { request: globalThis[REQUEST_ALS_KEY]?.getStore?.() };
|
|
341
|
+
}
|
|
342
|
+
function syncFromParts(pathname, search, hash, routes = _routes) {
|
|
343
|
+
_path = pathname || "/";
|
|
344
|
+
_search = search;
|
|
345
|
+
_hash = hash;
|
|
346
|
+
_params = extractParams(matchRoute(routes, _path), _path);
|
|
347
|
+
}
|
|
348
|
+
function syncRouteFromLocation() {
|
|
349
|
+
const loc = getAdapter().readLocation();
|
|
350
|
+
syncFromParts(loc.pathname, loc.search, loc.hash);
|
|
351
|
+
}
|
|
352
|
+
function prime() {
|
|
353
|
+
if (isBrowser) syncRouteFromLocation();
|
|
354
|
+
}
|
|
355
|
+
function beforeNavigate(fn) {
|
|
356
|
+
_beforeNavigateHooks.push(fn);
|
|
357
|
+
return () => {
|
|
358
|
+
const i = _beforeNavigateHooks.indexOf(fn);
|
|
359
|
+
if (i >= 0) _beforeNavigateHooks.splice(i, 1);
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
function afterNavigate(fn) {
|
|
363
|
+
_afterNavigateHooks.push(fn);
|
|
364
|
+
return () => {
|
|
365
|
+
const i = _afterNavigateHooks.indexOf(fn);
|
|
366
|
+
if (i >= 0) _afterNavigateHooks.splice(i, 1);
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
function runAfterNavigateHooks(nav) {
|
|
370
|
+
for (const fn of _afterNavigateHooks) try {
|
|
371
|
+
fn(nav);
|
|
372
|
+
} catch (e) {
|
|
373
|
+
console.error("[ilha-router] afterNavigate hook threw:", e);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
function currentNavKey() {
|
|
377
|
+
const state = isBrowser ? history.state : null;
|
|
378
|
+
const key = state && typeof state === "object" ? state.__ilhaNavKey : void 0;
|
|
379
|
+
return typeof key === "number" ? key : 0;
|
|
380
|
+
}
|
|
381
|
+
function saveScrollPosition() {
|
|
382
|
+
if (!isBrowser) return;
|
|
383
|
+
_scrollPositions.set(_lastNavKey, {
|
|
384
|
+
x: window.scrollX,
|
|
385
|
+
y: window.scrollY
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
function scrollAfterNavigate(hash) {
|
|
389
|
+
if (!isBrowser) return;
|
|
390
|
+
if (hash && hash.length > 1) {
|
|
391
|
+
const id = hash.slice(1);
|
|
392
|
+
const el = document.getElementById(id);
|
|
393
|
+
if (el) {
|
|
394
|
+
el.scrollIntoView();
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
window.scrollTo(0, 0);
|
|
399
|
+
}
|
|
400
|
+
function restoreScrollPosition() {
|
|
401
|
+
const pos = _scrollPositions.get(currentNavKey());
|
|
402
|
+
if (!pos) return;
|
|
403
|
+
requestAnimationFrame(() => window.scrollTo(pos.x, pos.y));
|
|
404
|
+
}
|
|
405
|
+
function navigate(to, opts = {}) {
|
|
406
|
+
if (!isBrowser) return;
|
|
407
|
+
const adapter = getAdapter();
|
|
408
|
+
const cur = adapter.readLocation();
|
|
409
|
+
const current = cur.pathname + cur.search + cur.hash;
|
|
410
|
+
if (to === current) return;
|
|
411
|
+
const type = opts.replace ? "replace" : "push";
|
|
412
|
+
let cancelled = false;
|
|
413
|
+
for (const fn of _beforeNavigateHooks) try {
|
|
414
|
+
fn({
|
|
415
|
+
from: current,
|
|
416
|
+
to,
|
|
417
|
+
type,
|
|
418
|
+
cancel: () => cancelled = true
|
|
419
|
+
});
|
|
420
|
+
} catch (e) {
|
|
421
|
+
console.error("[ilha-router] beforeNavigate hook threw:", e);
|
|
422
|
+
}
|
|
423
|
+
if (cancelled) return;
|
|
424
|
+
if (opts.replace) adapter.replace(to, { __ilhaNavKey: currentNavKey() });
|
|
425
|
+
else {
|
|
426
|
+
saveScrollPosition();
|
|
427
|
+
_navKeyCounter = Math.max(_navKeyCounter + 1, currentNavKey() + 1);
|
|
428
|
+
adapter.push(to, { __ilhaNavKey: _navKeyCounter });
|
|
429
|
+
}
|
|
430
|
+
_lastNavKey = currentNavKey();
|
|
431
|
+
const prevPath = cur.pathname;
|
|
432
|
+
syncRouteFromLocation();
|
|
433
|
+
if (opts.scroll !== false) {
|
|
434
|
+
const dest = adapter.readLocation();
|
|
435
|
+
const fromMatch = matchRoute(_routes, prevPath);
|
|
436
|
+
const destMatch = matchRoute(_routes, dest.pathname);
|
|
437
|
+
if (!(fromMatch?.data.page != null && destMatch?.data.page === fromMatch.data.page) || dest.hash && dest.hash !== "#") scrollAfterNavigate(dest.hash);
|
|
438
|
+
}
|
|
439
|
+
runAfterNavigateHooks({
|
|
440
|
+
from: current,
|
|
441
|
+
to,
|
|
442
|
+
type
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
function enableLinkInterception(root = document, _options = {}) {
|
|
446
|
+
if (!isBrowser) return () => {};
|
|
447
|
+
const clickHandler = (event) => {
|
|
448
|
+
const e = event;
|
|
449
|
+
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
|
450
|
+
const a = e.target?.closest?.("a");
|
|
451
|
+
if (!a || a.hasAttribute("download") || a.getAttribute("target") === "_blank" || a.hasAttribute("data-no-intercept")) return;
|
|
452
|
+
const href = a.getAttribute("href");
|
|
453
|
+
if (!href || /^(mailto|tel|javascript):/i.test(href)) return;
|
|
454
|
+
if (href.startsWith("#") && getHistoryMode() !== "hash") return;
|
|
455
|
+
if (href.startsWith("#") && !href.startsWith("#/")) return;
|
|
456
|
+
let url;
|
|
457
|
+
try {
|
|
458
|
+
url = new URL(href, location.href);
|
|
459
|
+
} catch {
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
if (url.origin !== location.origin) return;
|
|
463
|
+
e.preventDefault();
|
|
464
|
+
navigate(getHistoryMode() === "hash" ? url.hash.startsWith("#/") ? url.hash.slice(1) : url.pathname + url.search : url.pathname + url.search + url.hash);
|
|
465
|
+
};
|
|
466
|
+
root.addEventListener("click", clickHandler);
|
|
467
|
+
return () => root.removeEventListener("click", clickHandler);
|
|
468
|
+
}
|
|
469
|
+
function isActive(pattern, options = {}) {
|
|
470
|
+
if (options.end) return _path === pattern;
|
|
471
|
+
if (pattern === "/") return _path === "/";
|
|
472
|
+
return _path === pattern || _path.startsWith(pattern.endsWith("/") ? pattern : pattern + "/");
|
|
473
|
+
}
|
|
474
|
+
function resolveRedirectTarget(to, base, allowExternal) {
|
|
475
|
+
if (/[\\\u0000-\u0020]/.test(to)) return { ok: false };
|
|
476
|
+
try {
|
|
477
|
+
const u = new URL(to, base);
|
|
478
|
+
if (!/^https?:$/.test(u.protocol)) return { ok: false };
|
|
479
|
+
if (u.origin === base.origin) return {
|
|
480
|
+
ok: true,
|
|
481
|
+
to: u.pathname + u.search + u.hash
|
|
482
|
+
};
|
|
483
|
+
return allowExternal ? {
|
|
484
|
+
ok: true,
|
|
485
|
+
to: u.href
|
|
486
|
+
} : { ok: false };
|
|
487
|
+
} catch {
|
|
488
|
+
return { ok: false };
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
function resolveRequestUrl(urlOrRequest) {
|
|
492
|
+
try {
|
|
493
|
+
if (urlOrRequest instanceof Request) return new URL(urlOrRequest.url);
|
|
494
|
+
return typeof urlOrRequest === "string" ? new URL(urlOrRequest, "http://localhost") : urlOrRequest;
|
|
495
|
+
} catch {
|
|
496
|
+
return new URL("http://localhost/");
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
function pageForPath(pathname, routes, notFound) {
|
|
500
|
+
const match = matchRoute(routes, pathname);
|
|
501
|
+
if (match) return match.data.page;
|
|
502
|
+
return notFound;
|
|
503
|
+
}
|
|
504
|
+
async function renderPage(page, opts) {
|
|
505
|
+
return renderToString(page, opts);
|
|
506
|
+
}
|
|
507
|
+
function router(options = {}) {
|
|
508
|
+
const mode = options.mode ?? "spa";
|
|
509
|
+
const defaultInterceptLinks = options.interceptLinks !== false;
|
|
510
|
+
const allowExternalRedirects = options.allowExternalRedirects === true;
|
|
511
|
+
const records = [];
|
|
512
|
+
const routes = createRouteRegistry();
|
|
513
|
+
const patternToData = /* @__PURE__ */ new Map();
|
|
514
|
+
const notFound = options.notFound ?? null;
|
|
515
|
+
_routes = routes;
|
|
516
|
+
options.viewTransitions;
|
|
517
|
+
let navChangeCleanup = null;
|
|
518
|
+
let linkCleanup = null;
|
|
519
|
+
const builder = {
|
|
520
|
+
route(pattern, page) {
|
|
521
|
+
const data = {
|
|
522
|
+
page,
|
|
523
|
+
pattern
|
|
524
|
+
};
|
|
525
|
+
records.push({
|
|
526
|
+
pattern,
|
|
527
|
+
page
|
|
528
|
+
});
|
|
529
|
+
addRouteEntry(routes, pattern, data);
|
|
530
|
+
patternToData.set(pattern, data);
|
|
531
|
+
return builder;
|
|
532
|
+
},
|
|
533
|
+
errorBoundary(pattern, handler) {
|
|
534
|
+
const data = patternToData.get(pattern);
|
|
535
|
+
if (!data) {
|
|
536
|
+
console.warn(`[ilha-router] errorBoundary("${pattern}"): pattern was never registered via .route().`);
|
|
537
|
+
return builder;
|
|
538
|
+
}
|
|
539
|
+
data.errorHandler = handler;
|
|
540
|
+
return builder;
|
|
541
|
+
},
|
|
542
|
+
routes() {
|
|
543
|
+
return records.map((r) => ({ ...r }));
|
|
544
|
+
},
|
|
545
|
+
prime,
|
|
546
|
+
mount(target, { hydrate = false, interceptLinks: mountInterceptLinks } = {}) {
|
|
547
|
+
if (!isBrowser) {
|
|
548
|
+
console.warn("[ilha-router] mount() called in a non-browser environment");
|
|
549
|
+
return () => {};
|
|
550
|
+
}
|
|
551
|
+
const host = typeof target === "string" ? document.querySelector(target) : target;
|
|
552
|
+
if (!host) {
|
|
553
|
+
console.warn(`[ilha-router] No element found for selector "${target}"`);
|
|
554
|
+
return () => {};
|
|
555
|
+
}
|
|
556
|
+
if (mode === "static") {
|
|
557
|
+
console.warn("[ilha-router] router.mount() is a no-op in static mode.");
|
|
558
|
+
return () => {};
|
|
559
|
+
}
|
|
560
|
+
syncRouteFromLocation();
|
|
561
|
+
_lastNavKey = currentNavKey();
|
|
562
|
+
if (hydrate && getHistoryMode() === "hash") console.warn("[ilha-router] mount({ hydrate: true }) was called in hash mode. The server never sees the hash, so SSR HTML is for '/'.");
|
|
563
|
+
let mounted = true;
|
|
564
|
+
const prevScroll = "scrollRestoration" in history ? history.scrollRestoration : null;
|
|
565
|
+
if (prevScroll !== null) history.scrollRestoration = "manual";
|
|
566
|
+
const popHandler = () => {
|
|
567
|
+
if (!mounted) return;
|
|
568
|
+
const prevPath = _path + _search + _hash;
|
|
569
|
+
_scrollPositions.set(_lastNavKey, {
|
|
570
|
+
x: window.scrollX,
|
|
571
|
+
y: window.scrollY
|
|
572
|
+
});
|
|
573
|
+
_lastNavKey = currentNavKey();
|
|
574
|
+
syncRouteFromLocation();
|
|
575
|
+
restoreScrollPosition();
|
|
576
|
+
runAfterNavigateHooks({
|
|
577
|
+
from: prevPath,
|
|
578
|
+
to: _path + _search + _hash,
|
|
579
|
+
type: "pop"
|
|
580
|
+
});
|
|
581
|
+
remount();
|
|
582
|
+
};
|
|
583
|
+
navChangeCleanup = getAdapter().onChange(popHandler);
|
|
584
|
+
linkCleanup = mountInterceptLinks ?? defaultInterceptLinks ? enableLinkInterception(document) : null;
|
|
585
|
+
let unmountView = null;
|
|
586
|
+
const remount = () => {
|
|
587
|
+
unmountView?.();
|
|
588
|
+
const page = pageForPath(_path, routes, notFound);
|
|
589
|
+
if (!page) {
|
|
590
|
+
unmountView = null;
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
unmountView = mount(host, page, { hydrate: unmountView == null && hydrate });
|
|
594
|
+
};
|
|
595
|
+
remount();
|
|
596
|
+
const offNav = afterNavigate(() => {
|
|
597
|
+
if (mounted) remount();
|
|
598
|
+
});
|
|
599
|
+
return () => {
|
|
600
|
+
mounted = false;
|
|
601
|
+
offNav();
|
|
602
|
+
unmountView?.();
|
|
603
|
+
navChangeCleanup?.();
|
|
604
|
+
linkCleanup?.();
|
|
605
|
+
if (prevScroll !== null) history.scrollRestoration = prevScroll;
|
|
606
|
+
};
|
|
607
|
+
},
|
|
608
|
+
async render(url, options) {
|
|
609
|
+
const result = await builder.renderResponse(url, options);
|
|
610
|
+
if (result.kind === "html") return result.html;
|
|
611
|
+
if (result.kind === "redirect") return "";
|
|
612
|
+
return result.html;
|
|
613
|
+
},
|
|
614
|
+
async renderResponse(url, options) {
|
|
615
|
+
const parsed = resolveRequestUrl(url);
|
|
616
|
+
syncFromParts(parsed.pathname, parsed.search, parsed.hash, routes);
|
|
617
|
+
const store = { entries: [] };
|
|
618
|
+
try {
|
|
619
|
+
const page = pageForPath(parsed.pathname, routes, notFound);
|
|
620
|
+
if (!page) throw new RouteError(404, "Not found");
|
|
621
|
+
let captured;
|
|
622
|
+
const wrapped = async () => {
|
|
623
|
+
try {
|
|
624
|
+
return await Promise.resolve(page());
|
|
625
|
+
} catch (err) {
|
|
626
|
+
captured = err;
|
|
627
|
+
throw err;
|
|
628
|
+
}
|
|
629
|
+
};
|
|
630
|
+
const html = await withHeadStore(store, () => renderPage(wrapped, options));
|
|
631
|
+
if (captured instanceof Redirect || captured instanceof RouteError) throw captured;
|
|
632
|
+
return {
|
|
633
|
+
kind: "html",
|
|
634
|
+
html,
|
|
635
|
+
status: 200,
|
|
636
|
+
head: serializeHead(store.entries)
|
|
637
|
+
};
|
|
638
|
+
} catch (e) {
|
|
639
|
+
if (e instanceof Redirect) {
|
|
640
|
+
const safe = resolveRedirectTarget(e.to, parsed, allowExternalRedirects);
|
|
641
|
+
if (!safe.ok) return {
|
|
642
|
+
kind: "error",
|
|
643
|
+
status: 500,
|
|
644
|
+
message: "unsafe redirect target",
|
|
645
|
+
html: "",
|
|
646
|
+
head: EMPTY_HEAD
|
|
647
|
+
};
|
|
648
|
+
return {
|
|
649
|
+
kind: "redirect",
|
|
650
|
+
to: safe.to,
|
|
651
|
+
status: e.status
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
if (e instanceof RouteError) {
|
|
655
|
+
const boundary = matchRoute(routes, parsed.pathname)?.data.errorHandler;
|
|
656
|
+
let html = "";
|
|
657
|
+
if (boundary) {
|
|
658
|
+
const view = boundary({
|
|
659
|
+
message: e.message,
|
|
660
|
+
status: e.status
|
|
661
|
+
}, snapshot());
|
|
662
|
+
html = await renderPage(typeof view === "function" ? view : async () => view, options);
|
|
663
|
+
}
|
|
664
|
+
return {
|
|
665
|
+
kind: "error",
|
|
666
|
+
status: e.status,
|
|
667
|
+
message: e.message,
|
|
668
|
+
html,
|
|
669
|
+
head: serializeHead(store.entries)
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
throw e;
|
|
673
|
+
}
|
|
674
|
+
},
|
|
675
|
+
async respond(url, options = {}) {
|
|
676
|
+
const result = await builder.renderResponse(url, options);
|
|
677
|
+
if (result.kind === "redirect") return Response.redirect(result.to, result.status);
|
|
678
|
+
const head = result.head ?? EMPTY_HEAD;
|
|
679
|
+
if (isBrowser && result.kind === "html") applyHeadEntriesToDocument([]);
|
|
680
|
+
const body = options.shell ? options.shell(head, result.kind === "html" ? result.html : result.html) : result.kind === "html" ? result.html : result.html;
|
|
681
|
+
return httpResponse(body, {
|
|
682
|
+
status: result.kind === "error" ? result.status : result.status ?? 200,
|
|
683
|
+
headers: options.headers,
|
|
684
|
+
cspNonce: options.cspNonce,
|
|
685
|
+
contentSecurityPolicy: options.contentSecurityPolicy
|
|
686
|
+
});
|
|
687
|
+
},
|
|
688
|
+
hydrate(opts = {}) {
|
|
689
|
+
const root = opts.root ?? (isBrowser ? document.body : null);
|
|
690
|
+
if (!root) return () => {};
|
|
691
|
+
return builder.mount(root, {
|
|
692
|
+
hydrate: true,
|
|
693
|
+
interceptLinks: opts.interceptLinks
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
};
|
|
697
|
+
return builder;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
//#endregion
|
|
701
|
+
export { EMPTY_HEAD, Redirect, RouteError, afterNavigate, beforeNavigate, defineLayout, enableLinkInterception, error, getHistoryMode, head, httpResponse, isActive, navigate, navigating, prime, redirect, resolveRedirectTarget, routeHash, routeParams, routePath, routeSearch, router, serializeHead, setHistoryMode, useContext, useRoute, wrapError, wrapLayout };
|