@ilha/router 0.4.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -300,7 +300,7 @@ pageRouter.mount("#app", { hydrate: true, registry });
300
300
 
301
301
  #### `.hydrate(registry, options?)` — browser only
302
302
 
303
- Convenience method that combines `.prime()`, `ilha.mount()`, and `.mount()` into a single call. **This is the recommended client entry point.**
303
+ Convenience method that combines `.prime()`, `ilha.mount()`, and `.mount()` into a single call. **This is the recommended client entry point for SPA apps.**
304
304
 
305
305
  ```ts
306
306
  pageRouter.hydrate(registry);
@@ -318,6 +318,23 @@ Returns an `unmount` function that tears down all listeners and hydrated islands
318
318
 
319
319
  ---
320
320
 
321
+ #### `.hydrateStatic(registry, options?)` — browser only
322
+
323
+ The lightest client entry point. Calls `prime()` then `ilha.mount()` — no route view is mounted, no navigation handler is installed, and no route graph is touched. Use this in `static` mode where each page is a self-contained pre-rendered HTML file.
324
+
325
+ ```ts
326
+ pageRouter.hydrateStatic(registry);
327
+
328
+ // With options:
329
+ pageRouter.hydrateStatic(registry, {
330
+ root: document.getElementById("app"), // defaults to document.body
331
+ });
332
+ ```
333
+
334
+ Internal `<a href>` links navigate via normal browser page loads. Only interactive islands in the current page are activated.
335
+
336
+ ---
337
+
321
338
  #### `.attachLoader(pattern, loader)` — runtime
322
339
 
323
340
  Attaches or replaces a loader on an already-registered route pattern. No-op if the pattern was never registered via `.route()`. Used by the `ilha:loaders` virtual module to wire server-only loaders onto the client-safe `pageRouter` at SSR time.
@@ -658,18 +675,21 @@ interface NavigateOptions {
658
675
  interface MountOptions {
659
676
  hydrate?: boolean;
660
677
  registry?: Record<string, Island>;
678
+ interceptLinks?: boolean; // default: true
661
679
  }
662
680
 
663
681
  interface HydrateOptions {
664
682
  root?: Element;
665
683
  target?: string | Element;
684
+ interceptLinks?: boolean; // default: true
666
685
  }
667
686
 
668
687
  type HistoryMode = "history" | "hash";
669
- type RouterMode = "spa" | "mpa";
688
+ type RouterMode = "spa" | "static";
670
689
 
671
690
  interface RouterOptions {
672
- mode?: RouterMode; // default: "spa"
691
+ mode?: RouterMode; // "spa" | "static", default: "spa"
692
+ interceptLinks?: boolean; // default: true — only meaningful in spa mode
673
693
  }
674
694
 
675
695
  // Helper — returns fn as-is with LayoutHandler type enforced
@@ -932,20 +952,14 @@ pageRouter.hydrate(registry);
932
952
  pages({
933
953
  dir: "src/pages", // pages directory (default: "src/pages")
934
954
  generated: ".ilha/routes.ts", // generated file output (default: ".ilha/routes.ts")
935
- mode: "spa", // "spa" | "mpa" (default: "spa")
955
+ mode: "spa", // "spa" | "static" (default: "spa")
956
+ interceptLinks: true, // only meaningful in spa mode (default: true)
936
957
  });
937
958
  ```
938
959
 
939
- Use `mode: "mpa"` when you want filesystem-routed pages to behave like a multi-page app: the current page can still be SSR-rendered and hydrated, but in-app links are not intercepted by the router, so navigation is handled by the browser as a full document request.
940
-
941
- ```ts
942
- // vite.config.ts
943
- import { pages } from "@ilha/router/vite";
944
-
945
- export default defineConfig({
946
- plugins: [pages({ mode: "mpa" })],
947
- });
948
- ```
960
+ - **`mode: "spa"`** full client route graph, SSR/hydration, and client-side navigation.
961
+ - **`mode: "spa", interceptLinks: false`** — full route graph and SSR/hydration, but internal links perform full document navigations.
962
+ - **`mode: "static"`** — island registry only; no route graph bundled. Each pre-rendered page hydrates its own islands via `pageRouter.hydrateStatic(registry)`.
949
963
 
