@ilha/router 0.11.3 → 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/README.md CHANGED
@@ -16,13 +16,13 @@ bun add @ilha/router
16
16
 
17
17
  ## Import paths
18
18
 
19
- | Import path | Use it for |
20
- | ---------------------------- | ------------------------------------------------- |
21
- | `@ilha/router` | Runtime router, navigation, `head`, route hooks |
22
- | `@ilha/router/vite` | Vite file-system routing plugin (`pages()`) |
23
- | `@ilha/router/rsbuild` | Rsbuild file-system routing plugin (`pages()`) |
19
+ | Import path | Use it for |
20
+ | --- | --- |
21
+ | `@ilha/router` | Runtime router, navigation, `head`, route hooks |
22
+ | `@ilha/router/vite` | Vite file-system routing plugin (`pages()`) |
23
+ | `@ilha/router/rsbuild` | Rsbuild file-system routing plugin (`pages()`) |
24
24
  | `@ilha/router/server-island` | Client proxies for `*.server` modules (generated) |
25
- | `@ilha/router/ssr` | `POST /__ilha/frame` middleware and frame guards |
25
+ | `@ilha/router/ssr` | `POST /__ilha/frame` middleware and frame guards |
26
26
 
27
27
  There is no loader API. Fetch data inside an async component, or stream from a server module.
28
28
 
@@ -39,7 +39,11 @@ const HomePage = () => <p>home</p>;
39
39
  const AboutPage = () => <p>about</p>;
40
40
  const NotFound = () => <p>not found</p>;
41
41
 
42
- router().route("/", HomePage).route("/about", AboutPage).route("/**", NotFound).mount("#app");
42
+ router()
43
+ .route("/", HomePage)
44
+ .route("/about", AboutPage)
45
+ .route("/**", NotFound)
46
+ .mount("#app");
43
47
  ```
44
48
 
45
49
  A mounted SPA router intercepts same-origin `<a>` clicks. Use ordinary links for navigation; call `navigate()` after application logic.
@@ -68,7 +72,10 @@ const res = await app.respond(new Request(request.url), {
68
72
  });
69
73
 
70
74
  // client
71
- router().route("/", HomePage).route("/**", NotFound).mount("#app", { hydrate: true });
75
+ router()
76
+ .route("/", HomePage)
77
+ .route("/**", NotFound)
78
+ .mount("#app", { hydrate: true });
72
79
  ```
73
80
 
74
81
  `respond()` renders the route, injects the serialized `<head>` into your shell, and emits security headers. On the client, `{ hydrate: true }` preserves the SSR DOM, seeds state from snapshots, and re-renders with hydration on later navigations.
@@ -95,27 +102,27 @@ SSR + hydration is not supported in hash mode — the server cannot see hash rou
95
102
 
96
103
  ### `router(options?)`
97
104
 
98
- | Option | Meaning |
99
- | ------------------------ | --------------------------------------------------- |
100
- | `mode` | `"spa"` (default) or `"static"` (registry only) |
101
- | `notFound` | Component for unmatched paths |
102
- | `interceptLinks` | Intercept same-origin `<a>` clicks (default `true`) |
103
- | `viewTransitions` | Wrap navigations in the View Transition API |
104
- | `allowExternalRedirects` | Allow cross-origin redirects (default `false`) |
105
+ | Option | Meaning |
106
+ | --- | --- |
107
+ | `mode` | `"spa"` (default) or `"static"` (registry only) |
108
+ | `notFound` | Component for unmatched paths |
109
+ | `interceptLinks` | Intercept same-origin `<a>` clicks (default `true`) |
110
+ | `viewTransitions` | Wrap navigations in the View Transition API |
111
+ | `allowExternalRedirects` | Allow cross-origin redirects (default `false`) |
105
112
 
106
113
  ### Builder
107
114
 
