@ilha/router 0.9.2 → 0.10.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.
@@ -1,4 +1,6 @@
1
- import { ISLAND_MOUNT_HANDLES, ISLAND_MOUNT_INTERNAL, context, html, ilha, mount } from "ilha";
1
+ import { a as escapeHtml, c as withHeadStore, i as escapeHeadAttr, n as applyHeadEntriesToDocument, o as head, r as cssEscapeAttr, s as serializeHead, t as parseSnapshotAttr } from "./snapshot-CsEaY6h_.js";
2
+ import { context, effect, html, ilha, mount, state } from "ilha";
3
+ import { ISLAND_MOUNT_HANDLES, ISLAND_MOUNT_INTERNAL } from "ilha/internal";
2
4
 
3
5
  //#region src/hash.ts
4
6
  const isBrowser$1 = typeof window !== "undefined" && typeof document !== "undefined";
@@ -83,6 +85,7 @@ const hashAdapter = {
83
85
  extractLogicalPath(anchor) {
84
86
  const href = anchor.getAttribute("href");
85
87
  if (!href) return null;
88
+ if (href.startsWith("//")) return null;
86
89
  if (anchor.protocol && !/^(http:|https:)$/.test(anchor.protocol)) return null;
87
90
  if (href.startsWith("#")) {
88
91
  const inner = href.slice(1);
@@ -129,6 +132,37 @@ function getAdapter() {
129
132
  return _adapter;
130
133
  }
131
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
+
132
166
  //#endregion
133
167
  //#region src/route-match.ts
134
168
  function parsePattern(pattern) {
@@ -247,7 +281,12 @@ function composeLoaders(loaders) {
247
281
  if (loaders.length === 1) return loaders[0];
248
282
  return async (ctx) => {
249
283
  const results = await Promise.all(loaders.map((l) => l(ctx)));
250
- return Object.assign({}, ...results);
284
+ let merged = {};
285
+ for (const r of results) merged = {
286
+ ...merged,
287
+ ...r
288
+ };
289
+ return merged;
251
290
  };
252
291
  }
253
292
  const WRAP_LAYOUT_LEAF = Symbol.for("ilha.router.wrapLayout.leaf");
@@ -375,13 +414,21 @@ function injectKPageSlot(layoutHtml, slotInnerHtml, which) {
375
414
  const target = which === "innermost" ? spans[spans.length - 1] : spans[0];
376
415
  return layoutHtml.slice(0, target.openEnd) + slotInnerHtml + layoutHtml.slice(target.closeStart);
377
416
  }
417
+ /** Branded read of ilha's optional mount-handle hook (absent on plain islands). */
418
+ function readMountInternal(island) {
419
+ return island[ISLAND_MOUNT_INTERNAL];
420
+ }
421
+ /** Branded write of ilha's mount-handle hook — mirrors the core island contract. */
422
+ function writeMountInternal(island, hook) {
423
+ island[ISLAND_MOUNT_INTERNAL] = hook;
424
+ }
378
425
  function layoutHtmlWithEmptyKPage(wrappedLayout, props) {
379
426
  const handler = wrappedLayout[WRAP_LAYOUT_HANDLER];
380
427
  if (!handler) return wrappedLayout.toString(props);
381
428
  const rawKeyed = (wrappedLayout[WRAP_LAYOUT_LEAF] ?? wrappedLayout).key("page");
382
429
  const shellChild = ((partial) => rawKeyed({
383
430
  ...props,
384
- ...partial ?? {}
431
+ ...partial
385
432
  }));
386
433
  Object.assign(shellChild, { toString: () => "" });
387
434
  shellChild[ISLAND_CALL] = true;
@@ -400,7 +447,7 @@ function wrapLayout(layout, page) {
400
447
  const merged = layoutInputRef.merged;
401
448
  const slotProps = merged && typeof merged === "object" ? {
402
449
  ...merged,
403
- ...props ?? {}
450
+ ...props
404
451
  } : props;
405
452
  return rawKeyedPage(slotProps);
406
453
  });
@@ -427,33 +474,41 @@ function wrapLayout(layout, page) {
427
474
  };
428
475
  if (mountHost.hasAttribute("data-ilha-state")) {
429
476
  const outerState = outer.getAttribute("data-ilha-state");
430
- if (outerState) try {
431
- applyOuterSnapshot(JSON.parse(outerState));
432
- return;
433
- } catch {}
477
+ if (outerState) {
478
+ const outerParsed = parseSnapshotAttr(outerState);
479
+ if (outerParsed) {
480
+ applyOuterSnapshot(outerParsed);
481
+ return;
482
+ }
483
+ }
434
484
  const slotState = mountHost.getAttribute("data-ilha-state");
435
- if (slotState) try {
436
- const snapshot = JSON.parse(slotState);
437
- delete snapshot._skipOnMount;
438
- mountHost.setAttribute("data-ilha-state", JSON.stringify(snapshot));
439
- } catch {}
485
+ if (slotState) {
486
+ const snapshot = parseSnapshotAttr(slotState);
487
+ if (snapshot) {
488
+ delete snapshot._skipOnMount;
489
+ mountHost.setAttribute("data-ilha-state", JSON.stringify(snapshot));
490
+ }
491
+ }
440
492
  return;
441
493
  }
442
494
  const outerState = outer.getAttribute("data-ilha-state");
443
- if (outerState) try {
444
- applyOuterSnapshot(JSON.parse(outerState));
445
- return;
446
- } catch {}
447
- if (mountHost.childNodes.length > 0) mountHost.setAttribute("data-ilha-state", "{}");
495
+ if (outerState) {
496
+ const outerParsed = parseSnapshotAttr(outerState);
497
+ if (outerParsed) {
498
+ applyOuterSnapshot(outerParsed);
499
+ return;
500
+ }
501
+ }
502
+ if (mountHost.childNodes.length > 0) mountHost.setAttribute("data-ilha-state", "{\"v\":2}");
448
503
  }
449
504
  function wrapLeafPageMountHooks(leaf) {
450
- const leafInternal = leaf[ISLAND_MOUNT_INTERNAL];
505
+ const leafInternal = readMountInternal(leaf);
451
506
  if (typeof leafInternal !== "function") return;
452
- leaf[ISLAND_MOUNT_INTERNAL] = (host, props) => {
507
+ writeMountInternal(leaf, (host, props) => {
453
508
  const outer = host.closest("[data-ilha]");
454
509
  if (outer && outer !== host) preparePageMountHost(outer, host);
455
510
  return leafInternal(host, props);
456
- };
511
+ });
457
512
  const leafMount = leaf.mount.bind(leaf);
458
513
  leaf.mount = (host, props) => {
459
514
  const outer = host.closest("[data-ilha]");
@@ -463,8 +518,8 @@ function wrapLayout(layout, page) {
463
518
  }
464
519
  wrapLeafPageMountHooks(leafPage);
465
520
  const pageHandles = /* @__PURE__ */ new Map();
466
- const pageInternalBase = page[ISLAND_MOUNT_INTERNAL];
467
- if (typeof pageInternalBase === "function") page[ISLAND_MOUNT_INTERNAL] = (host, props) => {
521
+ const pageInternalBase = readMountInternal(page);
522
+ if (typeof pageInternalBase === "function") writeMountInternal(page, (host, props) => {
468
523
  const handle = pageInternalBase(host, props);
469
524
  const entry = {
470
525
  handle,
@@ -481,7 +536,7 @@ function wrapLayout(layout, page) {
481
536
  handle.updateProps(p);
482
537
  }
483
538
  };
484
- };
539
+ });
485
540
  /**
486
541
  * In-place prop update for one mounted layout instance: refresh the
487
542
  * merged-input ref (so any layout re-render passes fresh props to `k:page`),
@@ -494,15 +549,27 @@ function wrapLayout(layout, page) {
494
549
  const layoutUpdateProps = (layoutHost, coreUpdate) => (p) => {
495
550
  setLayoutMergedInput(p);
496
551
  for (const [pageHost, entry] of pageHandles) if (layoutHost.contains(pageHost)) entry.handle.updateProps({
497
- ...entry.mountProps ?? {},
498
- ...p ?? {}
552
+ ...entry.mountProps,
553
+ ...p
499
554
  });
500
555
  coreUpdate?.(p);
501
556
  };
502
557
  const layoutMount = Wrapped.mount.bind(Wrapped);
503
- const layoutInternal = Wrapped[ISLAND_MOUNT_INTERNAL];
558
+ const layoutInternal = readMountInternal(Wrapped);
504
559
  function prepareLayoutMountHost(host) {
505
560
  preparePageMountHost(host, pageMountHost(host));
561
+ const raw = host.getAttribute("data-ilha-state");
562
+ if (raw) {
563
+ const parsed = parseSnapshotAttr(raw);
564
+ if (parsed) {
565
+ const s = parsed["s"];
566
+ const d = parsed["d"];
567
+ if (!(Array.isArray(s) && s.length > 0 || Array.isArray(d) && d.length > 0) && parsed["_skipOnMount"] === true) {
568
+ delete parsed["_skipOnMount"];
569
+ host.setAttribute("data-ilha-state", JSON.stringify(parsed));
570
+ }
571
+ }
572
+ }
506
573
  }
507
574
  Wrapped.mount = (host, props) => {
508
575
  setLayoutMergedInput(props);
@@ -515,7 +582,7 @@ function wrapLayout(layout, page) {
515
582
  });
516
583
  return unmount;
517
584
  };
518
- Wrapped[ISLAND_MOUNT_INTERNAL] = (host, props) => {
585
+ writeMountInternal(Wrapped, (host, props) => {
519
586
  setLayoutMergedInput(props);
520
587
  prepareLayoutMountHost(host);
521
588
  const base = typeof layoutInternal === "function" ? layoutInternal(host, props) : {
@@ -528,7 +595,7 @@ function wrapLayout(layout, page) {
528
595
  };
529
596
  ISLAND_MOUNT_HANDLES.set(host, enhanced);
530
597
  return enhanced;
531
- };
598
+ });
532
599
  Wrapped.hydratable = async (props, opts) => {
533
600
  if (!opts?.name) throw new Error("wrapLayout: hydratable requires options.name");
534
601
  const resolvedProps = props ?? {};
@@ -544,7 +611,7 @@ function wrapLayout(layout, page) {
544
611
  return Wrapped;
545
612
  }
546
613
  function wrapError(handler, page) {
547
- const Wrapper = ilha.render(() => {
614
+ const Wrapper = ilha(() => {
548
615
  try {
549
616
  return page.toString();
550
617
  } catch (e) {
@@ -580,9 +647,9 @@ function wrapError(handler, page) {
580
647
  return errorIsland.mount(host, props);
581
648
  }
582
649
  };
583
- Wrapper[ISLAND_MOUNT_INTERNAL] = (host, props) => {
650
+ writeMountInternal(Wrapper, (host, props) => {
584
651
  try {
585
- const pageInternal = page[ISLAND_MOUNT_INTERNAL];
652
+ const pageInternal = readMountInternal(page);
586
653
  if (typeof pageInternal === "function") return pageInternal(host, props);
587
654
  return {
588
655
  unmount: page.mount(host, props),
@@ -606,7 +673,7 @@ function wrapError(handler, page) {
606
673
  updateProps: () => {}
607
674
  };
608
675
  }
609
- };
676
+ });
610
677
  Wrapper.hydratable = async (props, opts) => {
611
678
  if (!opts?.name) throw new Error("wrapError: hydratable requires options.name");
612
679
  return page.hydratable(props ?? {}, opts);
@@ -772,7 +839,7 @@ const noopRouteHandle = () => ({
772
839
  /** Mount an island keeping the full internal handle so later same-island
773
840
  * navigations can push new loader props instead of remounting. */
774
841
  function mountIslandWithHandle(island, host, props) {
775
- const internal = island[ISLAND_MOUNT_INTERNAL];
842
+ const internal = readMountInternal(island);
776
843
  if (typeof internal === "function") {
777
844
  const h = internal(host, props);
778
845
  return {
@@ -788,11 +855,12 @@ function mountIslandWithHandle(island, host, props) {
788
855
  /**
789
856
  * Same-island fast path: fetch fresh loader data and push it into the mounted
790
857
  * island via `updateProps` — ilha's fine-grained morph reconciles the DOM, so
791
- * focus, caret, selection, and scroll survive (the reason persistQuery-driven
792
- * filter inputs don't blur while typing). Returns "updated" when applied (or
793
- * redirected), "remount" when the caller must run the full teardown + mount
794
- * path (loader error / not-found need boundary DOM; the full path re-fetches,
795
- * accepted for these rare cases). Throws AbortError when superseded.
858
+ * focus, caret, selection, and scroll survive (the reason same-route param
859
+ * updates on filter inputs don't blur while typing). Returns "updated" when
860
+ * applied (or redirected), "remount" when the caller must run the full
861
+ * teardown + mount path (loader error / not-found need boundary DOM; the full
862
+ * path re-fetches, accepted for these rare cases). Throws AbortError when
863
+ * superseded.
796
864
  */
797
865
  async function updateRouteInPlace(handle, pathWithSearch, signal) {
798
866
  if (!handle.updateProps) return "remount";
@@ -852,7 +920,7 @@ async function mountRouteWithHydration(island, host, pathWithSearch, signal, reg
852
920
  }));
853
921
  const escaped = escapeHtml(loaderResult.message);
854
922
  await withViewSwap(() => {
855
- host.innerHTML = `<div data-router-view data-router-error="${loaderResult.status}">${escaped}</div>`;
923
+ host.innerHTML = `<div data-router-view data-router-error="${escapeHtml(loaderResult.status)}">${escaped}</div>`;
856
924
  });
857
925
  return noopRouteHandle();
858
926
  }
@@ -907,12 +975,12 @@ function mountLoaderErrorBoundary(boundary, host, status, message) {
907
975
  search: routeSearch(),
908
976
  hash: routeHash()
909
977
  });
910
- host.innerHTML = `<div data-router-view data-router-error="${status}">${errorIsland.toString()}</div>`;
978
+ host.innerHTML = `<div data-router-view data-router-error="${escapeHtml(status)}">${errorIsland.toString()}</div>`;
911
979
  const ehHost = host.firstElementChild;
912
980
  return ehHost ? errorIsland.mount(ehHost) : () => {};
913
981
  } catch (e) {
914
982
  console.error("[ilha-router] error boundary threw while rendering a loader error:", e);
915
- host.innerHTML = `<div data-router-view data-router-error="${status}"></div>`;
983
+ host.innerHTML = `<div data-router-view data-router-error="${escapeHtml(status)}"></div>`;
916
984
  return () => {};
917
985
  }
918
986
  }
@@ -944,6 +1012,7 @@ const _routePathSig = context("router.path", "");
944
1012
  const _routeParamsSig = context("router.params", {});
945
1013
  const _routeSearchSig = context("router.search", "");
946
1014
  const _routeHashSig = context("router.hash", "");
1015
+ /** @internal Internal reactive route-state accessor; use the aggregate `useRoute()` for the public surface. */
947
1016
  function routePath(value) {
948
1017
  const store = activeRouteStore();
949
1018
  if (arguments.length > 0) {
@@ -952,6 +1021,7 @@ function routePath(value) {
952
1021
  }
953
1022
  return store ? store.path : _routePathSig();
954
1023
  }
1024
+ /** @internal Internal reactive route-state accessor; use the aggregate `useRoute()` for the public surface. */
955
1025
  function routeParams(value) {
956
1026
  const store = activeRouteStore();
957
1027
  if (arguments.length > 0) {
@@ -960,6 +1030,7 @@ function routeParams(value) {
960
1030
  }
961
1031
  return store ? store.params : _routeParamsSig();
962
1032
  }
1033
+ /** @internal Internal reactive route-state accessor; use the aggregate `useRoute()` for the public surface. */
963
1034
  function routeSearch(value) {
964
1035
  const store = activeRouteStore();
965
1036
  if (arguments.length > 0) {
@@ -968,6 +1039,7 @@ function routeSearch(value) {
968
1039
  }
969
1040
  return store ? store.search : _routeSearchSig();
970
1041
  }
1042
+ /** @internal Internal reactive route-state accessor; use the aggregate `useRoute()` for the public surface. */
971
1043
  function routeHash(value) {
972
1044
  const store = activeRouteStore();
973
1045
  if (arguments.length > 0) {
@@ -1276,7 +1348,7 @@ let _notFound = null;
1276
1348
  /** Redirect policy for browser-executed loaders — mirrors the last router's
1277
1349
  * `allowExternalRedirects` option (module-level for the same reason as `_notFound`). */
1278
1350
  let _allowExternalRedirects = false;
1279
- const RouterView = ilha.render(() => {
1351
+ const RouterView = ilha(() => {
1280
1352
  const island = activeIsland();
1281
1353
  if (!island) {
1282
1354
  if (_notFound) return `<div data-router-view data-router-not-found>${_notFound.toString()}</div>`;
@@ -1284,24 +1356,34 @@ const RouterView = ilha.render(() => {
1284
1356
  }
1285
1357
  return `<div data-router-view>${island.toString()}</div>`;
1286
1358
  });
1287
- const RouterLink = ilha.state("href", "").state("label", "").on("[data-link]@click", ({ state, event }) => {
1288
- event.preventDefault();
1289
- navigate(state.href());
1290
- }).on("[data-link]@mouseenter", ({ state }) => {
1291
- const href = state.href();
1292
- if (!href) return;
1293
- if (/^https?:\/\//i.test(href)) try {
1294
- const u = new URL(href);
1295
- if (u.origin !== location.origin) return;
1296
- prefetch(u.pathname + u.search);
1297
- return;
1298
- } catch {
1299
- return;
1300
- }
1301
- prefetch(href);
1302
- }).render(({ state }) => html`<a data-link data-prefetch href="${() => getAdapter().toLinkHref(state.href())}"
1303
- >${state.label}</a
1304
- >`);
1359
+ const RouterLink = ilha(({ href = "", label = "" }) => {
1360
+ const hrefState = state(href);
1361
+ const labelState = state(label);
1362
+ effect.once(({ host, signal }) => {
1363
+ const link = host.matches("[data-link]") ? host : host.querySelector("[data-link]");
1364
+ if (!link) return;
1365
+ link.addEventListener("click", (event) => {
1366
+ event.preventDefault();
1367
+ navigate(hrefState());
1368
+ }, { signal });
1369
+ link.addEventListener("mouseenter", () => {
1370
+ const href = hrefState();
1371
+ if (!href) return;
1372
+ if (/^https?:\/\//i.test(href)) try {
1373
+ const u = new URL(href);
1374
+ if (u.origin !== location.origin) return;
1375
+ prefetch(u.pathname + u.search);
1376
+ return;
1377
+ } catch {
1378
+ return;
1379
+ }
1380
+ prefetch(href);
1381
+ }, { signal });
1382
+ });
1383
+ return html`<a data-link data-prefetch href="${() => getAdapter().toLinkHref(hrefState())}"
1384
+ >${labelState()}</a
1385
+ >`;
1386
+ });
1305
1387
  function isActive(pattern, options = {}) {
1306
1388
  if (options.exact === false) {
1307
1389
  const path = routePath();
@@ -1312,291 +1394,6 @@ function isActive(pattern, options = {}) {
1312
1394
  if (!match) return false;
1313
1395
  return match.data.pattern === pattern;
1314
1396
  }
1315
- const ILHA_HEAD_ATTR = "data-ilha-head";
1316
- const ILHA_ROUTER_HTML_ATTR = "data-ilha-router-html";
1317
- const ILHA_ROUTER_BODY_ATTR = "data-ilha-router-body";
1318
- /** Browser-only fallback; SSR uses AsyncLocalStorage (see `withHeadStore`). */
1319
- let _browserHeadStore = null;
1320
- let _headAls = null;
1321
- let _headAlsInit = null;
1322
- /** ESM dynamic import — Nitro/Vite SSR workers have no `require`. */
1323
- async function getHeadAlsAsync() {
1324
- if (_headAls) return _headAls;
1325
- if (!_headAlsInit) _headAlsInit = import("node:async_hooks").then(({ AsyncLocalStorage }) => {
1326
- _headAls = new AsyncLocalStorage();
1327
- return _headAls;
1328
- });
1329
- return _headAlsInit;
1330
- }
1331
- function activeHeadStore() {
1332
- if (isBrowser) return _browserHeadStore;
1333
- return _headAls?.getStore() ?? null;
1334
- }
1335
- /**
1336
- * Contribute `<head>` data from inside an island's `.render()` body or a
1337
- * layout. During SSR this collects into the active render window; on the
1338
- * client, entries are collected when the router re-renders a route inside
1339
- * `withHeadStore` and then applied to `document`. Prefer a loader's `ctx.head`
1340
- * for data that depends on the request.
1341
- */
1342
- function head(input) {
1343
- const store = activeHeadStore();
1344
- if (!store) {
1345
- if (!isBrowser) console.warn("[ilha-router] head() called outside an SSR render window — ignored.");
1346
- return;
1347
- }
1348
- store.entries.push(input);
1349
- }
1350
- function cssEscapeAttr(value) {
1351
- if (typeof CSS !== "undefined" && typeof CSS.escape === "function") return CSS.escape(value);
1352
- return value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
1353
- }
1354
- function headManagedMetaSelector(tag) {
1355
- if ("charset" in tag) return `meta[charset][${ILHA_HEAD_ATTR}]`;
1356
- if ("name" in tag) return `meta[name="${cssEscapeAttr(tag.name)}"][${ILHA_HEAD_ATTR}]`;
1357
- if ("property" in tag) return `meta[property="${cssEscapeAttr(tag.property)}"][${ILHA_HEAD_ATTR}]`;
1358
- if ("http-equiv" in tag) return `meta[http-equiv="${cssEscapeAttr(tag["http-equiv"])}"][${ILHA_HEAD_ATTR}]`;
1359
- return null;
1360
- }
1361
- function headManagedLinkSelector(tag) {
1362
- if (tag.rel && tag.href) return `link[rel="${cssEscapeAttr(tag.rel)}"][href="${cssEscapeAttr(tag.href)}"][${ILHA_HEAD_ATTR}]`;
1363
- return null;
1364
- }
1365
- /**
1366
- * Apply merged head entries on client navigations. Updates `document.title` and
1367
- * managed meta/link nodes (`data-ilha-head`). Script tags from HeadInput are
1368
- * SSR-only and are not re-injected here. Removes managed tags from the previous
1369
- * route that are not part of this navigation's set.
1370
- */
1371
- function applyHeadEntriesToDocument(entries) {
1372
- if (!isBrowser) return;
1373
- let title;
1374
- let titleTemplate;
1375
- const meta = [];
1376
- const link = [];
1377
- let htmlAttrs = {};
1378
- let bodyAttrs = {};
1379
- for (const entry of entries) {
1380
- if (entry.title !== void 0) title = entry.title;
1381
- if (entry.titleTemplate !== void 0) titleTemplate = entry.titleTemplate;
1382
- if (entry.meta) meta.push(...entry.meta);
1383
- if (entry.link) link.push(...entry.link);
1384
- if (entry.htmlAttrs) htmlAttrs = {
1385
- ...htmlAttrs,
1386
- ...entry.htmlAttrs
1387
- };
1388
- if (entry.bodyAttrs) bodyAttrs = {
1389
- ...bodyAttrs,
1390
- ...entry.bodyAttrs
1391
- };
1392
- }
1393
- const resolvedTitle = applyTitleTemplate(title, titleTemplate);
1394
- if (resolvedTitle !== void 0) document.title = resolvedTitle;
1395
- const metaTags = dedupByKey(meta, metaDedupKey);
1396
- const linkTags = dedupByKey(link, (t) => `${t.rel ?? ""}:${t.href ?? ""}`);
1397
- const keepManaged = /* @__PURE__ */ new Set();
1398
- for (const tag of metaTags) {
1399
- const selector = headManagedMetaSelector(tag);
1400
- if (!selector) continue;
1401
- let el = document.querySelector(selector);
1402
- if (!el) {
1403
- el = document.createElement("meta");
1404
- el.setAttribute(ILHA_HEAD_ATTR, "");
1405
- document.head.appendChild(el);
1406
- }
1407
- for (const [k, v] of Object.entries(tag)) el.setAttribute(k, v);
1408
- keepManaged.add(el);
1409
- }
1410
- for (const tag of linkTags) {
1411
- const selector = headManagedLinkSelector(tag);
1412
- let el = selector ? document.querySelector(selector) : null;
1413
- if (!el) {
1414
- el = document.createElement("link");
1415
- el.setAttribute(ILHA_HEAD_ATTR, "");
1416
- document.head.appendChild(el);
1417
- }
1418
- for (const [k, v] of Object.entries(tag)) el.setAttribute(k, v);
1419
- keepManaged.add(el);
1420
- }
1421
- for (const el of [...document.head.querySelectorAll(`[${ILHA_HEAD_ATTR}]`)]) if (!keepManaged.has(el)) el.remove();
1422
- const htmlEl = document.documentElement;
1423
- const prevHtmlKeys = (htmlEl.getAttribute(ILHA_ROUTER_HTML_ATTR) ?? "").split(/\s+/).filter(Boolean);
1424
- for (const k of prevHtmlKeys) htmlEl.removeAttribute(k);
1425
- const nextHtmlKeys = Object.keys(htmlAttrs);
1426
- for (const [k, v] of Object.entries(htmlAttrs)) htmlEl.setAttribute(k, v);
1427
- if (nextHtmlKeys.length) htmlEl.setAttribute(ILHA_ROUTER_HTML_ATTR, nextHtmlKeys.join(" "));
1428
- else htmlEl.removeAttribute(ILHA_ROUTER_HTML_ATTR);
1429
- const bodyEl = document.body;
1430
- const prevBodyKeys = (bodyEl.getAttribute(ILHA_ROUTER_BODY_ATTR) ?? "").split(/\s+/).filter(Boolean);
1431
- for (const k of prevBodyKeys) bodyEl.removeAttribute(k);
1432
- const nextBodyKeys = Object.keys(bodyAttrs);
1433
- for (const [k, v] of Object.entries(bodyAttrs)) bodyEl.setAttribute(k, v);
1434
- if (nextBodyKeys.length) bodyEl.setAttribute(ILHA_ROUTER_BODY_ATTR, nextBodyKeys.join(" "));
1435
- else bodyEl.removeAttribute(ILHA_ROUTER_BODY_ATTR);
1436
- }
1437
- async function withHeadStore(store, fn) {
1438
- if (isBrowser) {
1439
- const prev = _browserHeadStore;
1440
- _browserHeadStore = store;
1441
- try {
1442
- return await fn();
1443
- } finally {
1444
- _browserHeadStore = prev;
1445
- }
1446
- }
1447
- return await (await getHeadAlsAsync()).run(store, () => Promise.resolve(fn()));
1448
- }
1449
- const HEAD_ESC = {
1450
- "&": "&amp;",
1451
- "<": "&lt;",
1452
- ">": "&gt;",
1453
- "\"": "&quot;",
1454
- "'": "&#39;"
1455
- };
1456
- function escapeHeadAttr(value) {
1457
- return String(value).replace(/[&<>"']/g, (c) => HEAD_ESC[c]);
1458
- }
1459
- /** Escape text content for inline HTML (loader error messages etc.). */
1460
- function escapeHtml(value) {
1461
- return String(value).replace(/[&<>]/g, (c) => HEAD_ESC[c]);
1462
- }
1463
- function serializeAttrs(attrs) {
1464
- const parts = [];
1465
- for (const [k, v] of Object.entries(attrs)) {
1466
- if (!/^[A-Za-z_:][A-Za-z0-9:._-]*$/.test(k) || /^on[a-z]/i.test(k)) {
1467
- if (isDevEnv()) console.warn(`[ilha-router] Dropping unsafe head attribute "${k}".`);
1468
- continue;
1469
- }
1470
- parts.push(` ${k}="${escapeHeadAttr(v)}"`);
1471
- }
1472
- return parts.join("");
1473
- }
1474
- function isSafeUrl(value) {
1475
- const v = value.trim();
1476
- if (v === "") return true;
1477
- if (v.startsWith("//")) return false;
1478
- if (v.startsWith("#") || v.startsWith("/") || v.startsWith("./")) return true;
1479
- if (/^(?:javascript|vbscript|data):/i.test(v)) return false;
1480
- try {
1481
- const u = new URL(v, "http://localhost");
1482
- return u.protocol === "http:" || u.protocol === "https:";
1483
- } catch {
1484
- return false;
1485
- }
1486
- }
1487
- function metaDedupKey(tag) {
1488
- if ("charset" in tag) return "charset";
1489
- if ("name" in tag) return `name:${tag.name}`;
1490
- if ("property" in tag) return `property:${tag.property}`;
1491
- if ("http-equiv" in tag) return `http-equiv:${tag["http-equiv"]}`;
1492
- return JSON.stringify(tag);
1493
- }
1494
- function dedupByKey(tags, keyOf) {
1495
- const map = /* @__PURE__ */ new Map();
1496
- for (const tag of tags) map.set(keyOf(tag), tag);
1497
- return [...map.values()];
1498
- }
1499
- function applyTitleTemplate(title, template) {
1500
- if (template === void 0) return title;
1501
- if (typeof template === "function") return template(title);
1502
- return template.replace(/%s/g, title ?? "");
1503
- }
1504
- /**
1505
- * Merge head entries in contribution order (loader first as the base, then
1506
- * render-time outer→inner layouts, then the page) and serialize. Later entries
1507
- * win on collision; the last `titleTemplate` wraps the resolved title.
1508
- */
1509
- function serializeHead(entries) {
1510
- let title;
1511
- let titleTemplate;
1512
- const meta = [];
1513
- const link = [];
1514
- const script = [];
1515
- let htmlAttrs = {};
1516
- let bodyAttrs = {};
1517
- for (const entry of entries) {
1518
- if (entry.title !== void 0) title = entry.title;
1519
- if (entry.titleTemplate !== void 0) titleTemplate = entry.titleTemplate;
1520
- if (entry.meta) meta.push(...entry.meta);
1521
- if (entry.link) link.push(...entry.link);
1522
- if (entry.script) script.push(...entry.script);
1523
- if (entry.htmlAttrs) htmlAttrs = {
1524
- ...htmlAttrs,
1525
- ...entry.htmlAttrs
1526
- };
1527
- if (entry.bodyAttrs) bodyAttrs = {
1528
- ...bodyAttrs,
1529
- ...entry.bodyAttrs
1530
- };
1531
- }
1532
- const resolvedTitle = applyTitleTemplate(title, titleTemplate);
1533
- const parts = [];
1534
- if (resolvedTitle !== void 0) parts.push(`<title>${escapeHeadAttr(resolvedTitle)}</title>`);
1535
- for (const tag of dedupByKey(meta, metaDedupKey)) {
1536
- if (/^refresh$/i.test(tag["http-equiv"] ?? "") && !isSafeRefreshTarget(tag.content ?? "")) {
1537
- if (isDevEnv()) console.warn(`[ilha-router] Dropping unsafe meta refresh target "${tag.content}".`);
1538
- continue;
1539
- }
1540
- parts.push(`<meta${serializeAttrs({
1541
- ...tag,
1542
- [ILHA_HEAD_ATTR]: ""
1543
- })} />`);
1544
- }
1545
- for (const tag of dedupByKey(link, (t) => `${t.rel ?? ""}:${t.href ?? ""}`)) {
1546
- if (tag.href !== void 0 && !isSafeUrl(tag.href)) {
1547
- if (isDevEnv()) console.warn(`[ilha-router] Dropping unsafe link href "${tag.href}".`);
1548
- continue;
1549
- }
1550
- parts.push(`<link${serializeAttrs({
1551
- ...tag,
1552
- [ILHA_HEAD_ATTR]: ""
1553
- })} />`);
1554
- }
1555
- for (const tag of script) {
1556
- if (tag.src !== void 0 && !isSafeUrl(tag.src)) {
1557
- if (isDevEnv()) console.warn(`[ilha-router] Dropping unsafe script src "${tag.src}".`);
1558
- continue;
1559
- }
1560
- const { children, ...attrs } = tag;
1561
- const body = (children ?? "").replace(/<\/script/gi, "<\\/script");
1562
- parts.push(`<script${serializeAttrs(attrs)}>${body}<\/script>`);
1563
- }
1564
- return {
1565
- headTags: parts.join("\n "),
1566
- htmlAttrs: serializeAttrs(htmlAttrs),
1567
- bodyAttrs: serializeAttrs(bodyAttrs)
1568
- };
1569
- }
1570
- function isSafeRefreshTarget(content) {
1571
- const match = /url\s*=\s*(['"]?)([^'";\s]+)\1/i.exec(content);
1572
- if (!match) return true;
1573
- return isSafeUrl(match[2] ?? "");
1574
- }
1575
- /**
1576
- * Build an HTTP `Response` for SSR output with sensible security headers:
1577
- * `Content-Type: text/html`, `X-Content-Type-Options: nosniff`,
1578
- * `Referrer-Policy`, `Cache-Control: no-store`, and an optional CSP. This is
1579
- * a low-level helper — prefer {@link RouterBuilder.respond} for the full
1580
- * render+head+headers pipeline.
1581
- */
1582
- function httpResponse(body, options = {}) {
1583
- const headers = new Headers(options.headers);
1584
- if (body != null && !headers.has("content-type")) headers.set("content-type", "text/html; charset=utf-8");
1585
- if (!headers.has("x-content-type-options")) headers.set("x-content-type-options", "nosniff");
1586
- if (!headers.has("referrer-policy")) headers.set("referrer-policy", "no-referrer");
1587
- if (!headers.has("cache-control")) headers.set("cache-control", "no-store");
1588
- const csp = options.contentSecurityPolicy ?? (options.cspNonce ? `default-src 'self'; script-src 'self' 'nonce-${options.cspNonce}'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; object-src 'none'; base-uri 'self'; frame-ancestors 'self'` : void 0);
1589
- if (csp) headers.set("content-security-policy", csp);
1590
- return new Response(body, {
1591
- status: options.status ?? 200,
1592
- headers
1593
- });
1594
- }
1595
- const EMPTY_HEAD = {
1596
- headTags: "",
1597
- htmlAttrs: "",
1598
- bodyAttrs: ""
1599
- };
1600
1397
  function resolveRequestUrl(urlOrRequest, request) {
1601
1398
  if (urlOrRequest instanceof Request) return {
1602
1399
  url: urlOrRequest.url,
@@ -1620,15 +1417,13 @@ function isDevEnv() {
1620
1417
  }
1621
1418
  /**
1622
1419
  * Validate a loader redirect target. Relative paths always pass; same-origin
1623
- * absolute URLs collapse to a path; cross-origin targets are rejected unless
1624
- * `allowExternal`. Protocol-relative (`//host`) and unparsable targets are
1625
- * always rejected.
1420
+ * absolute URLs collapse to a path; cross-origin targets (including
1421
+ * protocol-relative `//host` URLs) are rejected unless `allowExternal` is set.
1422
+ * Non-http(s) schemes, backslash/control-char smuggling, and unparsable
1423
+ * targets are always rejected.
1626
1424
  */
1627
1425
  function resolveRedirectTarget(to, base, allowExternal) {
1628
- if (to.startsWith("/") && !to.startsWith("//")) return {
1629
- ok: true,
1630
- to
1631
- };
1426
+ if (/[\\\u0000-\u0020]/.test(to)) return { ok: false };
1632
1427
  try {
1633
1428
  const u = new URL(to, base);
1634
1429
  if (!/^https?:$/.test(u.protocol)) return { ok: false };
@@ -1712,7 +1507,7 @@ async function executeLoader(loader, url, params, request, signal, onHead) {
1712
1507
  console.error("[ilha-router] loader failed:", e);
1713
1508
  return {
1714
1509
  kind: "error",
1715
- status: typeof e?.status === "number" ? e.status : 500,
1510
+ status: typeof e?.status === "number" && e.status >= 400 && e.status <= 599 ? e.status : 500,
1716
1511
  message: isDevEnv() ? e?.message ?? "Loader failed" : "Internal error"
1717
1512
  };
1718
1513
  }
@@ -1865,7 +1660,7 @@ function router(options = {}) {
1865
1660
  };
1866
1661
  const reverseRegistry = registry ? buildReverseRegistry(registry) : void 0;
1867
1662
  let navVersion = 0;
1868
- const NavHandler = ilha.render(() => {
1663
+ const NavHandler = ilha(() => {
1869
1664
  const current = activeIsland();
1870
1665
  const pathWithSearch = routePath() + routeSearch();
1871
1666
  if (current !== currentMountedIsland || pathWithSearch !== currentMountedPath) {
@@ -2042,7 +1837,7 @@ function router(options = {}) {
2042
1837
  }
2043
1838
  const escaped = escapeHtml(result.message);
2044
1839
  await withViewSwap(() => {
2045
- viewHost.innerHTML = `<div data-router-error="${result.status}">${escaped}</div>`;
1840
+ viewHost.innerHTML = `<div data-router-error="${escapeHtml(result.status)}">${escaped}</div>`;
2046
1841
  });
2047
1842
  return;
2048
1843
  }
@@ -2066,7 +1861,7 @@ function router(options = {}) {
2066
1861
  if (e?.name === "AbortError") return;
2067
1862
  console.error("[ilha-router] initial mount failed:", e);
2068
1863
  });
2069
- const NavHandler = ilha.render(() => {
1864
+ const NavHandler = ilha(() => {
2070
1865
  const current = activeIsland();
2071
1866
  const pathWithSearch = routePath() + routeSearch();
2072
1867
  if (current !== currentMountedIsland || pathWithSearch !== currentMountedPath) {
@@ -2155,7 +1950,8 @@ function router(options = {}) {
2155
1950
  });
2156
1951
  }
2157
1952
  const head = res.head ?? EMPTY_HEAD;
2158
- return httpResponse(shell ? shell(head, res.html) : res.html, {
1953
+ const body = shell ? shell(head, res.html) : res.html;
1954
+ return httpResponse(body, {
2159
1955
  status: status ?? res.status ?? 200,
2160
1956
  headers,
2161
1957
  cspNonce,
@@ -2296,14 +2092,14 @@ function router(options = {}) {
2296
2092
  kind: "error",
2297
2093
  status: result.status,
2298
2094
  message: result.message,
2299
- html: `<div data-router-view data-router-error="${result.status}">${html}</div>`,
2095
+ html: `<div data-router-view data-router-error="${escapeHtml(result.status)}">${html}</div>`,
2300
2096
  head: serializeHead(headStore.entries)
2301
2097
  };
2302
2098
  } catch (e) {
2303
2099
  console.error("[ilha-router] error boundary threw while rendering a loader error:", e);
2304
2100
  }
2305
2101
  const escapedMessage = escapeHtml(result.message);
2306
- const html = `<div data-router-view data-router-error="${result.status}">${escapedMessage}</div>`;
2102
+ const html = `<div data-router-view data-router-error="${escapeHtml(result.status)}">${escapedMessage}</div>`;
2307
2103
  return {
2308
2104
  kind: "error",
2309
2105
  status: result.status,
@@ -2362,4 +2158,4 @@ var src_default = {
2362
2158
  };
2363
2159
 
2364
2160
  //#endregion
2365
- export { useContext as A, routeHash as C, router as D, routeSearch as E, parsePattern as F, safeDecode as I, getHistoryMode as L, wrapError as M, wrapLayout as N, serializeHead as O, matchSegments as P, setHistoryMode as R, resolveRedirectTarget as S, routePath as T, navigate as _, RouterView as a, prime as b, composeLoaders as c, error as d, head as f, loader as g, isActive as h, RouterLink as i, useRoute as j, src_default as k, defineLayout as l, invalidate as m, LoaderError as n, afterNavigate as o, httpResponse as p, Redirect as r, beforeNavigate as s, LOADER_ENDPOINT as t, enableLinkInterception as u, navigating as v, routeParams as w, redirect as x, prefetch as y };
2161
+ export { wrapLayout as A, routePath as C, useContext as D, src_default as E, getHistoryMode as F, setHistoryMode as I, parsePattern as M, safeDecode as N, useRoute as O, httpResponse as P, routeParams as S, router as T, prefetch as _, RouterView as a, resolveRedirectTarget as b, composeLoaders as c, error as d, invalidate as f, navigating as g, navigate as h, RouterLink as i, matchSegments as j, wrapError as k, defineLayout as l, loader as m, LoaderError as n, afterNavigate as o, isActive as p, Redirect as r, beforeNavigate as s, LOADER_ENDPOINT as t, enableLinkInterception as u, prime as v, routeSearch as w, routeHash as x, redirect as y };