950
964
  The plugin regenerates the routes file only when content actually changes — avoiding unnecessary HMR invalidations. Structural changes (file add/remove, `+layout.ts`/`+error.ts` edits, or changes to loader exports) trigger full HMR reloads.
951
965
 
@@ -24,6 +24,8 @@ interface RouteRecord {
24
24
  island: Island<any, any>;
25
25
  /** Merged loader chain (layouts outer→inner, then page) — `undefined` if no loaders. */
26
26
  loader?: Loader<any>;
27
+ /** True when the route has a server-side loader, even if the client only has a marker. */
28
+ hasLoader?: boolean;
27
29
  }
28
30
  interface RouteSnapshot {
29
31
  path: string;
@@ -90,24 +92,43 @@ declare function defineLayout(layout: LayoutHandler): LayoutHandler;
90
92
  interface NavigateOptions {
91
93
  replace?: boolean;
92
94
  }
93
- type RouterMode = "spa" | "mpa";
95
+ type RouterMode = "spa" | "static";
94
96
  interface RouterOptions {
95
97
  /**
96
- * Client navigation mode. `spa` intercepts in-app links and renders routes
97
- * in-place. `mpa` leaves links to the browser, so each page navigation is a
98
- * full document request while still allowing hydration of the current route.
98
+ * Client navigation mode.
99
+ * - `spa` full route graph, SSR/hydration, client-side navigation.
100
+ * - `static` no route graph bundled; hydrate islands on the current
101
+ * pre-rendered page only.
99
102
  * Default: `spa`.
100
103
  */
101
104
  mode?: RouterMode;
105
+ /**
106
+ * When `true` (default), internal `<a>` clicks are intercepted and handled
107
+ * by the client router. Set to `false` for MPA-style behavior where links
108
+ * perform full document navigations.
109
+ * Only meaningful in `spa` mode; ignored in `static` mode.
110
+ * Default: `true`.
111
+ */
112
+ interceptLinks?: boolean;
102
113
  }
103
114
  interface HydratableRenderOptions extends Partial<Omit<HydratableOptions, "name">> {}
104
115
  interface HydrateOptions {
105
116
  root?: Element;
106
117
  target?: string | Element;
118
+ /**
119
+ * When `true` (default), internal `<a>` clicks are intercepted for
120
+ * client-side navigation. Set to `false` for MPA-style full-page navigations.
121
+ */
122
+ interceptLinks?: boolean;
107
123
  }
108
124
  interface MountOptions {
109
125
  hydrate?: boolean;
110
126
  registry?: Record<string, Island<any, any>>;
127
+ /**
128
+ * When `true` (default), internal `<a>` clicks are intercepted for
129
+ * client-side navigation. Set to `false` for MPA-style full-page navigations.
130
+ */
131
+ interceptLinks?: boolean;
111
132
  }
112
133
  /** Response envelope returned by `renderResponse` — lets the host app handle redirects. */
113
134
  type RenderResponse = {
@@ -138,6 +159,12 @@ interface RouterBuilder {
138
159
  * was never registered via `.route()`.
139
160
  */
140
161
  attachLoader(pattern: string, loader: Loader<any>): RouterBuilder;
162
+ /**
163
+ * Mark an already-registered route as having a server-side loader without
164
+ * importing that loader into the client bundle. Used by FS-routing codegen
165
+ * so SPA navigation knows to call the loader endpoint.
166
+ */
167
+ markLoader(pattern: string): RouterBuilder;
141
168
  /**
142
169
  * Return a snapshot of every registered route in match order. Useful for
143
170
  * prerenderers that need to discover the filesystem routes exposed by
@@ -181,6 +208,14 @@ interface RouterBuilder {
181
208
  * @returns Cleanup function
182
209
  */
183
210
  hydrate(registry: Record<string, Island<any, any>>, options?: HydrateOptions): () => void;
211
+ /**
212
+ * Hydrate islands on the current pre-rendered page without mounting a route
213
+ * view or enabling client navigation. Intended for `static` mode: each page
214
+ * is a self-contained HTML file; only interactive islands need activation.
215
+ */
216
+ hydrateStatic(registry: Record<string, Island<any, any>>, options?: {
217
+ root?: Element;
218
+ }): () => void;
184
219
  }
185
220
  /** Path of the loader endpoint served by the Vite plugin / production adapter. */
186
221
  declare const LOADER_ENDPOINT = "/__ilha/loader";
package/dist/index.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- import { A as loader, B as useRoute, C as RouterView, D as enableLinkInterception, E as defineLayout, F as routeHash, G as setHistoryMode, H as wrapLayout, I as routeParams, L as routePath, M as prefetch, N as prime, O as error, P as redirect, R as routeSearch, S as RouterOptions, T as composeLoaders, U as HistoryMode, V as wrapError, W as getHistoryMode, _ as RouteRecord, a as InferLoader, b as RouterLink, c as LinkInterceptionOptions, d as LoaderError, f as MergeLoaders, g as RenderResponse, h as Redirect, i as HydrateOptions, j as navigate, k as isActive, l as Loader, m as NavigateOptions, n as ErrorHandler, o as LOADER_ENDPOINT, p as MountOptions, r as HydratableRenderOptions, s as LayoutHandler, t as AppError, u as LoaderContext, v as RouteSnapshot, w as _default, x as RouterMode, y as RouterBuilder, z as router } from "./index-DSE6uoR1.js";
1
+ import { A as loader, B as useRoute, C as RouterView, D as enableLinkInterception, E as defineLayout, F as routeHash, G as setHistoryMode, H as wrapLayout, I as routeParams, L as routePath, M as prefetch, N as prime, O as error, P as redirect, R as routeSearch, S as RouterOptions, T as composeLoaders, U as HistoryMode, V as wrapError, W as getHistoryMode, _ as RouteRecord, a as InferLoader, b as RouterLink, c as LinkInterceptionOptions, d as LoaderError, f as MergeLoaders, g as RenderResponse, h as Redirect, i as HydrateOptions, j as navigate, k as isActive, l as Loader, m as NavigateOptions, n as ErrorHandler, o as LOADER_ENDPOINT, p as MountOptions, r as HydratableRenderOptions, s as LayoutHandler, t as AppError, u as LoaderContext, v as RouteSnapshot, w as _default, x as RouterMode, y as RouterBuilder, z as router } from "./index-DZA_1KrG.js";
2
2
  export { AppError, ErrorHandler, HistoryMode, HydratableRenderOptions, HydrateOptions, InferLoader, LOADER_ENDPOINT, LayoutHandler, LinkInterceptionOptions, Loader, LoaderContext, LoaderError, MergeLoaders, MountOptions, NavigateOptions, Redirect, RenderResponse, RouteRecord, RouteSnapshot, RouterBuilder, RouterLink, RouterMode, RouterOptions, RouterView, composeLoaders, _default as default, defineLayout, enableLinkInterception, error, getHistoryMode, isActive, loader, navigate, prefetch, prime, redirect, routeHash, routeParams, routePath, routeSearch, router, setHistoryMode, useRoute, wrapError, wrapLayout };
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
- import { C as wrapError, E as setHistoryMode, S as useRoute, T as getHistoryMode, _ as routeParams, a as RouterView, b as router, c as enableLinkInterception, d as loader, f as navigate, g as routeHash, h as redirect, i as RouterLink, l as error, m as prime, n as LoaderError, o as composeLoaders, p as prefetch, r as Redirect, s as defineLayout, t as LOADER_ENDPOINT, u as isActive, v as routePath, w as wrapLayout, x as src_default, y as routeSearch } from "./src-DX07qe2S.js";
1
+ import { C as wrapError, E as setHistoryMode, S as useRoute, T as getHistoryMode, _ as routeParams, a as RouterView, b as router, c as enableLinkInterception, d as loader, f as navigate, g as routeHash, h as redirect, i as RouterLink, l as error, m as prime, n as LoaderError, o as composeLoaders, p as prefetch, r as Redirect, s as defineLayout, t as LOADER_ENDPOINT, u as isActive, v as routePath, w as wrapLayout, x as src_default, y as routeSearch } from "./src-BP38YTMC.js";
2
2
  export { LOADER_ENDPOINT, LoaderError, Redirect, RouterLink, RouterView, composeLoaders, src_default as default, defineLayout, enableLinkInterception, error, getHistoryMode, isActive, loader, navigate, prefetch, prime, redirect, routeHash, routeParams, routePath, routeSearch, router, setHistoryMode, useRoute, wrapError, wrapLayout };
@@ -0,0 +1,28 @@
1
+ import * as _$unplugin from "unplugin";
2
+
3
+ //#region src/codegen.d.ts
4
+ type PagesMode = "spa" | "static";
5
+ //#endregion
6
+ //#region src/plugin.d.ts
7
+ interface IlhaPagesOptions {
8
+ /** Directory containing page files. Default: `src/pages` */
9
+ dir?: string;
10
+ /** Output path for the generated routes + registry file. Default: `.ilha/routes.ts` */
11
+ generated?: string;
12
+ /**
13
+ * File-system router navigation mode.
14
+ * - `spa` — full client route graph with SSR/hydration and client navigation.
15
+ * - `static` — island registry only; no route graph bundled into the client.
16
+ * Default: `spa`.
17
+ */
18
+ mode?: PagesMode;
19
+ /**
20
+ * When `false`, internal `<a>` clicks are not intercepted — browser performs
21
+ * full document navigations. Only meaningful in `spa` mode.
22
+ * Default: `true`.
23
+ */
24
+ interceptLinks?: boolean;
25
+ }
26
+ declare const ilhaPages: _$unplugin.UnpluginInstance<IlhaPagesOptions | undefined, boolean>;
27
+ //#endregion
28
+ export { ilhaPages as n, IlhaPagesOptions as t };
@@ -140,13 +140,15 @@ function validateEntries(entries, pagesDir) {
140
140
  }
141
141
  async function generate(pagesDir, outFile, options = {}) {
142
142
  const mode = options.mode ?? "spa";
143
+ const interceptLinks = options.interceptLinks;
144
+ const isStatic = mode === "static";
143
145
  const entries = sortEntries(await scanPages(pagesDir));
144
146
  validateEntries(entries, pagesDir);
145
147
  const rel = (abs) => {
146
148
  const r = toPosix(relative(dirname(outFile), abs));
147
149
  return r.startsWith(".") ? r : `./${r}`;
148
150
  };
149
- const imports = [`import { router, wrapLayout, wrapError } from "@ilha/router";`, `import type { Island } from "ilha";`];
151
+ const imports = isStatic ? [`import type { Island } from "ilha";`] : [`import { router, wrapLayout, wrapError } from "@ilha/router";`, `import type { Island } from "ilha";`];
150
152
  const wrappedIslandLines = [];
151
153
  const registryLines = [];
152
154
  const routeLines = [];
@@ -154,19 +156,26 @@ async function generate(pagesDir, outFile, options = {}) {
154
156
  for (const [i, entry] of entries.entries()) {
155
157
  const pageId = `_page${i}`;
156
158
  imports.push(`import { default as ${pageId} } from ${JSON.stringify(clientImport(entry.file))};`);
157
- for (const [j, l] of entry.layouts.entries()) imports.push(`import { default as _layout${i}_${j} } from ${JSON.stringify(clientImport(l))};`);
158
- for (const [j, e] of entry.errors.entries()) imports.push(`import { default as _error${i}_${j} } from ${JSON.stringify(clientImport(e))};`);
159
+ if (!isStatic) {
160
+ for (const [j, l] of entry.layouts.entries()) imports.push(`import { default as _layout${i}_${j} } from ${JSON.stringify(clientImport(l))};`);
161
+ for (const [j, e] of entry.errors.entries()) imports.push(`import { default as _error${i}_${j} } from ${JSON.stringify(clientImport(e))};`);
162
+ }
159
163
  let expr = pageId;
160
- for (let j = entry.errors.length - 1; j >= 0; j--) expr = `wrapError(_error${i}_${j}, ${expr})`;
161
- for (let j = entry.layouts.length - 1; j >= 0; j--) expr = `wrapLayout(_layout${i}_${j}, ${expr})`;
164
+ if (!isStatic) {
165
+ for (let j = entry.errors.length - 1; j >= 0; j--) expr = `wrapError(_error${i}_${j}, ${expr})`;
166
+ for (let j = entry.layouts.length - 1; j >= 0; j--) expr = `wrapLayout(_layout${i}_${j}, ${expr})`;
167
+ }
162
168
  const wrappedId = `_wrapped${i}`;
163
169
  wrappedIslandLines.push(`const ${wrappedId} = ${expr};`);
164
170
  registryLines.push(` ${JSON.stringify(entry.name)}: ${wrappedId}` + (i < entries.length - 1 ? "," : ""));
165
- routeLines.push(` .route(${JSON.stringify(entry.pattern)}, ${wrappedId})`);
171
+ if (!isStatic) routeLines.push(` .route(${JSON.stringify(entry.pattern)}, ${wrappedId})` + (entry.hasLoader || entry.loaderLayouts.length > 0 ? `.markLoader(${JSON.stringify(entry.pattern)})` : ""));
166
172
  }
167
- const code = [
173
+ const code = isStatic ? [
168
174
  `// @generated by @ilha/router — do not edit`,
175
+ `// static mode: no route graph, no client navigation.`,
176
+ `// Import registry to hydrate islands; use pageRouter.hydrateStatic(registry).`,
169
177
  ``,
178
+ `import { router as _router } from "@ilha/router";`,
170
179
  ...imports,
171
180
  ``,
172
181
  ...wrappedIslandLines,
@@ -175,12 +184,28 @@ async function generate(pagesDir, outFile, options = {}) {
175
184
  ...registryLines,
176
185
  `};`,
177
186
  ``,
178
- `export const pageRouter = router(${mode === "mpa" ? `{ mode: "mpa" }` : ""})`,
187
+ `export const pageRouter = _router({ mode: "static" });`
188
+ ].join("\n") : [
189
+ `// @generated by @ilha/router — do not edit`,
190
+ ``,
191
+ ...imports,
192
+ ``,
193
+ ...wrappedIslandLines,
194
+ ``,
195
+ `export const registry: Record<string, Island<any, any>> = {`,
196
+ ...registryLines,
197
+ `};`,
198
+ ``,
199
+ `export const pageRouter = router(${interceptLinks === false ? `{ interceptLinks: false }` : ""})`,
179
200
  ...routeLines,
180
201
  ` ;`
181
202
  ].join("\n");
182
203
  await mkdir(dirname(outFile), { recursive: true });
183
204
  const routesChanged = await writeIfChanged(outFile, code);
205
+ if (isStatic) {
206
+ if (routesChanged) await generateTypes(outFile);
207
+ return;
208
+ }
184
209
  const loadersFile = join(dirname(outFile), "loaders.ts");
185
210
  const loadersChanged = await writeIfChanged(loadersFile, buildLoadersFile(entries, loadersFile, outFile));
186
211
  if (routesChanged || loadersChanged) await generateTypes(outFile);
@@ -286,7 +311,10 @@ function createPagesPluginState(options) {
286
311
  };
287
312
  const regen = async () => {
288
313
  try {
289
- await generate(pagesDir, outFile, { mode: options.mode });
314
+ await generate(pagesDir, outFile, {
315
+ mode: options.mode,
316
+ interceptLinks: options.interceptLinks
317
+ });
290
318
  } catch (e) {
291
319
  console.error("[ilha:pages] codegen failed:", e);
292
320
  }
@@ -1,5 +1,5 @@
1
- import { H as wrapLayout, V as wrapError, n as ErrorHandler, s as LayoutHandler, t as AppError, v as RouteSnapshot } from "./index-DSE6uoR1.js";
2
- import { n as ilhaPages, t as IlhaPagesOptions } from "./plugin-Diq1AxSa.js";
1
+ import { H as wrapLayout, V as wrapError, n as ErrorHandler, s as LayoutHandler, t as AppError, v as RouteSnapshot } from "./index-DZA_1KrG.js";
2
+ import { n as ilhaPages, t as IlhaPagesOptions } from "./plugin-BizQQLsn.js";
3
3
  import * as _$unplugin from "unplugin";
4
4
 
5
5
  //#region src/rolldown.d.ts
package/dist/rolldown.js CHANGED
@@ -1,5 +1,5 @@
1
- import { C as wrapError, w as wrapLayout } from "./src-DX07qe2S.js";
2
- import { t as ilhaPages } from "./plugin-Bhbl9aqc.js";
1
+ import { C as wrapError, w as wrapLayout } from "./src-BP38YTMC.js";
2
+ import { t as ilhaPages } from "./plugin-C-TVH3XD.js";
3
3
  //#region src/rolldown.ts
4
4
  /** Rolldown plugin — use via `@ilha/router/rolldown`. */
5
5
  function pages(options = {}) {
package/dist/rspack.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { H as wrapLayout, V as wrapError, n as ErrorHandler, s as LayoutHandler, t as AppError, v as RouteSnapshot } from "./index-DSE6uoR1.js";
2
- import { n as ilhaPages, t as IlhaPagesOptions } from "./plugin-Diq1AxSa.js";
1
+ import { H as wrapLayout, V as wrapError, n as ErrorHandler, s as LayoutHandler, t as AppError, v as RouteSnapshot } from "./index-DZA_1KrG.js";
2
+ import { n as ilhaPages, t as IlhaPagesOptions } from "./plugin-BizQQLsn.js";
3
3
  import * as _$unplugin from "unplugin";
4
4
 
5
5
  //#region src/rspack.d.ts
package/dist/rspack.js CHANGED
@@ -1,5 +1,5 @@
1
- import { C as wrapError, w as wrapLayout } from "./src-DX07qe2S.js";
2
- import { t as ilhaPages } from "./plugin-Bhbl9aqc.js";
1
+ import { C as wrapError, w as wrapLayout } from "./src-BP38YTMC.js";
2
+ import { t as ilhaPages } from "./plugin-C-TVH3XD.js";
3
3
  //#region src/rspack.ts
4
4
  /** Rspack plugin — use via `@ilha/router/rspack`. */
5
5
  function pages(options = {}) {
@@ -349,7 +349,7 @@ function prefetch(pathWithSearch) {
349
349
  if (!isBrowser) return;
350
350
  if (prefetchCache.has(pathWithSearch)) return;
351
351
  const pathOnly = pathWithSearch.split("?")[0] ?? "";
352
- if (!findRoute(_rou3, "GET", pathOnly)?.data?.loader) return;
352
+ if (!findRoute(_rou3, "GET", pathOnly)?.data?.hasLoader) return;
353
353
  const promise = fetchLoaderData(pathWithSearch).catch((e) => {
354
354
  return {
355
355
  kind: "error",
@@ -369,7 +369,7 @@ async function mountRouteWithHydration(island, host, pathWithSearch, signal, reg
369
369
  host.innerHTML = `<div data-router-empty></div>`;
370
370
  return () => {};
371
371
  }
372
- const hasLoader = !!findRoute(_rou3, "GET", pathWithSearch.split("?")[0] ?? "")?.data?.loader;
372
+ const hasLoader = !!findRoute(_rou3, "GET", pathWithSearch.split("?")[0] ?? "")?.data?.hasLoader;
373
373
  let props = {};
374
374
  const loaderResult = hasLoader ? await fetchLoaderData(pathWithSearch, signal) : {
375
375
  kind: "data",
@@ -579,6 +579,7 @@ async function executeLoader(loader, url, params, request, signal) {
579
579
  }
580
580
  function router(options = {}) {
581
581
  const mode = options.mode ?? "spa";
582
+ const defaultInterceptLinks = options.interceptLinks !== false;
582
583
  _records = [];
583
584
  _rou3 = createRouter();
584
585
  _islandToPattern = /* @__PURE__ */ new Map();
@@ -587,14 +588,17 @@ function router(options = {}) {
587
588
  let _linkCleanup = null;
588
589
  const builder = {
589
590
  route(pattern, island, loader) {
591
+ const hasLoader = !!loader;
590
592
  const data = {
591
593
  island,
592
- loader
594
+ loader,
595
+ hasLoader
593
596
  };
594
597
  _records.push({
595
598
  pattern,
596
599
  island,
597
- loader
600
+ loader,
601
+ hasLoader
598
602
  });
599
603
  addRoute(_rou3, "GET", pattern, data);
600
604
  _patternToData.set(pattern, data);
@@ -608,15 +612,37 @@ function router(options = {}) {
608
612
  return builder;
609
613
  }
610
614
  data.loader = loader;
615
+ data.hasLoader = true;
611
616
  const rec = _records.find((r) => r.pattern === pattern);
612
- if (rec) rec.loader = loader;
617
+ if (rec) {
618
+ rec.loader = loader;
619
+ rec.hasLoader = true;
620
+ }
621
+ return builder;
622
+ },
623
+ markLoader(pattern) {
624
+ const data = _patternToData.get(pattern);
625
+ if (!data) {
626
+ console.warn(`[ilha-router] markLoader("${pattern}"): pattern was never registered via .route(). The loader marker will be ignored.`);
627
+ return builder;
628
+ }
629
+ data.hasLoader = true;
630
+ const rec = _records.find((r) => r.pattern === pattern);
631
+ if (rec) rec.hasLoader = true;
613
632
  return builder;
614
633
  },
615
634
  routes() {
616
635
  return _records.map((record) => ({ ...record }));
617
636
  },
618
637
  prime,
619
- mount(target, { hydrate = false, registry } = {}) {
638
+ hydrateStatic(registry, options = {}) {
639
+ if (!isBrowser) return () => {};
640
+ const root = options.root ?? document.body;
641
+ prime();
642
+ const { unmount } = mount(registry, { root });
643
+ return unmount;
644
+ },
645
+ mount(target, { hydrate = false, registry, interceptLinks: mountInterceptLinks } = {}) {
620
646
  if (!isBrowser) {
621
647
  console.warn("[ilha-router] mount() called in a non-browser environment");
622
648
  return () => {};
@@ -627,9 +653,13 @@ function router(options = {}) {
627
653
  return () => {};
628
654
  }
629
655
  syncRouteFromLocation();
656
+ if (mode === "static") {
657
+ console.warn("[ilha-router] router.mount() called in static mode. Use router.hydrateStatic(registry) instead.");
658
+ return () => {};
659
+ }
630
660
  const popHandler = () => syncRouteFromLocation();
631
661
  _navChangeCleanup = getAdapter().onChange(popHandler);
632
- _linkCleanup = mode === "spa" ? enableLinkInterception(document) : null;
662
+ _linkCleanup = mountInterceptLinks ?? defaultInterceptLinks ? enableLinkInterception(document) : null;
633
663
  let unmountView = null;
634
664
  let navAbort = null;
635
665
  if (hydrate) {
@@ -693,7 +723,7 @@ function router(options = {}) {
693
723
  const viewHost = host?.querySelector("[data-router-view]");
694
724
  if (!viewHost) return;
695
725
  const loc = getAdapter().readLocation();
696
- const result = !!findRoute(_rou3, "GET", loc.pathname)?.data?.loader ? await fetchLoaderData(loc.pathname + loc.search, signal) : {
726
+ const result = !!findRoute(_rou3, "GET", loc.pathname)?.data?.hasLoader ? await fetchLoaderData(loc.pathname + loc.search, signal) : {
697
727
  kind: "data",
698
728
  data: {}
699
729
  };
@@ -823,7 +853,8 @@ function router(options = {}) {
823
853
  const { unmount } = mount(registry, { root });
824
854
  const unmountRouter = this.mount(target, {
825
855
  hydrate: true,
826
- registry
856
+ registry,
857
+ interceptLinks: options.interceptLinks
827
858
  });
828
859
  return () => {
829
860
  unmount();
package/dist/vite.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /// <reference types="node" />
2
- import { H as wrapLayout, V as wrapError, n as ErrorHandler, s as LayoutHandler, t as AppError, v as RouteSnapshot } from "./index-DSE6uoR1.js";
3
- import { n as ilhaPages, t as IlhaPagesOptions } from "./plugin-Diq1AxSa.js";
2
+ import { H as wrapLayout, V as wrapError, n as ErrorHandler, s as LayoutHandler, t as AppError, v as RouteSnapshot } from "./index-DZA_1KrG.js";
3
+ import { n as ilhaPages, t as IlhaPagesOptions } from "./plugin-BizQQLsn.js";
4
4
  import * as fs from "node:fs";
5
5
  import * as http from "node:http";
6
6
  import { Agent, ClientRequest, ClientRequestArgs, OutgoingHttpHeaders } from "node:http";
package/dist/vite.js CHANGED
@@ -1,5 +1,5 @@
1
- import { C as wrapError, w as wrapLayout } from "./src-DX07qe2S.js";
2
- import { t as ilhaPages } from "./plugin-Bhbl9aqc.js";
1
+ import { C as wrapError, w as wrapLayout } from "./src-BP38YTMC.js";
2
+ import { t as ilhaPages } from "./plugin-C-TVH3XD.js";
3
3
  //#region src/vite.ts
4
4
  /** Vite plugin — use via `@ilha/router/vite`. */
5
5
  function pages(options = {}) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ilha/router",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "description": "A tiny SPA router for Ilha",
5
5
  "license": "MIT",
6
6
  "author": "Ryuz <ryuzer@proton.me>",
@@ -1,21 +0,0 @@
1
- import * as _$unplugin from "unplugin";
2
-
3
- //#region src/codegen.d.ts
4
- type PagesMode = "spa" | "mpa";
5
- //#endregion
6
- //#region src/plugin.d.ts
7
- interface IlhaPagesOptions {
8
- /** Directory containing page files. Default: `src/pages` */
9
- dir?: string;
10
- /** Output path for the generated routes + registry file. Default: `.ilha/routes.ts` */
11
- generated?: string;
12
- /**
13
- * File-system router navigation mode. `spa` intercepts in-app links and
14
- * renders routes client-side; `mpa` leaves links to the browser for full
15
- * document navigations. Default: `spa`.
16
- */
17
- mode?: PagesMode;
18
- }
19
- declare const ilhaPages: _$unplugin.UnpluginInstance<IlhaPagesOptions | undefined, boolean>;
20
- //#endregion
21
- export { ilhaPages as n, IlhaPagesOptions as t };