// The opt-in client router — June's progressive-enhancement SPA layer.
//
// OFF by default (config.clientRouter). When on, the document wraps the page in
//
and the islands bundle calls startClientRouter() once.
// From then on, same-origin left-clicks become SOFT navigations: fetch the next
// URL's `fragment` projection (the [data-june-root] inner HTML for the SAME url —
// HTML-over-the-wire, the agent surface untouched), then MORPH it into the live
// region — unchanged nodes keep focus/scroll/selection/input, and a persistent
// island's live React root survives. New islands re-hydrate.
//
// It degrades safely: no JS, a failed fetch, or an unrecognized response shape
// all fall back to a hard browser navigation — never a broken page.
//
// PURE per the contract layer's rule (no `node:*` / `Bun.*`); it is browser-only
// (touches `document`/`history`/`fetch`), so — like islands-client — it is
// exposed ONLY via the `@junejs/core/client-router` subpath and is NOT
// re-exported from the barrel.
import { executeScripts, neutralizeScripts } from "./execute-scripts";
import { morph } from "./morph";
import { FRAGMENT_ACCEPT, SEGMENT_HEADER, SHELL_ATTR, TITLE_HEADER, decodeTitle } from "./nav-protocol";
import { outletEl, resolveSwapTarget } from "./shell";
// Called with each freshly swapped-in region so the host can hydrate the new
// page's islands (islands-client binds this to its registry). In whole-chain
// mode this is [data-june-root]; in segment mode it is the [data-june-outlet].
export type Rehydrate = (root: ParentNode) => void;
// Strip a single trailing slash (except the root "/") so an exact-page link and
// the current path compare equal regardless of slash form — June doesn't redirect
// "/guide/" to "/guide", so both reach the client verbatim.
const trimSlash = (p: string): string => (p.length > 1 && p.endsWith("/") ? p.slice(0, -1) : p);
// The shell links (a[href] OUTSIDE the outlet) reconciled on each nav, cached
// while the SAME outlet is mounted. In segment mode morph never touches the shell
// and the outlet element keeps its identity across same-shell soft-navs, so the
// list is stable; a new shell (or a fresh document) yields a different outlet
// element and rebuilds. This turns a per-nav `querySelectorAll` + per-link
// `contains()` (O(all links) on a 1000-link sidebar) into a one-time scan reused
// across navigations. (A shell island that mutates its own nav links at runtime
// won't be re-scanned until the shell remounts — docs sidebars are static.)
let cachedOutlet: Element | null = null;
let cachedShellLinks: HTMLAnchorElement[] = [];
// Segment-scoped mode moves the shell (sidebar/nav, with its active-link state)
// OUTSIDE the swapped region, so morph no longer re-renders aria-current. This
// reconciles it from location.pathname — the trade the granularity optimization
// makes. No-op in whole-chain mode (no outlet), where morph already re-renders
// the shell. A shell link is active when it points at the current page OR an
// ancestor of it (section highlight), matching the common SSR convention.
function updateActiveLinks(): void {
const outlet = outletEl();
if (!outlet) return; // whole-chain mode — morph carries aria-current for free
if (cachedOutlet !== outlet) {
cachedOutlet = outlet;
cachedShellLinks = Array.from(
document.querySelectorAll("a[href]"),
).filter((a) => !outlet.contains(a) && a.origin === location.origin);
}
const here = trimSlash(location.pathname);
for (const a of cachedShellLinks) {
const p = trimSlash(a.pathname);
const exact = p === here;
const active = exact || (p !== "/" && here.startsWith(p + "/"));
if (active) a.setAttribute("aria-current", exact ? "page" : "true");
else if (a.hasAttribute("aria-current")) a.removeAttribute("aria-current");
}
}
// Agent surfaces + non-HTML stay hard navigations — the same exclusions the
// speculation rules use (humans soft-navigate; a link to llms.txt must not).
function isHardNav(url: URL): boolean {
return /\.(md|json|txt|xml)$/.test(url.pathname) || url.pathname === "/mcp";
}
// The part of the URL a soft-nav is keyed on: path + query, never the hash. Two
// URLs with the same key are the same document; only the browser's own fragment
// scrolling differs between them.
const pageKey = (): string => location.pathname + location.search;
const isHex = (c: number): boolean =>
(c >= 0x30 && c <= 0x39) || (c >= 0x41 && c <= 0x46) || (c >= 0x61 && c <= 0x66);
// The URL spec's percent-decode followed by a non-fatal UTF-8 decode — what a
// hard load does to a fragment before looking it up. Byte-level: each
// well-formed `%XX` becomes one byte, anything else passes through unchanged,
// and an invalid UTF-8 sequence decodes to U+FFFD rather than failing. Both are
// where decodeURIComponent differs: it is all-or-nothing on a malformed escape
// AND on invalid UTF-8, so `%41%C0` would throw instead of yielding "A�".
function percentDecode(s: string): string {
if (!/%[0-9A-Fa-f]{2}/.test(s)) return s;
const src = new TextEncoder().encode(s);
const out: number[] = [];
for (let i = 0; i < src.length; i++) {
const b = src[i]!;
if (b === 0x25 /* % */ && i + 2 < src.length && isHex(src[i + 1]!) && isHex(src[i + 2]!)) {
out.push(parseInt(String.fromCharCode(src[i + 1]!, src[i + 2]!), 16));
i += 2;
} else {
out.push(b);
}
}
// "UTF-8 decode without BOM": a leading U+FEFF is part of the id, not a
// byte-order mark to strip (TextDecoder strips it unless told otherwise).
return new TextDecoder("utf-8", { ignoreBOM: true }).decode(new Uint8Array(out));
}
// The HTML spec's "find a potential indicated element", as a hard load does it:
// the RAW fragment first, then its percent-decoded form; each tried as an id,
// then as the name of an (only anchors — a same-named or