@ilha/router 0.11.2 → 0.11.4

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