Skip to content

Commit fc9eee2

Browse files
authored
feat(router): add defineLayout helper (#10)
* feat(router): add defineLayout helper * chore(router): update readme (exports)
1 parent f91b9e4 commit fc9eee2

3 files changed

Lines changed: 118 additions & 1 deletion

File tree

packages/router/README.md

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,32 @@ const wrapped = wrapLayout(myLayout, myPage);
321321

322322
---
323323

324+
### `defineLayout(fn)`
325+
326+
A typed helper that returns the layout function as-is. Use it instead of the `satisfies LayoutHandler` cast for a cleaner, import-light syntax.
327+
328+
```ts
329+
// src/pages/+layout.ts
330+
import { defineLayout } from "@ilha/router";
331+
import ilha, { html } from "ilha";
332+
333+
export default defineLayout((children) =>
334+
ilha.render(
335+
() => html`
336+
<nav>
337+
<a href="/">Home</a>
338+
<a href="/about">About</a>
339+
</nav>
340+
<main>${children}</main>
341+
`,
342+
),
343+
);
344+
```
345+
346+
Equivalent to annotating with `satisfies LayoutHandler` but requires no explicit type import.
347+
348+
---
349+
324350
### `wrapError(handler, page)`
325351

326352
Wraps a page island with an error boundary. If the page throws during SSR (`.toString()`), the `handler` receives the error and current route snapshot and returns a fallback island. Also intercepts errors during `.mount()` for client-side resilience.
@@ -367,6 +393,9 @@ interface HydrateOptions {
367393
root?: Element;
368394
target?: string | Element;
369395
}
396+
397+
// Helper — returns fn as-is with LayoutHandler type enforced
398+
function defineLayout(fn: LayoutHandler): LayoutHandler;
370399
```
371400

372401
---
@@ -467,8 +496,28 @@ A `+layout.ts` wraps every page in its directory and all subdirectories. Layouts
467496

468497
```ts
469498
// src/pages/+layout.ts
470-
import { html } from "ilha";
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+
Alternatively, using the explicit type annotation:
516+
517+
```ts
518+
// src/pages/+layout.ts — using satisfies (equivalent)
471519
import type { LayoutHandler } from "@ilha/router/vite";
520+
import ilha, { html } from "ilha";
472521

473522
export default ((children) =>
474523
ilha.render(

packages/router/src/index.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
routePath,
1313
routeParams,
1414
routeSearch,
15+
defineLayout,
1516
} from "./index";
1617

1718
// ─────────────────────────────────────────────
@@ -948,3 +949,66 @@ describe("SSR full-page HTML template", () => {
948949
expect(routeParams()).toEqual({});
949950
});
950951
});
952+
953+
// ─────────────────────────────────────────────
954+
// defineLayout()
955+
// ─────────────────────────────────────────────
956+
957+
describe("defineLayout()", () => {
958+
it("returns the same function reference (identity)", () => {
959+
const layout = (children: typeof homePage) => ilha.render(() => children.toString());
960+
const result = defineLayout(layout);
961+
expect(result).toBe(layout);
962+
});
963+
964+
it("wraps page content when the returned layout is called", () => {
965+
const layout = defineLayout((children) =>
966+
ilha.render(() => `<layout>${children.toString()}</layout>`),
967+
);
968+
const page = ilha.render(() => `<p>content</p>`);
969+
const wrapped = layout(page);
970+
expect(wrapped.toString()).toContain("<layout>");
971+
expect(wrapped.toString()).toContain("<p>content</p>");
972+
});
973+
974+
it("returned island has .toString and .mount", () => {
975+
const layout = defineLayout((children) => ilha.render(() => children.toString()));
976+
const wrapped = layout(homePage);
977+
expect(typeof wrapped.toString).toBe("function");
978+
expect(typeof wrapped.mount).toBe("function");
979+
});
980+
981+
it("composes with wrapLayout — output is identical to satisfies LayoutHandler pattern", () => {
982+
// defineLayout should produce the same result as the manual satisfies cast
983+
const fn = (children: typeof homePage) =>
984+
ilha.render(() => `<shell>${children.toString()}</shell>`);
985+
986+
const viaDefine = defineLayout(fn);
987+
const page = ilha.render(() => `<p>page</p>`);
988+
989+
const wrappedViaDefine = viaDefine(page);
990+
const wrappedDirect = fn(page);
991+
992+
expect(wrappedViaDefine.toString()).toBe(wrappedDirect.toString());
993+
});
994+
995+
it("nested defineLayout calls compose inside-out", () => {
996+
const outer = defineLayout((children) =>
997+
ilha.render(() => `<outer>${children.toString()}</outer>`),
998+
);
999+
const inner = defineLayout((children) =>
1000+
ilha.render(() => `<inner>${children.toString()}</inner>`),
1001+
);
1002+
const page = ilha.render(() => `<p>page</p>`);
1003+
1004+
// outer wraps inner wraps page — outermost last in call chain
1005+
const wrapped = outer(inner(page));
1006+
const html = wrapped.toString();
1007+
1008+
expect(html).toContain("<outer>");
1009+
expect(html).toContain("<inner>");
1010+
expect(html).toContain("<p>page</p>");
1011+
expect(html.indexOf("<outer>")).toBeLessThan(html.indexOf("<inner>"));
1012+
expect(html.indexOf("<inner>")).toBeLessThan(html.indexOf("<p>page</p>"));
1013+
});
1014+
});

packages/router/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,10 @@ export function wrapError(handler: ErrorHandler, page: Island<any, any>): Island
8181
return wrapper;
8282
}
8383

84+
export function defineLayout(layout: LayoutHandler): LayoutHandler {
85+
return layout;
86+
}
87+
8488
export interface NavigateOptions {
8589
replace?: boolean;
8690
}

0 commit comments

Comments
 (0)