@ilha/router 0.1.1 → 0.2.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 +386 -37
- package/dist/index.d.ts +128 -17
- package/dist/index.js +350 -55
- package/dist/vite.js +125 -13
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# `@ilha/router`
|
|
2
2
|
|
|
3
|
-
A lightweight, isomorphic router for [Ilha](https://github.com/ilhajs/ilha) islands. Runs in the browser with full reactivity and on the server as a synchronous HTML string renderer. Pairs natively with
|
|
3
|
+
A lightweight, isomorphic router for [Ilha](https://github.com/ilhajs/ilha) islands. Runs in the browser with full reactivity and on the server as a synchronous HTML string renderer. Pairs natively with the file-system routing Vite plugin for zero-config page management.
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -85,10 +85,22 @@ Returns a `RouterBuilder`.
|
|
|
85
85
|
|
|
86
86
|
---
|
|
87
87
|
|
|
88
|
-
#### `.route(pattern, island)`
|
|
88
|
+
#### `.route(pattern, island, loader?)`
|
|
89
89
|
|
|
90
90
|
Registers a route. Patterns are matched in **declaration order** — first match wins. Uses [rou3](https://github.com/h3js/rou3) for matching, the same engine as Nitro.
|
|
91
91
|
|
|
92
|
+
The optional `loader` is a data-fetching function that runs before the page renders. Its return value is passed as input props to the island. On the client, loaders are fetched via the `/__ilha/loader` endpoint on navigation.
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
import { loader } from "@ilha/router";
|
|
96
|
+
|
|
97
|
+
const userLoader = loader(async ({ params }) => {
|
|
98
|
+
return { user: await fetchUser(params.id) };
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
router().route("/user/:id", userPage, userLoader).mount("#app");
|
|
102
|
+
```
|
|
103
|
+
|
|
92
104
|
| Pattern | Matches | `routeParams()` |
|
|
93
105
|
| --------------- | ------------------- | --------------------------------- |
|
|
94
106
|
| `/` | `/` | `{}` |
|
|
@@ -130,7 +142,7 @@ No-op with a console warning when called outside a browser environment.
|
|
|
130
142
|
|
|
131
143
|
#### `.render(url)` — server / SSR
|
|
132
144
|
|
|
133
|
-
Resolves the given URL against the route registry and returns a synchronous HTML string. Accepts a path string, full URL string, or `URL` object. Populates all route signals identically to the browser.
|
|
145
|
+
Resolves the given URL against the route registry and returns a synchronous HTML string. Accepts a path string, full URL string, or `URL` object. Populates all route signals identically to the browser.
|
|
134
146
|
|
|
135
147
|
```ts
|
|
136
148
|
const html = router().route("/", homePage).route("/**", notFound).render("/");
|
|
@@ -141,9 +153,9 @@ Renders `<div data-router-empty></div>` when no route matches.
|
|
|
141
153
|
|
|
142
154
|
---
|
|
143
155
|
|
|
144
|
-
#### `.renderHydratable(url, registry, options?)` — server / SSR
|
|
156
|
+
#### `.renderHydratable(url, registry, options?, request?)` — server / SSR
|
|
145
157
|
|
|
146
|
-
Async variant of `.render()` that outputs HTML with `data-ilha` hydration markers so the client can rehydrate without a full re-render.
|
|
158
|
+
Async variant of `.render()` that outputs HTML with `data-ilha` hydration markers so the client can rehydrate without a full re-render. If a loader is registered for the matched route, it runs first and its return value is serialized into `data-ilha-props`.
|
|
147
159
|
|
|
148
160
|
```ts
|
|
149
161
|
const html = await router().route("/", homePage).renderHydratable("/", registry);
|
|
@@ -160,6 +172,53 @@ If the active island is not found in the registry, falls back to plain SSR and e
|
|
|
160
172
|
|
|
161
173
|
---
|
|
162
174
|
|
|
175
|
+
#### `.renderResponse(url, registry, options?, request?)` — server / SSR
|
|
176
|
+
|
|
177
|
+
Structured-envelope variant of `.renderHydratable()`. Returns a `RenderResponse` discriminated union instead of a raw HTML string, so the host server can emit proper HTTP status codes for redirects and loader errors.
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
const res = await router()
|
|
181
|
+
.route("/protected", protectedPage, authLoader)
|
|
182
|
+
.renderResponse("/protected", registry);
|
|
183
|
+
|
|
184
|
+
if (res.kind === "redirect") {
|
|
185
|
+
return Response.redirect(res.to, res.status);
|
|
186
|
+
}
|
|
187
|
+
if (res.kind === "error") {
|
|
188
|
+
return new Response(res.html, { status: res.status });
|
|
189
|
+
}
|
|
190
|
+
return new Response(res.html, { headers: { "content-type": "text/html" } });
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
| `kind` | Fields | When |
|
|
194
|
+
| ------------ | --------------------------------------------------- | ------------------------------------------ |
|
|
195
|
+
| `"html"` | `html: string`, `status?: number` | Normal render; `status` is 404 if no match |
|
|
196
|
+
| `"redirect"` | `to: string`, `status: number` | Loader called `redirect()` |
|
|
197
|
+
| `"error"` | `status: number`, `message: string`, `html: string` | Loader called `error()` or threw |
|
|
198
|
+
|
|
199
|
+
---
|
|
200
|
+
|
|
201
|
+
#### `.runLoader(url, request?)` — server / SSR
|
|
202
|
+
|
|
203
|
+
Runs the loader chain for the matched route without rendering any HTML. Returns a discriminated union result. Used by the `/__ilha/loader` endpoint the Vite plugin exposes for client-side navigation.
|
|
204
|
+
|
|
205
|
+
```ts
|
|
206
|
+
const result = await router().route("/user/:id", userPage, userLoader).runLoader("/user/42");
|
|
207
|
+
|
|
208
|
+
if (result.kind === "data") {
|
|
209
|
+
console.log(result.data); // → { user: { id: "42" } }
|
|
210
|
+
}
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
| `kind` | Fields | When |
|
|
214
|
+
| ------------- | ----------------------------------- | -------------------------------- |
|
|
215
|
+
| `"data"` | `data: Record<string, unknown>` | Loader succeeded (or no loader) |
|
|
216
|
+
| `"redirect"` | `to: string`, `status: number` | Loader called `redirect()` |
|
|
217
|
+
| `"error"` | `status: number`, `message: string` | Loader called `error()` or threw |
|
|
218
|
+
| `"not-found"` | — | No route matched the URL |
|
|
219
|
+
|
|
220
|
+
---
|
|
221
|
+
|
|
163
222
|
#### `.prime()` — browser only
|
|
164
223
|
|
|
165
224
|
Primes route context signals from the current `window.location` **before** `ilha.mount()` runs. This prevents a signal mismatch that would destroy hydrated bindings.
|
|
@@ -180,7 +239,7 @@ pageRouter.mount("#app", { hydrate: true, registry });
|
|
|
180
239
|
|
|
181
240
|
#### `.hydrate(registry, options?)` — browser only
|
|
182
241
|
|
|
183
|
-
Convenience method that combines `.prime()`, `ilha.mount()`, and `.mount()` into a single call.
|
|
242
|
+
Convenience method that combines `.prime()`, `ilha.mount()`, and `.mount()` into a single call. **This is the recommended client entry point.**
|
|
184
243
|
|
|
185
244
|
```ts
|
|
186
245
|
pageRouter.hydrate(registry);
|
|
@@ -196,6 +255,16 @@ Returns an `unmount` function that tears down all listeners and hydrated islands
|
|
|
196
255
|
|
|
197
256
|
---
|
|
198
257
|
|
|
258
|
+
#### `.attachLoader(pattern, loader)` — runtime
|
|
259
|
+
|
|
260
|
+
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.
|
|
261
|
+
|
|
262
|
+
```ts
|
|
263
|
+
router().route("/user/:id", userPage).attachLoader("/user/:id", serverLoader);
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
---
|
|
267
|
+
|
|
199
268
|
### `navigate(to, options?)`
|
|
200
269
|
|
|
201
270
|
Programmatically navigate to a path. Updates the URL, history stack, and all reactive signals. Duplicate navigations (same URL) are no-ops.
|
|
@@ -223,6 +292,100 @@ prime();
|
|
|
223
292
|
|
|
224
293
|
---
|
|
225
294
|
|
|
295
|
+
### `loader(fn)`
|
|
296
|
+
|
|
297
|
+
Identity function for declaring a typed data loader. Exists as a type anchor and as a marker the Vite plugin uses to detect exported loaders automatically. The loader receives a `LoaderContext` and must return or resolve to a plain object (serializable to JSON for client-side fetches).
|
|
298
|
+
|
|
299
|
+
```ts
|
|
300
|
+
import { loader } from "@ilha/router";
|
|
301
|
+
|
|
302
|
+
export const load = loader(async ({ params, request, url, signal }) => {
|
|
303
|
+
const user = await fetchUser(params.id, { signal });
|
|
304
|
+
return { user };
|
|
305
|
+
});
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
Inside a loader, call `redirect()` or `error()` to short-circuit rendering:
|
|
309
|
+
|
|
310
|
+
```ts
|
|
311
|
+
import { loader, redirect, error } from "@ilha/router";
|
|
312
|
+
|
|
313
|
+
export const load = loader(async ({ params }) => {
|
|
314
|
+
const session = await getSession();
|
|
315
|
+
if (!session) redirect("/login", 302);
|
|
316
|
+
const post = await getPost(params.id);
|
|
317
|
+
if (!post) error(404, "Post not found");
|
|
318
|
+
return { post };
|
|
319
|
+
});
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
Returns `fn` unchanged.
|
|
323
|
+
|
|
324
|
+
---
|
|
325
|
+
|
|
326
|
+
### `redirect(to, status?)`
|
|
327
|
+
|
|
328
|
+
Throws a `Redirect` sentinel that is caught by the loader execution pipeline. Always use inside a loader — do not catch it yourself.
|
|
329
|
+
|
|
330
|
+
```ts
|
|
331
|
+
import { redirect } from "@ilha/router";
|
|
332
|
+
|
|
333
|
+
redirect("/login"); // 302 by default
|
|
334
|
+
redirect("/moved", 301); // permanent redirect
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
---
|
|
338
|
+
|
|
339
|
+
### `error(status, message)`
|
|
340
|
+
|
|
341
|
+
Throws a `LoaderError` sentinel that is caught by the loader execution pipeline. The rendered output will be an inline error element; use `.renderResponse()` on the server to intercept loader errors before they reach the client.
|
|
342
|
+
|
|
343
|
+
```ts
|
|
344
|
+
import { error } from "@ilha/router";
|
|
345
|
+
|
|
346
|
+
error(404, "Not found");
|
|
347
|
+
error(403, "Forbidden");
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
---
|
|
351
|
+
|
|
352
|
+
### `composeLoaders(loaders)`
|
|
353
|
+
|
|
354
|
+
Merges multiple loaders into a single loader. All loaders run **concurrently** via `Promise.all`. Later loaders win on key collision — the page loader overrides a layout loader for the same key.
|
|
355
|
+
|
|
356
|
+
Used internally by the Vite plugin to compose layout loaders with the page loader. Also available for manual composition.
|
|
357
|
+
|
|
358
|
+
```ts
|
|
359
|
+
import { composeLoaders, loader } from "@ilha/router";
|
|
360
|
+
|
|
361
|
+
const layoutLoader = loader(async () => ({ user: await getCurrentUser() }));
|
|
362
|
+
const pageLoader = loader(async ({ params }) => ({ post: await getPost(params.id) }));
|
|
363
|
+
|
|
364
|
+
const combined = composeLoaders([layoutLoader, pageLoader]);
|
|
365
|
+
// → { user: …, post: … }
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
If any loader in the chain throws a `Redirect` or `LoaderError`, the composed loader re-throws it immediately.
|
|
369
|
+
|
|
370
|
+
---
|
|
371
|
+
|
|
372
|
+
### `prefetch(pathWithSearch)`
|
|
373
|
+
|
|
374
|
+
Prefetches the loader data for a given path by calling the `/__ilha/loader` endpoint in the background. The result is cached and consumed on the next navigation to that path, making the transition feel instant. Safe to call repeatedly — an in-flight request for the same path is reused until it resolves and is consumed, avoiding duplicate network requests.
|
|
375
|
+
|
|
376
|
+
```ts
|
|
377
|
+
import { prefetch } from "@ilha/router";
|
|
378
|
+
|
|
379
|
+
prefetch("/user/42");
|
|
380
|
+
prefetch("/dashboard?tab=overview");
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
No-op on the server, for paths with no registered loader, or for unmatched paths.
|
|
384
|
+
|
|
385
|
+
`RouterLink` automatically calls `prefetch()` on `mouseenter` for links that carry the `data-prefetch` attribute (set by default). You can opt a specific link out with `data-prefetch="false"`.
|
|
386
|
+
|
|
387
|
+
---
|
|
388
|
+
|
|
226
389
|
### `useRoute()`
|
|
227
390
|
|
|
228
391
|
Returns reactive signal accessors for the current route state. Safe to call inside any island render function on both client and server.
|
|
@@ -266,19 +429,25 @@ isActive("/user/:id"); // → true when on any /user/* path
|
|
|
266
429
|
|
|
267
430
|
---
|
|
268
431
|
|
|
269
|
-
### `enableLinkInterception(root?)`
|
|
432
|
+
### `enableLinkInterception(root?, options?)`
|
|
270
433
|
|
|
271
434
|
Attaches a delegated click listener to `root` (defaults to `document`) that intercepts `<a>` clicks and routes them client-side. Called automatically by `.mount()`.
|
|
272
435
|
|
|
273
|
-
Skips links that are external, `target="_blank"`, anchor-only (`#hash`),
|
|
436
|
+
Skips links that are external, `target="_blank"`, anchor-only (`#hash`), modified (`Ctrl`/`Meta`/`Shift`), or marked with `data-no-intercept`. Also skips events already handled (`e.defaultPrevented`).
|
|
274
437
|
|
|
275
438
|
Returns a cleanup function.
|
|
276
439
|
|
|
277
440
|
```ts
|
|
278
|
-
const stop = enableLinkInterception(myContainer);
|
|
441
|
+
const stop = enableLinkInterception(myContainer, { prefetch: true });
|
|
279
442
|
stop(); // remove listener
|
|
280
443
|
```
|
|
281
444
|
|
|
445
|
+
**Options:**
|
|
446
|
+
|
|
447
|
+
| Option | Type | Default | Description |
|
|
448
|
+
| ---------- | --------- | ------- | ------------------------------------- |
|
|
449
|
+
| `prefetch` | `boolean` | `true` | Enable prefetch on `mouseenter` hover |
|
|
450
|
+
|
|
282
451
|
No-op on the server.
|
|
283
452
|
|
|
284
453
|
---
|
|
@@ -298,13 +467,13 @@ RouterView.mount(el); // client
|
|
|
298
467
|
|
|
299
468
|
### `RouterLink`
|
|
300
469
|
|
|
301
|
-
A declarative link island that calls `navigate()` on click.
|
|
470
|
+
A declarative link island that calls `navigate()` on click. Automatically prefetches loader data for the target path on `mouseenter` (opt out per-link with `data-prefetch="false"`).
|
|
302
471
|
|
|
303
472
|
```ts
|
|
304
473
|
import { RouterLink } from "@ilha/router";
|
|
305
474
|
|
|
306
475
|
RouterLink.toString({ href: "/about", label: "About" });
|
|
307
|
-
// → '<a data-link href="/about">About</a>'
|
|
476
|
+
// → '<a data-link data-prefetch href="/about">About</a>'
|
|
308
477
|
```
|
|
309
478
|
|
|
310
479
|
---
|
|
@@ -321,9 +490,35 @@ const wrapped = wrapLayout(myLayout, myPage);
|
|
|
321
490
|
|
|
322
491
|
---
|
|
323
492
|
|
|
493
|
+
### `defineLayout(fn)`
|
|
494
|
+
|
|
495
|
+
A typed helper that returns the layout function as-is. Use it instead of the `satisfies LayoutHandler` cast for a cleaner, import-light syntax.
|
|
496
|
+
|
|
497
|
+
```ts
|
|
498
|
+
// src/pages/+layout.ts
|
|
499
|
+
import { defineLayout } from "@ilha/router";
|
|
500
|
+
import ilha, { html } from "ilha";
|
|
501
|
+
|
|
502
|
+
export default defineLayout((children) =>
|
|
503
|
+
ilha.render(
|
|
504
|
+
() => html`
|
|
505
|
+
<nav>
|
|
506
|
+
<a href="/">Home</a>
|
|
507
|
+
<a href="/about">About</a>
|
|
508
|
+
</nav>
|
|
509
|
+
<main>${children}</main>
|
|
510
|
+
`,
|
|
511
|
+
),
|
|
512
|
+
);
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
Equivalent to annotating with `satisfies LayoutHandler` but requires no explicit type import.
|
|
516
|
+
|
|
517
|
+
---
|
|
518
|
+
|
|
324
519
|
### `wrapError(handler, page)`
|
|
325
520
|
|
|
326
|
-
Wraps a page island with an error boundary. If the page throws during SSR (`.toString()`)
|
|
521
|
+
Wraps a page island with an error boundary. If the page throws during SSR (`.toString()`) or on the client during `.mount()`, the `handler` receives the error and current route snapshot and returns a fallback island. The nearest (innermost) `wrapError` boundary catches first. If the inner handler re-throws, the next outer boundary takes over.
|
|
327
522
|
|
|
328
523
|
```ts
|
|
329
524
|
import { wrapError } from "@ilha/router";
|
|
@@ -331,7 +526,7 @@ import { wrapError } from "@ilha/router";
|
|
|
331
526
|
const safe = wrapError(myErrorHandler, myPage);
|
|
332
527
|
```
|
|
333
528
|
|
|
334
|
-
|
|
529
|
+
> **Note:** Error boundaries wrap the _page island's render_, not the loader. Loader errors (thrown via `error()`) are surfaced through `.renderResponse()` — they do not currently route through `+error.ts` boundaries. Use `.renderResponse()` to handle loader errors at the HTTP layer.
|
|
335
530
|
|
|
336
531
|
---
|
|
337
532
|
|
|
@@ -351,9 +546,29 @@ interface AppError {
|
|
|
351
546
|
stack?: string;
|
|
352
547
|
}
|
|
353
548
|
|
|
549
|
+
interface LoaderContext {
|
|
550
|
+
params: Record<string, string>;
|
|
551
|
+
request: Request;
|
|
552
|
+
url: URL;
|
|
553
|
+
signal: AbortSignal;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
type Loader<T> = (ctx: LoaderContext) => Promise<T> | T;
|
|
557
|
+
|
|
558
|
+
// Extract the return type of a loader
|
|
559
|
+
type InferLoader<L> = L extends Loader<infer T> ? Awaited<T> : never;
|
|
560
|
+
|
|
561
|
+
// Merge multiple loader return types — later loaders win on key collision
|
|
562
|
+
type MergeLoaders<Ls extends readonly Loader<any>[]> = /* … */;
|
|
563
|
+
|
|
354
564
|
type LayoutHandler = (children: Island) => Island;
|
|
355
565
|
type ErrorHandler = (error: AppError, route: RouteSnapshot) => Island;
|
|
356
566
|
|
|
567
|
+
type RenderResponse =
|
|
568
|
+
| { kind: "html"; html: string; status?: number }
|
|
569
|
+
| { kind: "redirect"; to: string; status: number }
|
|
570
|
+
| { kind: "error"; status: number; message: string; html: string };
|
|
571
|
+
|
|
357
572
|
interface NavigateOptions {
|
|
358
573
|
replace?: boolean;
|
|
359
574
|
}
|
|
@@ -367,6 +582,21 @@ interface HydrateOptions {
|
|
|
367
582
|
root?: Element;
|
|
368
583
|
target?: string | Element;
|
|
369
584
|
}
|
|
585
|
+
|
|
586
|
+
// Helper — returns fn as-is with LayoutHandler type enforced
|
|
587
|
+
function defineLayout(fn: LayoutHandler): LayoutHandler;
|
|
588
|
+
|
|
589
|
+
// Identity — type anchor and Vite plugin marker
|
|
590
|
+
function loader<T>(fn: Loader<T>): Loader<T>;
|
|
591
|
+
|
|
592
|
+
// Throws a Redirect sentinel — use inside loaders only
|
|
593
|
+
function redirect(to: string, status?: number): never;
|
|
594
|
+
|
|
595
|
+
// Throws a LoaderError sentinel — use inside loaders only
|
|
596
|
+
function error(status: number, message: string): never;
|
|
597
|
+
|
|
598
|
+
// Merges loaders — later loaders win on key collision
|
|
599
|
+
function composeLoaders<Ls extends readonly Loader<any>[]>(loaders: Ls): Loader<MergeLoaders<Ls>>;
|
|
370
600
|
```
|
|
371
601
|
|
|
372
602
|
---
|
|
@@ -392,32 +622,65 @@ Add `.ilha/` (or your custom `generated` path) to `.gitignore`.
|
|
|
392
622
|
|
|
393
623
|
```
|
|
394
624
|
src/pages/
|
|
395
|
-
+layout.ts
|
|
396
|
-
+error.ts
|
|
397
|
-
index.ts
|
|
398
|
-
about.ts
|
|
625
|
+
+layout.ts ← root layout (wraps all pages)
|
|
626
|
+
+error.ts ← root error boundary
|
|
627
|
+
index.ts → /
|
|
628
|
+
about.ts → /about
|
|
629
|
+
(auth)/ ← route group — transparent to the URL
|
|
630
|
+
+layout.ts ← layout scoped to (auth) pages only
|
|
631
|
+
sign-in.ts → /sign-in
|
|
632
|
+
sign-up.ts → /sign-up
|
|
633
|
+
(marketing)/ ← another route group
|
|
634
|
+
index.ts → /
|
|
399
635
|
user/
|
|
400
|
-
+layout.ts
|
|
401
|
-
+error.ts
|
|
402
|
-
[id].ts
|
|
636
|
+
+layout.ts ← nested layout (wraps user/* only)
|
|
637
|
+
+error.ts ← nested error boundary
|
|
638
|
+
[id].ts → /user/:id
|
|
403
639
|
[id]/
|
|
404
|
-
settings.ts
|
|
405
|
-
[...slug].ts
|
|
640
|
+
settings.ts → /user/:id/settings
|
|
641
|
+
[...slug].ts → /**:slug
|
|
406
642
|
```
|
|
407
643
|
|
|
408
644
|
### Filename → pattern mapping
|
|
409
645
|
|
|
410
|
-
| File
|
|
411
|
-
|
|
|
412
|
-
| `index.ts`
|
|
413
|
-
| `about.ts`
|
|
414
|
-
| `[id].ts`
|
|
415
|
-
| `user/[id].ts`
|
|
416
|
-
| `[org]/[repo].ts`
|
|
417
|
-
| `[...slug].ts`
|
|
646
|
+
| File | Pattern |
|
|
647
|
+
| ------------------------- | --------------- |
|
|
648
|
+
| `index.ts` | `/` |
|
|
649
|
+
| `about.ts` | `/about` |
|
|
650
|
+
| `[id].ts` | `/:id` |
|
|
651
|
+
| `user/[id].ts` | `/user/:id` |
|
|
652
|
+
| `[org]/[repo].ts` | `/:org/:repo` |
|
|
653
|
+
| `[...slug].ts` | `/**:slug` |
|
|
654
|
+
| `(auth)/sign-in.ts` | `/sign-in` |
|
|
655
|
+
| `(auth)/[token].ts` | `/:token` |
|
|
656
|
+
| `(shop)/products/[id].ts` | `/products/:id` |
|
|
418
657
|
|
|
419
658
|
`.test.ts`, `.spec.ts`, and `.d.ts` files are automatically excluded.
|
|
420
659
|
|
|
660
|
+
### Route groups
|
|
661
|
+
|
|
662
|
+
Folders wrapped in parentheses — `(name)` — are **route groups**. They organise files without contributing a segment to the URL. The group name is completely invisible to the router.
|
|
663
|
+
|
|
664
|
+
```
|
|
665
|
+
src/pages/
|
|
666
|
+
(auth)/
|
|
667
|
+
sign-in.ts → /sign-in ✓ (not /auth/sign-in)
|
|
668
|
+
sign-up.ts → /sign-up ✓
|
|
669
|
+
(marketing)/
|
|
670
|
+
index.ts → / ✓
|
|
671
|
+
pricing.ts → /pricing ✓
|
|
672
|
+
```
|
|
673
|
+
|
|
674
|
+
Route groups are useful for:
|
|
675
|
+
|
|
676
|
+
- **Shared layouts without a shared URL prefix** — place a `+layout.ts` inside `(auth)/` and it wraps only those pages, with no `/auth` prefix in the URL.
|
|
677
|
+
- **Organising large page trees** — split pages into logical sections (`(admin)`, `(public)`, `(shop)`) while keeping flat URLs.
|
|
678
|
+
- **Co-locating related pages** — keep sign-in, sign-up, and password reset together in `(auth)/` for clarity.
|
|
679
|
+
|
|
680
|
+
> Groups can be nested: `(a)/(b)/page.ts` → `/page`. Both group folders are transparent.
|
|
681
|
+
|
|
682
|
+
> If two files in different groups resolve to the **same pattern** (e.g. `(auth)/sign-in.ts` and `sign-in.ts` both produce `/sign-in`), the plugin warns about a duplicate pattern and the first match wins deterministically.
|
|
683
|
+
|
|
421
684
|
### Route sorting
|
|
422
685
|
|
|
423
686
|
Routes are sorted automatically by specificity — no need to order files manually:
|
|
@@ -426,7 +689,7 @@ Routes are sorted automatically by specificity — no need to order files manual
|
|
|
426
689
|
2. **Parameterised** paths (`/user/:id`)
|
|
427
690
|
3. **Wildcard** paths (`/**:slug`) — lowest priority
|
|
428
691
|
|
|
429
|
-
Within the same tier, longer segment counts and alphabetical order act as tiebreakers for determinism.
|
|
692
|
+
Within the same tier, longer segment counts and alphabetical order act as tiebreakers for determinism. Route group pages sort alongside regular pages by their resolved pattern — the group folder is transparent.
|
|
430
693
|
|
|
431
694
|
### Layouts
|
|
432
695
|
|
|
@@ -434,8 +697,28 @@ A `+layout.ts` wraps every page in its directory and all subdirectories. Layouts
|
|
|
434
697
|
|
|
435
698
|
```ts
|
|
436
699
|
// src/pages/+layout.ts
|
|
437
|
-
import {
|
|
700
|
+
import { defineLayout } from "@ilha/router";
|
|
701
|
+
import ilha, { html } from "ilha";
|
|
702
|
+
|
|
703
|
+
export default defineLayout((children) =>
|
|
704
|
+
ilha.render(
|
|
705
|
+
() => html`
|
|
706
|
+
<nav>
|
|
707
|
+
<a href="/">Home</a>
|
|
708
|
+
<a href="/about">About</a>
|
|
709
|
+
</nav>
|
|
710
|
+
<main>${children}</main>
|
|
711
|
+
`,
|
|
712
|
+
),
|
|
713
|
+
);
|
|
714
|
+
```
|
|
715
|
+
|
|
716
|
+
Alternatively, using the explicit type annotation:
|
|
717
|
+
|
|
718
|
+
```ts
|
|
719
|
+
// src/pages/+layout.ts — using satisfies (equivalent)
|
|
438
720
|
import type { LayoutHandler } from "@ilha/router/vite";
|
|
721
|
+
import ilha, { html } from "ilha";
|
|
439
722
|
|
|
440
723
|
export default ((children) =>
|
|
441
724
|
ilha.render(
|
|
@@ -449,13 +732,62 @@ export default ((children) =>
|
|
|
449
732
|
)) satisfies LayoutHandler;
|
|
450
733
|
```
|
|
451
734
|
|
|
735
|
+
A `+layout.ts` inside a route group folder works exactly like a regular nested layout — it wraps only the pages inside that group, without affecting pages elsewhere.
|
|
736
|
+
|
|
737
|
+
```
|
|
738
|
+
src/pages/
|
|
739
|
+
+layout.ts ← wraps ALL pages (including those in groups)
|
|
740
|
+
(auth)/
|
|
741
|
+
+layout.ts ← wraps (auth) pages only: /sign-in, /sign-up
|
|
742
|
+
sign-in.ts
|
|
743
|
+
sign-up.ts
|
|
744
|
+
about.ts ← wrapped by root layout only
|
|
745
|
+
```
|
|
746
|
+
|
|
747
|
+
### Page loaders
|
|
748
|
+
|
|
749
|
+
A page file can export a `load` function declared with the `loader()` helper. The Vite plugin automatically detects the named `load` export, composes it with any layout loaders in the chain (outermost first, then page), and wires them into the router via `.attachLoader()` at SSR time.
|
|
750
|
+
|
|
751
|
+
```ts
|
|
752
|
+
// src/pages/user/[id].ts
|
|
753
|
+
import { loader } from "@ilha/router";
|
|
754
|
+
import ilha from "ilha";
|
|
755
|
+
|
|
756
|
+
export const load = loader(async ({ params }) => {
|
|
757
|
+
const user = await fetchUser(params.id);
|
|
758
|
+
return { user };
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
export default ilha.input<{ user: User }>().render((input) => `<h1>${input.user.name}</h1>`);
|
|
762
|
+
```
|
|
763
|
+
|
|
764
|
+
The `load` export must be declared with the `loader()` helper so the Vite plugin can identify it via export name.
|
|
765
|
+
|
|
766
|
+
### Layout loaders
|
|
767
|
+
|
|
768
|
+
A `+layout.ts` can also export a loader. Layout loaders run concurrently with the page loader. The page loader wins on key collision.
|
|
769
|
+
|
|
770
|
+
```ts
|
|
771
|
+
// src/pages/+layout.ts
|
|
772
|
+
import { defineLayout, loader } from "@ilha/router";
|
|
773
|
+
|
|
774
|
+
export const load = loader(async () => {
|
|
775
|
+
return { currentUser: await getCurrentUser() };
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
export default defineLayout((children) => /* … */);
|
|
779
|
+
```
|
|
780
|
+
|
|
781
|
+
Layout loaders are composed automatically — you do not need to call `composeLoaders()` manually.
|
|
782
|
+
|
|
452
783
|
### Error boundaries
|
|
453
784
|
|
|
454
|
-
A `+error.ts` catches any error thrown during rendering of pages in its directory and all subdirectories. The nearest boundary wins. If an inner boundary re-throws, the next outer boundary takes over.
|
|
785
|
+
A `+error.ts` catches any error thrown during rendering of pages in its directory and all subdirectories. The nearest boundary wins. If an inner boundary re-throws, the next outer boundary takes over.
|
|
455
786
|
|
|
456
787
|
```ts
|
|
457
788
|
// src/pages/+error.ts
|
|
458
789
|
import type { ErrorHandler } from "@ilha/router/vite";
|
|
790
|
+
import ilha from "ilha";
|
|
459
791
|
|
|
460
792
|
export default ((error, route) =>
|
|
461
793
|
ilha.render(
|
|
@@ -471,17 +803,19 @@ export default ((error, route) =>
|
|
|
471
803
|
|
|
472
804
|
### Virtual modules
|
|
473
805
|
|
|
474
|
-
The plugin exposes
|
|
806
|
+
The plugin exposes three virtual modules:
|
|
475
807
|
|
|
476
808
|
| Module | Export | Description |
|
|
477
809
|
| --------------- | ------------ | -------------------------------------------- |
|
|
478
810
|
| `ilha:pages` | `pageRouter` | A `RouterBuilder` with all routes registered |
|
|
479
811
|
| `ilha:registry` | `registry` | `Record<string, Island>` for hydration |
|
|
812
|
+
| `ilha:loaders` | — | Side-effect import that wires server loaders |
|
|
480
813
|
|
|
481
814
|
```ts
|
|
482
815
|
// routes/[...].ts — Nitro catch-all handler
|
|
483
816
|
import { pageRouter } from "ilha:pages";
|
|
484
817
|
import { registry } from "ilha:registry";
|
|
818
|
+
import "ilha:loaders"; // ← wire server loaders
|
|
485
819
|
|
|
486
820
|
export default defineEventHandler(async (event) => {
|
|
487
821
|
const html = await pageRouter.renderHydratable(event.node.req.url ?? "/", registry);
|
|
@@ -508,13 +842,13 @@ pages({
|
|
|
508
842
|
});
|
|
509
843
|
```
|
|
510
844
|
|
|
511
|
-
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
|
|
845
|
+
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.
|
|
512
846
|
|
|
513
847
|
---
|
|
514
848
|
|
|
515
849
|
## SSR + Hydration
|
|
516
850
|
|
|
517
|
-
The same route config runs on both sides. Signals (`routePath`, `routeParams`, etc.) are populated identically by `.render()`/`.renderHydratable()` on the server and `.mount()`/`.hydrate()` on the client
|
|
851
|
+
The same route config runs on both sides. Signals (`routePath`, `routeParams`, etc.) are populated identically by `.render()`/`.renderHydratable()` on the server and `.mount()`/`.hydrate()` on the client:
|
|
518
852
|
|
|
519
853
|
```ts
|
|
520
854
|
// server: resolves URL → hydratable HTML string
|
|
@@ -531,7 +865,7 @@ routeParams(); // → { id: "99" }
|
|
|
531
865
|
|
|
532
866
|
```
|
|
533
867
|
server client
|
|
534
|
-
──────────────────────────────
|
|
868
|
+
────────────────────────────── ───────────────────────────────────
|
|
535
869
|
renderHydratable(url, registry) pageRouter.prime() ← sync signals first
|
|
536
870
|
→ data-ilha="…" markers mount(registry, { root }) ← hydrate islands
|
|
537
871
|
→ data-ilha-state snapshot pageRouter.mount(target, ← setup navigation
|
|
@@ -540,6 +874,21 @@ renderHydratable(url, registry) pageRouter.prime() ← sync signals firs
|
|
|
540
874
|
|
|
541
875
|
Or use the one-liner: `pageRouter.hydrate(registry)`.
|
|
542
876
|
|
|
877
|
+
### Loader data flow
|
|
878
|
+
|
|
879
|
+
On the **server**, loaders run inside `.renderHydratable()` / `.renderResponse()`. Their return value is serialized into `data-ilha-props` on the island element so the client can rehydrate without re-fetching.
|
|
880
|
+
|
|
881
|
+
On the **client**, navigations fetch loader data from the `/__ilha/loader` endpoint before mounting the next island. The endpoint is served automatically by the Vite plugin (dev) and the Nitro adapter (production).
|
|
882
|
+
|
|
883
|
+
```
|
|
884
|
+
server client (navigation)
|
|
885
|
+
──────────────────────────── ─────────────────────────────────────
|
|
886
|
+
renderHydratable GET /__ilha/loader?path=/user/42
|
|
887
|
+
→ executeLoader(…) → runLoader("/user/42")
|
|
888
|
+
→ island.hydratable(props) → fetchLoaderData("/user/42")
|
|
889
|
+
→ data-ilha-props="{…}" → mountRouteWithHydration(island, host, …)
|
|
890
|
+
```
|
|
891
|
+
|
|
543
892
|
---
|
|
544
893
|
|
|
545
894
|
## License
|