108
- | Method | Purpose |
109
- | ---------------------------------------------- | --------------------------------------- |
110
- | `route(pattern, page)` | Register a URL pattern |
111
- | `errorBoundary(pattern, handler)` | Catch failures for a pattern |
112
- | `routes()` | The route records |
113
- | `prime()` | Prime route signals (browser) |
114
- | `mount(target, { hydrate?, interceptLinks? })` | Activate in the browser |
115
- | `render(url)` | HTML string (server) |
116
- | `renderResponse(url)` | `RenderResponse` discriminated union |
117
- | `respond(url, options?)` | `Response` with head + security headers |
118
- | `hydrate({ root?, interceptLinks? })` | Hydrate SSR markup, then navigate |
115
+ | Method | Purpose |
116
+ | --- | --- |
117
+ | `route(pattern, page)` | Register a URL pattern |
118
+ | `errorBoundary(pattern, handler)` | Catch failures for a pattern |
119
+ | `routes()` | The route records |
120
+ | `prime()` | Prime route signals (browser) |
121
+ | `mount(target, { hydrate?, interceptLinks? })` | Activate in the browser |
122
+ | `render(url)` | HTML string (server) |
123
+ | `renderResponse(url)` | `RenderResponse` discriminated union |
124
+ | `respond(url, options?)` | `Response` with head + security headers |
125
+ | `hydrate({ root?, interceptLinks? })` | Hydrate SSR markup, then navigate |
119
126
 
120
127
  `renderResponse()` resolves to `{ kind: "html", html, status?, head? }`, `{ kind: "redirect", to, status }`, or `{ kind: "error", status, message, html, head? }`.
121
128
 
@@ -149,16 +156,16 @@ const Breadcrumb = () => {
149
156
  };
150
157
  ```
151
158
 
152
- | Export | Meaning |
153
- | ----------------------------------------------------------------- | ------------------------------------------------------ |
154
- | `useRoute()` | `{ path, params, search, hash, navigating }` accessors |
155
- | `routePath()` / `routeParams()` / `routeSearch()` / `routeHash()` | Standalone accessors |
156
- | `navigate(to, { replace?, scroll? })` | Programmatic navigation |
157
- | `navigating()` | True while a navigation is in flight |
158
- | `isActive(pattern, { end? })` | True when the current path matches |
159
- | `beforeNavigate(fn)` / `afterNavigate(fn)` | Navigation hooks (can cancel) |
160
- | `useContext()` | `{ request }` during SSR |
161
- | `enableLinkInterception(root?)` | Manual link interception |
159
+ | Export | Meaning |
160
+ | --- | --- |
161
+ | `useRoute()` | `{ path, params, search, hash, navigating }` accessors |
162
+ | `routePath()` / `routeParams()` / `routeSearch()` / `routeHash()` | Standalone accessors |
163
+ | `navigate(to, { replace?, scroll? })` | Programmatic navigation |
164
+ | `navigating()` | True while a navigation is in flight |
165
+ | `isActive(pattern, { end? })` | True when the current path matches |
166
+ | `beforeNavigate(fn)` / `afterNavigate(fn)` | Navigation hooks (can cancel) |
167
+ | `useContext()` | `{ request }` during SSR |
168
+ | `enableLinkInterception(root?)` | Manual link interception |
162
169
 
163
170
  ### Head
164
171
 
@@ -171,7 +178,7 @@ export default function About() {
171
178
  }
172
179
  ```
173
180
 
174
- `HeadInput` fields: `title`, `titleTemplate`, `meta`, `link`, `script`, `htmlAttrs`, `bodyAttrs`. Call `head()` inside a page or layout; during SSR entries collect into the render window and `serializeHead()` turns them into shell fragments. On the client, entries apply to `document` on navigation.
181
+ `HeadInput` fields: `title`, `titleTemplate`, `meta`, `link`, `script`, `htmlAttrs`, `bodyAttrs`. Call `head()` inside a page or layout — during SSR entries collect into the render window; on the client the router applies them to `document` after each mount and navigation.
175
182
 
176
183
  ### Pages, layouts, and errors
177
184
 
@@ -181,14 +188,14 @@ import { defineLayout, wrapError, error, redirect } from "@ilha/router";
181
188
  export default defineLayout(({ children }) => <main>{children}</main>);
182
189
  ```
183
190
 
184
- | Export | Purpose |
185
- | ------------------------------------------------------------------------------ | ----------------------------------------------- |
186
- | `wrapLayout(layout, page)` | Wrap a page in a layout (`children` carries it) |
187
- | `wrapError(handler, page)` | Catch page throws, render a fallback view |
188
- | `defineLayout(layout)` | Type helper for layout components |
189
- | `redirect(to, status?)` | Throw `Redirect` — the router navigates |
190
- | `error(status, message)` | Throw `RouteError` — a boundary catches it |
191
- | `httpResponse(html, { status?, headers?, cspNonce?, contentSecurityPolicy? })` | Headered `Response` |
191
+ | Export | Purpose |
192
+ | --- | --- |
193
+ | `wrapLayout(layout, page)` | Wrap a page in a layout (`children` carries it) |
194
+ | `wrapError(handler, page)` | Catch page throws, render a fallback view |
195
+ | `defineLayout(layout)` | Type helper for layout components |
196
+ | `redirect(to, status?)` | Throw `Redirect` — the router navigates |
197
+ | `error(status, message)` | Throw `RouteError` — a boundary catches it |
198
+ | `httpResponse(html, { status?, headers?, cspNonce?, contentSecurityPolicy? })` | Headered `Response` |
192
199
 
193
200
  An error handler receives `AppError` (`message`, `status?`) and a route snapshot, and returns a view or a component.
194
201
 
@@ -240,10 +247,10 @@ pages({
240
247
 
241
248
  ### Virtual modules
242
249
 
243
- | Module | Exports | Use for |
244
- | ------------------- | ------------------------ | ------------------------------- |
250
+ | Module | Exports | Use for |
251
+ | --- | --- | --- |
245
252
  | `ilha:pages/server` | `pageRouter`, `registry` | SSR, prerender, server handlers |
246
- | `ilha:pages/client` | `pageRouter`, `registry` | Browser hydration entry |
253
+ | `ilha:pages/client` | `pageRouter`, `registry` | Browser hydration entry |
247
254
 
248
255
  ```ts
249
256
  // src/client.ts — browser entry
@@ -286,7 +293,7 @@ export const getTasks = action(async function* () {
286
293
  export const TaskList = async function TaskList() {
287
294
  return Stream.map(
288
295
  Stream.fromAsyncIterable(getTasks(), (error) =>
289
- error instanceof Error ? error : new Error(String(error)),
296
+ error instanceof Error ? error : new Error(String(error))
290
297
  ),
291
298
  (list) => (
292
299
  <ul>
@@ -294,7 +301,7 @@ export const TaskList = async function TaskList() {
294
301
  <li key={t.id}>{t.text}</li>
295
302
  ))}
296
303
  </ul>
297
- ),
304
+ )
298
305
  );
299
306
  };
300
307
  ```
@@ -318,24 +325,24 @@ The plugin rewrites the client-graph import of `TaskList` to a proxy (`@ilha/rou
318
325
 
319
326
  The frame endpoint re-renders server islands from a client state snapshot. Island state is world-readable through frames unless you gate them.
320
327
 
321
- | Concern | How |
322
- | ------------------ | ---------------------------------------------------------------------------------------- |
323
- | Production posture | Deny-by-default: `/__ilha/frame` returns `403` until you install a guard |
324
- | Dev posture | Permissive unless a `frameGuard` is registered (plugin option) |
325
- | Origin checks | `Origin` compared against `setFrameAuth({ trustedOrigins })` or the request's own `Host` |
326
- | CSRF | `setFrameAuth({ csrf })` verifier for the state-changing POST |
327
- | Identity | Only `cookie`, `authorization`, `user-agent` are forwarded to the scoped render |
328
- | Body cap | 16 KiB; oversized bodies return `413` |
328
+ | Concern | How |
329
+ | --- | --- |
330
+ | Production posture | Deny-by-default: `/__ilha/frame` returns `403` until you install a guard |
331
+ | Dev posture | Permissive unless a `frameGuard` is registered (plugin option) |
332
+ | Origin checks | `Origin` compared against `setFrameAuth({ trustedOrigins })` or the request's own `Host` |
333
+ | CSRF | `setFrameAuth({ csrf })` verifier for the state-changing POST |
334
+ | Identity | Only `cookie`, `authorization`, `user-agent` are forwarded to the scoped render |
335
+ | Body cap | 16 KiB; oversized bodies return `413` |
329
336
 
330
337
  ### `@ilha/router/ssr`
331
338
 
332
- | Export | Purpose |
333
- | ---------------------------------------------------------- | ------------------------------------------------ |
334
- | `ssr` (default) | The production frame handler |
335
- | `setFrameAuth({ defaultAction?, trustedOrigins?, csrf? })` | Install the frame-auth policy |
336
- | `setFrameGuard(guard)` | Per-request allow/deny |
337
- | `renderServerIsland(id, request, runWithScope, props?)` | Render one island — `Effect<string, FrameError>` |
338
- | `renderServerIslandResult(...)` | Promise/`Result` variant for non-Effect callers |
339
+ | Export | Purpose |
340
+ | --- | --- |
341
+ | `ssr` (default) | The production frame handler |
342
+ | `setFrameAuth({ defaultAction?, trustedOrigins?, csrf? })` | Install the frame-auth policy |
343
+ | `setFrameGuard(guard)` | Per-request allow/deny |
344
+ | `renderServerIsland(id, request, runWithScope, props?)` | Render one island — `Effect<string, FrameError>` |
345
+ | `renderServerIslandResult(...)` | Promise/`Result` variant for non-Effect callers |
339
346
 
340
347
  ```ts
341
348
  import { setFrameAuth } from "@ilha/router/ssr";
@@ -349,7 +356,9 @@ setFrameAuth({
349
356
  import { setFrameGuard } from "@ilha/router/ssr";
350
357
 
351
358
  setFrameGuard((request) =>
352
- isSignedIn(request) ? undefined : new Response("Unauthorized", { status: 401 }),
359
+ isSignedIn(request)
360
+ ? undefined
361
+ : new Response("Unauthorized", { status: 401 })
353
362
  );
354
363
  ```
355
364
 
package/dist/codegen.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /** Server pages: `foo.server.tsx` routes `/foo`, rendered through the frame protocol. */
2
2
  export declare const SERVER_PAGE_RE: RegExp;
3
- export declare function fileToPattern(pagesDir: string, file: string): string;
3
+ export declare const fileToPattern: (pagesDir: string, file: string) => string;
4
4
  export type PagesMode = "spa" | "static";
5
5
  export interface GenerateOptions {
6
6
  /** Client navigation mode. Default: `spa`. */
@@ -23,5 +23,5 @@ export interface GeneratedPaths {
23
23
  /** Client module: ?client imports, browser-optimised. `ilha:pages/client` */
24
24
  clientFile: string;
25
25
  }
26
- export declare function resolveGeneratedPaths(outDir: string): GeneratedPaths;
27
- export declare function generate(pagesDir: string, outDir: string, options?: GenerateOptions): Promise<void>;
26
+ export declare const resolveGeneratedPaths: (outDir: string) => GeneratedPaths;
27
+ export declare const generate: (pagesDir: string, outDir: string, options?: GenerateOptions) => Promise<void>;
package/dist/hash.d.ts CHANGED
@@ -1,4 +1,8 @@
1
1
  export type HistoryMode = "history" | "hash";
2
+ /** Structured-cloneable history entry state passed to pushState/replaceState. */
3
+ export type HistoryEntryState = null | undefined | string | number | boolean | HistoryEntryState[] | {
4
+ readonly [key: string]: HistoryEntryState | undefined;
5
+ };
2
6
  export interface LogicalLocation {
3
7
  pathname: string;
4
8
  search: string;
@@ -6,24 +10,24 @@ export interface LogicalLocation {
6
10
  }
7
11
  export interface HistoryAdapter {
8
12
  /** Read the current logical URL (the one routes are matched against). */
9
- readLocation(): LogicalLocation;
13
+ readLocation: () => LogicalLocation;
10
14
  /** Push a new logical URL onto the history stack. `state` is stored on the history entry. */
11
- push(to: string, state?: unknown): void;
15
+ push: (to: string, state?: HistoryEntryState) => void;
12
16
  /** Replace the current history entry with a new logical URL. `state` is stored on the history entry. */
13
- replace(to: string, state?: unknown): void;
17
+ replace: (to: string, state?: HistoryEntryState) => void;
14
18
  /** Subscribe to logical-URL changes. Returns a cleanup function. */
15
- onChange(handler: () => void): () => void;
19
+ onChange: (handler: () => void) => () => void;
16
20
  /**
17
21
  * Convert a logical href (what the user writes, e.g. "/users/42") into
18
22
  * the actual DOM href attribute (e.g. "#/users/42" in hash mode).
19
23
  */
20
- toLinkHref(logicalPath: string): string;
24
+ toLinkHref: (logicalPath: string) => string;
21
25
  /**
22
26
  * Extract a logical path from an `<a>` element. Returns null when the
23
27
  * link is not an in-app navigation target (external, anchor-only, etc).
24
28
  * The caller still applies modifier-key / target=_blank checks.
25
29
  */
26
- extractLogicalPath(anchor: HTMLAnchorElement): string | null;
30
+ extractLogicalPath: (anchor: HTMLAnchorElement) => string | null;
27
31
  }
28
32
  /**
29
33
  * Set the router's history mode. Call this once at app entry, before
@@ -38,7 +42,7 @@ export interface HistoryAdapter {
38
42
  * until they're re-attached (typically by unmounting and remounting
39
43
  * the router).
40
44
  */
41
- export declare function setHistoryMode(mode: HistoryMode): void;
42
- export declare function getHistoryMode(): HistoryMode;
45
+ export declare const setHistoryMode: (mode: HistoryMode) => void;
46
+ export declare const getHistoryMode: () => HistoryMode;
43
47
  /** Internal — used by index.ts. Not part of the public API. */
44
- export declare function getAdapter(): HistoryAdapter;
48
+ export declare const getAdapter: () => HistoryAdapter;
@@ -0,0 +1,342 @@
1
+ //#region src/head.ts
2
+ /**
3
+ * Render-scoped head collection and document sync.
4
+ *
5
+ * Loaders and render-time `head()` calls push `HeadInput` entries into a
6
+ * store scoped to the current render (`withHeadStore` / `withHeadStoreSync`).
7
+ * SSR serializes them with `serializeHead`; on the client the router applies
8
+ * the collected entries to `document` after each mount/navigation.
9
+ */
10
+ const objectTag = (value) => Object.prototype.toString.call(value);
11
+ const isFunction = (value) => {
12
+ const tag = objectTag(value);
13
+ return tag === "[object Function]" || tag === "[object AsyncFunction]" || tag === "[object GeneratorFunction]";
14
+ };
15
+ /** Backslash or ASCII control/space chars — HTML parsers strip or mangle these in URLs. */
16
+ const hasUnsafeUrlChar = (value) => {
17
+ for (const ch of value) {
18
+ const code = ch.codePointAt(0) ?? 0;
19
+ if (code <= 32 || code === 92) return true;
20
+ }
21
+ return false;
22
+ };
23
+ const isBrowser = globalThis.window !== void 0 && globalThis.document !== void 0;
24
+ /** Dev-mode check mirroring index.ts — used to surface loader error detail. */
25
+ const isDevEnv = () => {
26
+ if (isBrowser) return false;
27
+ try {
28
+ return (process.env?.["NODE_ENV"] ?? "development") !== "production";
29
+ } catch {
30
+ return true;
31
+ }
32
+ };
33
+ const ILHA_HEAD_ATTR = "data-ilha-head";
34
+ const ILHA_ROUTER_HTML_ATTR = "data-ilha-router-html";
35
+ const ILHA_ROUTER_BODY_ATTR = "data-ilha-router-body";
36
+ /** Browser-only fallback; SSR uses AsyncLocalStorage (see `withHeadStore`). */
37
+ let _browserHeadStore = null;
38
+ let _headAls = null;
39
+ let _headAlsInit = null;
40
+ /** ESM dynamic import — Nitro/Vite SSR workers have no `require`. */
41
+ const getHeadAlsAsync = async () => {
42
+ if (_headAls) return _headAls;
43
+ if (!_headAlsInit) _headAlsInit = (async () => {
44
+ const { AsyncLocalStorage } = await import(
45
+ /* @vite-ignore */
46
+ "node:async_hooks"
47
+ );
48
+ _headAls = new AsyncLocalStorage();
49
+ return _headAls;
50
+ })();
51
+ return await _headAlsInit;
52
+ };
53
+ const activeHeadStore = () => {
54
+ if (isBrowser) return _browserHeadStore;
55
+ return _headAls?.getStore() ?? null;
56
+ };
57
+ const cssEscapeAttr = (value) => {
58
+ const cssApi = globalThis.CSS;
59
+ if (cssApi !== void 0 && isFunction(cssApi.escape)) return cssApi.escape(value);
60
+ return value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"");
61
+ };
62
+ const headManagedMetaSelector = (tag) => {
63
+ if ("charset" in tag) return `meta[charset][${ILHA_HEAD_ATTR}]`;
64
+ if ("name" in tag) return `meta[name="${cssEscapeAttr(tag.name ?? "")}"][${ILHA_HEAD_ATTR}]`;
65
+ if ("property" in tag) return `meta[property="${cssEscapeAttr(tag.property ?? "")}"][${ILHA_HEAD_ATTR}]`;
66
+ if ("http-equiv" in tag) return `meta[http-equiv="${cssEscapeAttr(tag["http-equiv"] ?? "")}"][${ILHA_HEAD_ATTR}]`;
67
+ return null;
68
+ };
69
+ const headManagedLinkSelector = (tag) => {
70
+ if (tag.rel && tag.href) return `link[rel="${cssEscapeAttr(tag.rel)}"][href="${cssEscapeAttr(tag.href)}"][${ILHA_HEAD_ATTR}]`;
71
+ return null;
72
+ };
73
+ const HEAD_ESC = {
74
+ "\"": "&quot;",
75
+ "&": "&amp;",
76
+ "'": "&#39;",
77
+ "<": "&lt;",
78
+ ">": "&gt;"
79
+ };
80
+ const escapeMappedChar = (c) => {
81
+ if (c in HEAD_ESC) return HEAD_ESC[c];
82
+ return c;
83
+ };
84
+ const escapeHeadAttr = (value) => String(value).replaceAll(/[&<>"']/gu, escapeMappedChar);
85
+ const metaDedupKey = (tag) => {
86
+ if ("charset" in tag) return "charset";
87
+ if ("name" in tag) return `name:${tag.name}`;
88
+ if ("property" in tag) return `property:${tag.property}`;
89
+ if ("http-equiv" in tag) return `http-equiv:${tag["http-equiv"]}`;
90
+ return JSON.stringify(tag);
91
+ };
92
+ const dedupByKey = (tags, keyOf) => {
93
+ const map = /* @__PURE__ */ new Map();
94
+ for (const tag of tags) map.set(keyOf(tag), tag);
95
+ return [...map.values()];
96
+ };
97
+ const applyTitleTemplate = (title, template) => {
98
+ if (template === void 0) return title;
99
+ if (isFunction(template)) return template(title);
100
+ return template.replaceAll("%s", title ?? "");
101
+ };
102
+ const serializeAttrs = (attrs) => {
103
+ const parts = [];
104
+ for (const [k, v] of Object.entries(attrs)) {
105
+ if (!/^[A-Za-z_:][A-Za-z0-9:._-]*$/u.test(k) || /^on[a-z]/iu.test(k) || v === void 0) {
106
+ if (isDevEnv()) console.warn(`[ilha-router] Dropping unsafe head attribute "${k}".`);
107
+ continue;
108
+ }
109
+ parts.push(` ${k}="${escapeHeadAttr(v)}"`);
110
+ }
111
+ return parts.join("");
112
+ };
113
+ const isSafeUrl = (value) => {
114
+ const v = value.trim();
115
+ if (v === "") return true;
116
+ if (hasUnsafeUrlChar(v)) return false;
117
+ if (v.startsWith("//")) return false;
118
+ if (v.startsWith("#") || v.startsWith("/") || v.startsWith("./")) return true;
119
+ if (/^(?:javascript|vbscript|data):/iu.test(v)) return false;
120
+ try {
121
+ const u = new URL(v, "http://localhost");
122
+ return u.protocol === "http:" || u.protocol === "https:";
123
+ } catch {
124
+ return false;
125
+ }
126
+ };
127
+ const isSafeRefreshTarget = (content) => {
128
+ const match = /url\s*=\s*(?<quote>['"]?)(?<target>[^'";\s]+)\k<quote>/iu.exec(content);
129
+ if (!match) return true;
130
+ const target = match.groups?.target ?? "";
131
+ if (hasUnsafeUrlChar(target)) return false;
132
+ if (target.startsWith("//")) return false;
133
+ return target.startsWith("/") || target.startsWith("./");
134
+ };
135
+ const mergeHeadEntries = (entries, includeScripts) => {
136
+ let title;
137
+ let titleTemplate;
138
+ const meta = [];
139
+ const link = [];
140
+ const script = [];
141
+ const htmlAttrs = {};
142
+ const bodyAttrs = {};
143
+ for (const entry of entries) {
144
+ const { title: entryTitle, titleTemplate: entryTemplate, meta: entryMeta, link: entryLink, script: entryScript, htmlAttrs: entryHtml, bodyAttrs: entryBody } = entry;
145
+ if (entryTitle !== void 0) title = entryTitle;
146
+ if (entryTemplate !== void 0) titleTemplate = entryTemplate;
147
+ if (entryMeta) meta.push(...entryMeta);
148
+ if (entryLink) link.push(...entryLink);
149
+ if (includeScripts && entryScript) script.push(...entryScript);
150
+ if (entryHtml) Object.assign(htmlAttrs, entryHtml);
151
+ if (entryBody) Object.assign(bodyAttrs, entryBody);
152
+ }
153
+ return {
154
+ bodyAttrs,
155
+ htmlAttrs,
156
+ link,
157
+ meta,
158
+ script,
159
+ title,
160
+ titleTemplate
161
+ };
162
+ };
163
+ const syncManagedMeta = (metaTags, keep) => {
164
+ for (const tag of metaTags) {
165
+ const selector = headManagedMetaSelector(tag);
166
+ if (!selector) continue;
167
+ const found = document.querySelector(selector);
168
+ let el;
169
+ if (found instanceof HTMLMetaElement) el = found;
170
+ else {
171
+ el = document.createElement("meta");
172
+ el.setAttribute(ILHA_HEAD_ATTR, "");
173
+ document.head.append(el);
174
+ }
175
+ for (const [k, v] of Object.entries(tag)) if (v !== void 0) el.setAttribute(k, v);
176
+ keep.add(el);
177
+ }
178
+ };
179
+ const syncManagedLink = (linkTags, keep) => {
180
+ for (const tag of linkTags) {
181
+ const selector = headManagedLinkSelector(tag);
182
+ let el = null;
183
+ if (selector) {
184
+ const found = document.querySelector(selector);
185
+ if (found instanceof HTMLLinkElement) el = found;
186
+ }
187
+ if (!el) {
188
+ el = document.createElement("link");
189
+ el.setAttribute(ILHA_HEAD_ATTR, "");
190
+ document.head.append(el);
191
+ }
192
+ for (const [k, v] of Object.entries(tag)) if (v !== void 0) el.setAttribute(k, v);
193
+ keep.add(el);
194
+ }
195
+ };
196
+ const applyRouterAttrs = (el, attrs, trackAttr) => {
197
+ const prevKeys = (el.getAttribute(trackAttr) ?? "").split(/\s+/u).filter(Boolean);
198
+ for (const k of prevKeys) el.removeAttribute(k);
199
+ const nextKeys = Object.keys(attrs);
200
+ for (const [k, v] of Object.entries(attrs)) if (v !== void 0) el.setAttribute(k, v);
201
+ if (nextKeys.length) el.setAttribute(trackAttr, nextKeys.join(" "));
202
+ else el.removeAttribute(trackAttr);
203
+ };
204
+ /**
205
+ * Apply merged head entries on client navigations. Updates `document.title` and
206
+ * managed meta/link nodes (`data-ilha-head`). Script tags from HeadInput are
207
+ * SSR-only and are not re-injected here. Removes managed tags from the previous
208
+ * route that are not part of this navigation's set.
209
+ */
210
+ const applyHeadEntriesToDocument = (entries) => {
211
+ if (!isBrowser) return;
212
+ const merged = mergeHeadEntries(entries, false);
213
+ const resolvedTitle = applyTitleTemplate(merged.title, merged.titleTemplate);
214
+ if (resolvedTitle !== void 0) document.title = resolvedTitle;
215
+ const metaTags = dedupByKey(merged.meta, metaDedupKey);
216
+ const linkTags = dedupByKey(merged.link, (t) => `${t.rel ?? ""}:${t.href ?? ""}`);
217
+ const keepManaged = /* @__PURE__ */ new Set();
218
+ syncManagedMeta(metaTags, keepManaged);
219
+ syncManagedLink(linkTags, keepManaged);
220
+ for (const el of document.head.querySelectorAll(`[${ILHA_HEAD_ATTR}]`)) if (!keepManaged.has(el)) el.remove();
221
+ applyRouterAttrs(document.documentElement, merged.htmlAttrs, ILHA_ROUTER_HTML_ATTR);
222
+ applyRouterAttrs(document.body, merged.bodyAttrs, ILHA_ROUTER_BODY_ATTR);
223
+ };
224
+ const withHeadStore = async (store, fn) => {
225
+ if (isBrowser) {
226
+ const prev = _browserHeadStore;
227
+ _browserHeadStore = store;
228
+ try {
229
+ return await fn();
230
+ } finally {
231
+ _browserHeadStore = prev;
232
+ }
233
+ }
234
+ return await (await getHeadAlsAsync()).run(store, () => Promise.resolve(fn()));
235
+ };
236
+ let _flushScheduled = false;
237
+ let _flushTarget = null;
238
+ const scheduleBrowserHeadFlush = (store) => {
239
+ _flushTarget = store;
240
+ if (_flushScheduled) return;
241
+ _flushScheduled = true;
242
+ queueMicrotask(() => {
243
+ _flushScheduled = false;
244
+ const target = _flushTarget;
245
+ _flushTarget = null;
246
+ if (target && _browserHeadStore === target) applyHeadEntriesToDocument(target.entries);
247
+ });
248
+ };
249
+ /**
250
+ * Contribute `<head>` data from inside a page, layout, or island render.
251
+ *
252
+ * During SSR (or a client mount with an open browser head window), entries
253
+ * collect into the active store. On the client with no store, the entry is
254
+ * applied to `document` immediately.
255
+ */
256
+ const head = (input) => {
257
+ const store = activeHeadStore();
258
+ if (store) {
259
+ store.entries.push(input);
260
+ if (isBrowser) scheduleBrowserHeadFlush(store);
261
+ return;
262
+ }
263
+ if (isBrowser) {
264
+ applyHeadEntriesToDocument([input]);
265
+ return;
266
+ }
267
+ console.warn("[ilha-router] head() called outside a render window — ignored.");
268
+ };
269
+ /**
270
+ * Open a browser head collection window for a client route mount.
271
+ * Nested page/layout `head()` calls may land after `mount()` returns; keep
272
+ * the window open until `close()`, and flush on a microtask (or via `flush()`).
273
+ */
274
+ const openBrowserHead = () => {
275
+ const store = { entries: [] };
276
+ _browserHeadStore = store;
277
+ return {
278
+ close: () => {
279
+ if (_browserHeadStore === store) _browserHeadStore = null;
280
+ },
281
+ flush: () => {
282
+ applyHeadEntriesToDocument(store.entries);
283
+ }
284
+ };
285
+ };
286
+ const pushSerializedMeta = (parts, meta) => {
287
+ for (const tag of dedupByKey(meta, metaDedupKey)) {
288
+ if (/^refresh$/iu.test(tag["http-equiv"] ?? "") && !isSafeRefreshTarget(tag.content ?? "")) {
289
+ if (isDevEnv()) console.warn(`[ilha-router] Dropping unsafe meta refresh target "${tag.content}".`);
290
+ continue;
291
+ }
292
+ parts.push(`<meta${serializeAttrs({
293
+ ...tag,
294
+ [ILHA_HEAD_ATTR]: ""
295
+ })} />`);
296
+ }
297
+ };
298
+ const pushSerializedLink = (parts, link) => {
299
+ for (const tag of dedupByKey(link, (t) => `${t.rel ?? ""}:${t.href ?? ""}`)) {
300
+ if (tag.href !== void 0 && !isSafeUrl(tag.href)) {
301
+ if (isDevEnv()) console.warn(`[ilha-router] Dropping unsafe link href "${tag.href}".`);
302
+ continue;
303
+ }
304
+ parts.push(`<link${serializeAttrs({
305
+ ...tag,
306
+ [ILHA_HEAD_ATTR]: ""
307
+ })} />`);
308
+ }
309
+ };
310
+ const pushSerializedScript = (parts, script) => {
311
+ for (const tag of script) {
312
+ if (tag.src !== void 0 && !isSafeUrl(tag.src)) {
313
+ if (isDevEnv()) console.warn(`[ilha-router] Dropping unsafe script src "${tag.src}".`);
314
+ continue;
315
+ }
316
+ const { children, ...attrs } = tag;
317
+ const body = (children ?? "").replaceAll(/<\/script/giu, "<\\/script");
318
+ parts.push(`<script${serializeAttrs(attrs)}>${body}<\/script>`);
319
+ }
320
+ };
321
+ /**
322
+ * Merge head entries in contribution order (loader first as the base, then
323
+ * render-time outer→inner layouts, then the page) and serialize. Later entries
324
+ * win on collision; the last `titleTemplate` wraps the resolved title.
325
+ */
326
+ const serializeHead = (entries) => {
327
+ const merged = mergeHeadEntries(entries, true);
328
+ const resolvedTitle = applyTitleTemplate(merged.title, merged.titleTemplate);
329
+ const parts = [];
330
+ if (resolvedTitle !== void 0) parts.push(`<title>${escapeHeadAttr(resolvedTitle)}</title>`);
331
+ pushSerializedMeta(parts, merged.meta);
332
+ pushSerializedLink(parts, merged.link);
333
+ pushSerializedScript(parts, merged.script);
334
+ return {
335
+ bodyAttrs: serializeAttrs(merged.bodyAttrs),
336
+ headTags: parts.join("\n "),
337
+ htmlAttrs: serializeAttrs(merged.htmlAttrs)
338
+ };
339
+ };
340
+
341
+ //#endregion
342
+ export { withHeadStore as a, serializeHead as i, head as n, openBrowserHead as r, applyHeadEntriesToDocument as t };