ilha 0.0.1 → 0.1.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.
Files changed (4) hide show
  1. package/README.md +387 -176
  2. package/dist/index.d.ts +79 -32
  3. package/dist/index.js +375 -205
  4. package/package.json +4 -20
package/README.md CHANGED
@@ -1,299 +1,510 @@
1
- # ilha
1
+ # `ilha`
2
2
 
3
- A tiny, framework-free island architecture library. Define interactive islands with typed props, reactive state, async-derived data, and slots — render them as plain HTML strings on the server, mount them on the client.
3
+ A tiny, isomorphic island framework for building reactive UI components. Runs in the browser with fine-grained signal reactivity and on the server as a synchronous HTML string renderer. Powered by [alien-signals](https://github.com/stackblitz/alien-signals) — zero virtual DOM, no compiler required.
4
4
 
5
- Built on [alien-signals](https://github.com/stackblitz/alien-signals) for fine-grained reactivity. Supports any [Standard Schema](https://standardschema.dev) validator (Zod, Valibot, ArkType, …).
5
+ ---
6
6
 
7
- ## Install
7
+ ## Installation
8
8
 
9
9
  ```bash
10
- bun add ilha
11
- # or
12
10
  npm install ilha
11
+ # or Bun
12
+ bun add ilha
13
13
  ```
14
14
 
15
- ## Quick start
15
+ ---
16
+
17
+ ## Quick Start
16
18
 
17
19
  ```ts
18
- import ilha, { html, mount } from "ilha";
19
- import { z } from "zod";
20
+ import ilha, { html } from "ilha";
20
21
 
21
- const counter = ilha
22
- .input(z.object({ count: z.number().default(0) }))
23
- .state("count", ({ count }) => count)
24
- .on("[data-inc]@click", ({ state }) => state.count(state.count() + 1))
22
+ const Counter = ilha
23
+ .state("count", 0)
24
+ .on("button@click", ({ state }) => state.count(state.count() + 1))
25
25
  .render(
26
26
  ({ state }) => html`
27
- <p>${state.count}</p>
28
- <button data-inc>+</button>
27
+ <div>
28
+ <p>Count: ${state.count}</p>
29
+ <button>Increment</button>
30
+ </div>
29
31
  `,
30
32
  );
31
33
 
32
- // SSR — sync islands return a string immediately
33
- counter({ count: 5 }); // → "<p>5</p><button data-inc>+</button>"
34
+ // SSR
35
+ Counter.toString(); // → '<div><p>Count: 0</p><button>Increment</button></div>'
34
36
 
35
- // Client — mount onto a DOM element
36
- mount({ counter });
37
+ // Client
38
+ Counter.mount(document.getElementById("app"));
37
39
  ```
38
40
 
39
- ```html
40
- <div data-ilha="counter" data-props='{"count": 5}'></div>
41
- ```
41
+ ---
42
+
43
+ ## Core Concepts
44
+
45
+ Islands are **self-contained reactive components** that know how to render themselves to an HTML string (SSR) and mount themselves into the DOM (client). You build an island using a fluent builder chain: declare inputs, state, events, effects, then call `.render()` to get a callable `Island` object.
46
+
47
+ State is managed with signals — when a signal changes, only the affected island re-renders using a minimal DOM morph. No virtual DOM diffing, no framework overhead.
48
+
49
+ ---
42
50
 
43
51
  ## Builder API
44
52
 
45
- Every island is built with a chainable builder. All methods return a new builder — nothing is mutated.
53
+ Every island starts from the `ilha` builder object (or `ilha.input()` if you need typed props).
54
+
55
+ ### `ilha.input(schema)`
56
+
57
+ Declares the island's external input type using any [Standard Schema](https://standardschema.dev/) compatible validator (e.g. Zod, Valibot, ArkType).
58
+
59
+ ```ts
60
+ import { z } from "zod";
61
+
62
+ const MyIsland = ilha
63
+ .input(z.object({ name: z.string().default("World") }))
64
+ .render(({ input }) => `<p>Hello, ${input.name}!</p>`);
65
+
66
+ MyIsland.toString({ name: "Ilha" }); // → '<p>Hello, Ilha!</p>'
67
+ ```
68
+
69
+ Async schemas are not supported.
46
70
 
47
- | Method | Description |
48
- | ------------------------------- | ------------------------------------------------------------------ |
49
- | `.input(schema)` | Declare typed props via any Standard Schema validator |
50
- | `.state(key, init)` | Add a reactive signal; `init` can be a value or `(input) => value` |
51
- | `.derived(key, fn)` | Derive reactive data from state/input — sync or async |
52
- | `.bind(selector, stateKey)` | Two-way bind a form element to a state key |
53
- | `.on(selector@event, handler)` | Attach a delegated event listener |
54
- | `.effect(fn)` | Run a reactive side effect on mount; return a cleanup function |
55
- | `.slot(name, island)` | Nest a child island |
56
- | `.transition({ enter, leave })` | Async-safe mount/unmount animations |
57
- | `.render(fn)` | Finalize — returns an `Island` |
71
+ ---
58
72
 
59
- ## Events
73
+ ### `.state(key, init?)`
60
74
 
61
- The `.on()` selector string uses `selector@event` syntax with optional modifiers:
75
+ Declares a reactive state signal. The initial value can be a static value or a function receiving the resolved `input`.
62
76
 
63
77
  ```ts
64
- .on("[data-btn]@click", handler) // delegated click
65
- .on("@click", handler) // bind to root element
66
- .on("[data-btn]@click:once", handler) // fires once
67
- .on("[data-btn]@click:passive:capture", handler)
78
+ ilha
79
+ .state("count", 0)
80
+ .state("name", "anonymous")
81
+ .state("double", ({ count }) => count * 2) // init from input
82
+ .render(({ state }) => `<p>${state.count()}</p>`);
68
83
  ```
69
84
 
70
- ## Two-way binding
85
+ State accessors are **getters and setters** — call without arguments to read, call with a value to write:
71
86
 
72
- `.bind(selector, stateKey)` creates a two-way link between a form element and a state key — no event handler boilerplate needed:
87
+ ```ts
88
+ state.count(); // → 0 (read)
89
+ state.count(5); // → sets to 5 (write)
90
+ ```
91
+
92
+ Inside `html\`\``, you can interpolate signal accessors directly **without calling them** — `ilha` detects signal accessors and calls them for you, also applying HTML escaping:
73
93
 
74
94
  ```ts
75
- const form = ilha
76
- .state("email", "")
77
- .state("subscribe", false)
78
- .bind("[data-email]", "email")
79
- .bind("[data-sub]", "subscribe")
80
- .render(
81
- ({ state }) => html`
82
- <input data-email value="${state.email()}" />
83
- <input type="checkbox" data-sub ${state.subscribe() ? "checked" : ""} />
84
- <p>Email: ${state.email()}, Subscribe: ${state.subscribe()}</p>
85
- `,
86
- );
95
+ html`<p>${state.count}</p>`; // same as html`<p>${state.count()}</p>`
87
96
  ```
88
97
 
89
- Both directions are handled automatically:
98
+ ---
90
99
 
91
- - **DOM → state** — when the user types or toggles, the signal updates immediately
92
- - **state → DOM** — when the signal changes programmatically, the element's value/checked syncs
100
+ ### `.derived(key, fn)`
93
101
 
94
- The DOM value is automatically coerced to match the type of the state key — no manual conversion needed:
102
+ Declares an async (or sync) derived value. The function receives `{ state, input, signal }` where `signal` is an `AbortSignal` that aborts on re-run. Re-runs automatically when any reactive dependency changes.
95
103
 
96
104
  ```ts
97
- .state("count", 0)
98
- .bind("[data-count]", "count") // input string coerced to number automatically
105
+ ilha
106
+ .state("userId", 1)
107
+ .derived("user", async ({ state, signal }) => {
108
+ const res = await fetch(`/api/users/${state.userId()}`, { signal });
109
+ return res.json();
110
+ })
111
+ .render(({ derived }) => {
112
+ if (derived.user.loading) return `<p>Loading…</p>`;
113
+ if (derived.user.error) return `<p>Error: ${derived.user.error.message}</p>`;
114
+ return `<p>${derived.user.value.name}</p>`;
115
+ });
99
116
  ```
100
117
 
101
- If the input is cleared and the state is a number, the value falls back to `0` rather than `NaN`.
118
+ Each derived value exposes `{ loading, value, error }`.
102
119
 
103
- Element types and their behaviour:
120
+ ---
104
121
 
105
- | Element | Event | Property |
106
- | ------------------------ | ------------------ | ----------------- |
107
- | `input` (text, email, …) | `input` | `.value` |
108
- | `input[type=number]` | `input` | `.valueAsNumber` |
109
- | `input[type=checkbox]` | `change` | `.checked` |
110
- | `input[type=radio]` | `change` | selected `.value` |
111
- | `select`, `textarea` | `change` / `input` | `.value` |
122
+ ### `.on(selector, handler)`
112
123
 
113
- For radio groups, bind all radios in the group to the same state key, typically via a shared selector like `[name=plan]`. The state stores the selected radio's `value`:
124
+ Attaches a delegated event listener. The selector string uses the format `"cssSelector@eventName"`. Omit the selector part to target the island host itself.
114
125
 
115
- `.bind()` is a no-op during SSR — it only activates on mount.
126
+ ```ts
127
+ ilha
128
+ .state("count", 0)
129
+ .on("@click", ({ state }) => state.count(state.count() + 1)) // host click
130
+ .on("button.inc@click", ({ state }) => state.count(state.count() + 1)) // child click
131
+ .on("input@input:debounce", ({ state, event }) => {
132
+ // with modifier
133
+ state.query((event.target as HTMLInputElement).value);
134
+ })
135
+ .render(({ state }) => html`<div><button class="inc">+</button></div>`);
136
+ ```
137
+
138
+ **Event modifiers** — append after a `:` separator:
139
+
140
+ | Modifier | Description |
141
+ | --------- | ------------------------ |
142
+ | `once` | Listener fires only once |
143
+ | `capture` | Capture phase |
144
+ | `passive` | `{ passive: true }` |
116
145
 
117
- ### Stale element references
146
+ Multiple modifiers can be combined: `@click:once:capture`.
118
147
 
119
- Every time bound state changes, the island re-renders and replaces `el.innerHTML`. Any element reference captured before a state change becomes stale. Always re-query from `el` after dispatching events or triggering state changes:
148
+ The handler receives a `HandlerContext`:
120
149
 
121
150
  ```ts
122
- // ✗ — reference captured before re-render, may be a detached element
123
- const input = el.querySelector("[data-q]")!;
124
- input.dispatchEvent(new Event("input"));
125
- input.value; // stale
126
-
127
- // ✓ — always re-query after a state-changing interaction
128
- el.querySelector<HTMLInputElement>("[data-q]")!.dispatchEvent(new Event("input"));
129
- el.querySelector<HTMLInputElement>("[data-q]")!.value; // fresh
151
+ {
152
+ state: IslandState; // reactive state signals
153
+ input: TInput; // resolved input props
154
+ host: Element; // island root element
155
+ target: Element; // element that fired the event (typed per event name)
156
+ event: Event; // the native event (typed per event name)
157
+ }
130
158
  ```
131
159
 
132
- ## Derived data
160
+ ---
161
+
162
+ ### `.effect(fn)`
163
+
164
+ Registers a reactive effect that runs after mount and re-runs when any signal it reads changes. Optionally returns a cleanup function.
165
+
166
+ ```ts
167
+ ilha
168
+ .state("title", "Hello")
169
+ .effect(({ state }) => {
170
+ document.title = state.title();
171
+ return () => {
172
+ document.title = "";
173
+ }; // cleanup on unmount or re-run
174
+ })
175
+ .render(({ state }) => `<p>${state.title()}</p>`);
176
+ ```
133
177
 
134
- `.derived()` computes values from state or input. It can be sync or async.
178
+ ---
135
179
 
136
- ### Sync derived
180
+ ### `.onMount(fn)`
137
181
 
138
- Sync derived is a pure computation — `value` is available immediately with no loading state, and updates synchronously whenever its dependencies change:
182
+ Runs once after the island is mounted into the DOM. Receives `{ state, derived, input, host, hydrated }` where `hydrated` is `true` when the island was mounted over existing SSR content. Optionally returns a cleanup function called on unmount.
139
183
 
140
184
  ```ts
141
- const island = ilha
142
- .state("count", 0)
143
- .derived("doubled", ({ state }) => state.count() * 2)
144
- .on("[data-inc]@click", ({ state }) => state.count(state.count() + 1))
185
+ ilha
186
+ .onMount(({ host, hydrated }) => {
187
+ console.log("mounted", hydrated ? "(hydrated)" : "(fresh)");
188
+ return () => console.log("unmounted");
189
+ })
190
+ .render(() => `<div>hello</div>`);
191
+ ```
192
+
193
+ `.onMount()` is skipped when `snapshot.skipOnMount` is set via `.hydratable()`.
194
+
195
+ ---
196
+
197
+ ### `.bind(selector, stateKey | externalSignal)`
198
+
199
+ Two-way binds a form element to a state key or an external signal. Handles `input`, `select`, `textarea`, `checkbox`, `radio`, and `number` inputs automatically.
200
+
201
+ ```ts
202
+ ilha
203
+ .state("name", "")
204
+ .state("agreed", false)
205
+ .bind("input.name", "name")
206
+ .bind("input[type=checkbox]", "agreed")
145
207
  .render(
146
- ({ state, derived }) => html`
147
- <p>${state.count()} × 2 = ${derived.doubled.value}</p>
148
- <button data-inc>+</button>
208
+ ({ state }) => html`
209
+ <form>
210
+ <input class="name" value="${state.name}" />
211
+ <input type="checkbox" />
212
+ <p>Hello, ${state.name}! Agreed: ${state.agreed}</p>
213
+ </form>
149
214
  `,
150
215
  );
151
216
  ```
152
217
 
153
- ### Async derived
218
+ You can also bind to an external signal created with `context()`:
219
+
220
+ ```ts
221
+ .bind("input", myContextSignal)
222
+ ```
223
+
224
+ ---
225
+
226
+ ### `.slot(name, island)`
227
+
228
+ Embeds a child island as a named slot. The child island is mounted and managed independently. During SSR the slot renders the child's HTML inline; during client mount the child island is activated for interactivity.
229
+
230
+ ```ts
231
+ const Icon = ilha.render(() => `<svg>…</svg>`);
232
+
233
+ const Card = ilha.slot("icon", Icon).render(
234
+ ({ slots }) => html`
235
+ <div class="card">
236
+ ${slots.icon()}
237
+ <p>Card content</p>
238
+ </div>
239
+ `,
240
+ );
241
+ ```
242
+
243
+ ---
154
244
 
155
- Async derived wraps the result in a `{ loading, value, error }` envelope. Previous `value` is preserved while re-fetching (stale-while-revalidate). Each run gets an `AbortSignal` — stale requests are cancelled automatically when dependencies change:
245
+ ### `.transition(opts)`
246
+
247
+ Attaches enter/leave transition callbacks called on mount and unmount respectively.
156
248
 
157
249
  ```ts
158
- const pokemon = ilha
159
- .state("name", "charizard")
160
- .derived("data", async ({ state, signal }) => {
161
- const res = await fetch(`https://pokeapi.co/api/v2/pokemon/${state.name()}`, { signal });
162
- return res.json() as Promise<{ name: string }>;
250
+ ilha
251
+ .transition({
252
+ enter: async (host) => {
253
+ host.animate([{ opacity: 0 }, { opacity: 1 }], 300).finished;
254
+ },
255
+ leave: async (host) => {
256
+ await host.animate([{ opacity: 1 }, { opacity: 0 }], 300).finished;
257
+ },
163
258
  })
164
- .render(({ derived }) => {
165
- const { loading, value, error } = derived.data;
166
- if (loading) return `<p>Loading${value ? ` (was: ${value.name})` : ""}…</p>`;
167
- if (error) return `<p>Error: ${error.message}</p>`;
168
- return `<p>${value!.name}</p>`;
169
- });
259
+ .render(() => `<div>content</div>`);
170
260
  ```
171
261
 
172
- ### Async SSR
262
+ The `leave` transition is awaited before cleanup runs.
263
+
264
+ ---
173
265
 
174
- On the server, islands with async derived values can be used in two ways:
266
+ ### `.render(fn)`
175
267
 
176
- - **`await island()`** — resolves all async derived values before rendering final HTML
177
- - **`island.toString()`** or implicit template interpolation — stays synchronous and uses the loading fallback for async derived values
268
+ Finalises the builder and returns an `Island`. The render function receives `{ state, derived, input, slots }` and must return a string or `RawHtml`.
178
269
 
179
270
  ```ts
180
- const page = ilha
181
- .derived("user", async () => ({ name: "Ada" }))
182
- .render(({ derived }) => {
183
- if (derived.user.loading) return "<p>Loading…</p>";
184
- if (derived.user.error) return `<p>Error: ${derived.user.error.message}</p>`;
185
- return `<p>${derived.user.value!.name}</p>`;
186
- });
271
+ const MyIsland = ilha.state("x", 1).render(({ state, input }) => html`<p>${state.x}</p>`);
272
+ ```
273
+
274
+ ---
275
+
276
+ ## Island Interface
277
+
278
+ Every island produced by `.render()` exposes:
279
+
280
+ ### `island(props?)` / `island.toString(props?)`
281
+
282
+ Render the island to an HTML string synchronously. `island.toString()` is always synchronous. If `.derived()` entries have async functions, they render in `loading: true` state when called synchronously.
187
283
 
188
- // async SSR
189
- await page(); // → "<p>Ada</p>"
284
+ Calling `island(props)` returns a `string` (or `Promise<string>` when derived values are async and awaited).
190
285
 
191
- // sync fallback
192
- page.toString(); // → "<p>Loading…</p>"
193
- `${page}`; // → "<p>Loading…</p>"
286
+ ```ts
287
+ MyIsland.toString(); // always sync
288
+ MyIsland.toString({ name: "Ilha" }); // with props
289
+ await MyIsland({ name: "Ilha" }); // async — awaits derived
194
290
  ```
195
291
 
196
- This keeps SSR flexible:
292
+ ---
197
293
 
198
- - sync islands remain zero-overhead and return a plain string
199
- - async islands can be awaited when the server runtime supports async rendering
200
- - template literals remain safe and synchronous
294
+ ### `island.mount(host, props?)`
201
295
 
202
- ### Derived envelope
296
+ Mounts the island into a DOM element. Reads `data-ilha-props` and `data-ilha-state` from the host element automatically — no need to pass props when hydrating SSR output.
203
297
 
204
- Every `.derived()` value — sync or async — is accessed as:
298
+ Returns an `unmount` function.
205
299
 
206
300
  ```ts
207
- derived.key.loading; // boolean — always false for sync
208
- derived.key.value; // T | undefined
209
- derived.key.error; // Error | undefined — always undefined for sync
301
+ const unmount = MyIsland.mount(document.getElementById("app"));
302
+ unmount(); // → stops effects, removes listeners, runs leave transition
210
303
  ```
211
304
 
212
- ### Derived context
305
+ In dev mode, double-mounting the same element logs a warning and returns a no-op.
306
+
307
+ ---
213
308
 
214
- The `fn` passed to `.derived()` receives:
309
+ ### `island.hydratable(props, options)`
310
+
311
+ Async method that renders the island wrapped in a `data-ilha` hydration container. Used for SSR+hydration pipelines.
215
312
 
216
313
  ```ts
217
- ({ state, input, signal }) => ...
218
- // ^^^^^^ AbortSignal — only meaningful for async
314
+ const html = await MyIsland.hydratable(
315
+ { name: "Ilha" },
316
+ {
317
+ name: "my-island", // registry key for client-side activation
318
+ as: "div", // wrapper tag (default: "div")
319
+ snapshot: true, // embed state + derived as data-ilha-state
320
+ skipOnMount: false, // skip onMount on hydration (default: true when snapshot)
321
+ },
322
+ );
323
+ // → '<div data-ilha="my-island" data-ilha-props="…" data-ilha-state="…">…</div>'
219
324
  ```
220
325
 
221
- ## Slots
326
+ **`snapshot` option:**
327
+
328
+ | Value | Behaviour |
329
+ | --------------------------------- | --------------------------------------------- |
330
+ | `false` | No snapshot — onMount always runs |
331
+ | `true` | Snapshots both state and derived values |
332
+ | `{ state: true, derived: false }` | Fine-grained control over what is snapshotted |
222
333
 
223
- Compose islands by nesting them as slots:
334
+ ---
335
+
336
+ ## Top-level Helpers
337
+
338
+ ### `ilha.mount(registry, options?)` / `mount(registry, options?)`
339
+
340
+ Auto-discovers all `[data-ilha]` elements in the DOM and mounts the corresponding island from the registry.
224
341
 
225
342
  ```ts
226
- const app = ilha
227
- .slot("counter", counter)
228
- .render(
229
- ({ slots }) => html`
230
- <div>${slots.counter} // default props ${slots.counter({ count: 10 })} // with props</div>
231
- `,
232
- );
343
+ import { mount } from "ilha";
344
+
345
+ const { unmount } = mount(
346
+ { counter: Counter, card: Card },
347
+ {
348
+ root: document.getElementById("app"), // default: document.body
349
+ lazy: true, // use IntersectionObserver (mount on visibility)
350
+ },
351
+ );
352
+
353
+ unmount(); // → unmounts all discovered islands
233
354
  ```
234
355
 
235
- Or declaratively in HTML:
356
+ ---
357
+
358
+ ### `ilha.from(selector, island, props?)` / `from(selector, island, props?)`
359
+
360
+ Mounts a single island into the first element matching `selector`. Returns the `unmount` function, or `null` if the element is not found.
361
+
362
+ ```ts
363
+ import { from } from "ilha";
236
364
 
237
- ```html
238
- <div data-ilha-slot="counter" data-props='{"count": 10}'></div>
365
+ const unmount = from("#hero", HeroIsland, { title: "Welcome" });
239
366
  ```
240
367
 
241
- ## Shared state
368
+ ---
369
+
370
+ ### `context(key, initial)`
242
371
 
243
- `context()` creates a module-level signal shared across all islands. The same key always returns the same signal — the initial value from the first registration wins.
372
+ Creates a **global context signal** — a named reactive signal shared across all islands. Identical keys always return the same signal instance.
244
373
 
245
374
  ```ts
246
375
  import { context } from "ilha";
247
376
 
248
- const theme = context("theme", "light");
377
+ const theme = context("app.theme", "light");
249
378
 
250
379
  theme(); // → "light"
251
- theme("dark"); // updates all subscribed islands
380
+ theme("dark"); // → sets to "dark"
252
381
  ```
253
382
 
254
- > **Note:** Context signals are global for the lifetime of the page. There is no per-instance scoping or cleanup.
383
+ Safe to call in both SSR and browser environments.
384
+
385
+ ---
386
+
387
+ ### `html\`\`` tagged template
255
388
 
256
- ## Mounting
389
+ XSS-safe HTML template tag. Interpolated values are HTML-escaped by default. Pass `raw()` to opt out of escaping.
257
390
 
258
391
  ```ts
259
- // Auto-discover all [data-ilha] elements
260
- mount({ counter, app });
261
- mount({ counter }, { root: document.querySelector("#app") });
262
- mount({ counter }, { lazy: true }); // IntersectionObserver
263
- mount({ counter }, { hydrate: true }); // preserve SSR HTML
392
+ import { html, raw } from "ilha";
264
393
 
265
- // Mount a single island by selector or element
266
- import { from } from "ilha";
267
- from("#my-counter", counter, { count: 5 });
394
+ const name = "<script>alert(1)</script>";
395
+ html`<p>${name}</p>`; // → <p>&lt;script&gt;…</p> (escaped)
396
+ html`<p>${raw("<b>hi</b>")}</p>`; // → <p><b>hi</b></p> (raw)
397
+ ```
398
+
399
+ Interpolation rules:
400
+
401
+ | Value type | Behaviour |
402
+ | -------------------- | ------------------------------------------- |
403
+ | `string` / `number` | HTML-escaped |
404
+ | `null` / `undefined` | Omitted (empty string) |
405
+ | `raw(str)` | Inserted as-is (no escaping) |
406
+ | `html\`…\`` | Inserted as-is (already safe) |
407
+ | Signal accessor | Called and escaped |
408
+ | Array | Each item processed recursively (no commas) |
409
+
410
+ **List rendering pattern:**
411
+
412
+ ```ts
413
+ const items = ["apple", "banana", "cherry"];
414
+ html`<ul>
415
+ ${items.map((item) => html`<li>${item}</li>`)}
416
+ </ul>`;
268
417
  ```
269
418
 
270
- ## SSR hydration
419
+ ---
420
+
421
+ ### `raw(value)`
271
422
 
272
- Set `data-ilha-state` on the element to restore serialised state client-side without re-running validation:
423
+ Marks a string as trusted raw HTML, bypassing escaping when used inside `html\`\``.
273
424
 
274
- ```html
275
- <div data-ilha="counter" data-ilha-state='{"count": 42}'>
276
- <p>42</p>
277
- <button data-inc>+</button>
278
- </div>
425
+ ```ts
426
+ import { raw } from "ilha";
427
+
428
+ raw("<strong>bold</strong>"); // → passes through unescaped
279
429
  ```
280
430
 
281
- ## `html` template tag
431
+ ---
282
432
 
283
- Safe HTML template helper — escapes all interpolations by default:
433
+ ### `type(coerce?)`
434
+
435
+ Creates a lightweight Standard Schema validator for use with `.input()` — useful when you don't want a full validation library.
284
436
 
285
437
  ```ts
286
- import { html, raw } from "ilha";
438
+ import { type } from "ilha";
287
439
 
288
- html`<p>${userInput}</p>`; // escaped
289
- html`<p>${raw("<b>bold</b>")}</p>`; // explicit raw passthrough
290
- html`<p>${state.count}</p>`; // signal accessor — calls getter + escapes
440
+ const MyIsland = ilha
441
+ .input(type((v: unknown) => v as { count: number }))
442
+ .render(({ input }) => `<p>${input.count}</p>`);
291
443
  ```
292
444
 
293
- ## Known limitations
445
+ ---
446
+
447
+ ## SSR + Hydration
448
+
449
+ The recommended SSR + hydration pattern uses `.hydratable()` on the server and `ilha.mount()` on the client.
450
+
451
+ ### Server
452
+
453
+ ```ts
454
+ import { MyIsland } from "./islands";
455
+
456
+ const html = await MyIsland.hydratable({ count: 42 }, { name: "my-island", snapshot: true });
457
+
458
+ return `<!doctype html><html><body>${html}</body></html>`;
459
+ ```
460
+
461
+ ### Client
462
+
463
+ ```ts
464
+ import { mount } from "ilha";
465
+ import { MyIsland } from "./islands";
466
+
467
+ mount({ "my-island": MyIsland });
468
+ ```
469
+
470
+ The client reads `data-ilha-state` to restore signal values from the snapshot, skipping a needless re-render and calling `.onMount()` only if `skipOnMount` is not set.
471
+
472
+ ### State snapshot flow
473
+
474
+ ```
475
+ server client
476
+ ────────────────────────────────────── ──────────────────────────────────────────
477
+ .hydratable({ count: 42 }, { mount({ "my-island": MyIsland })
478
+ name: "my-island", → reads data-ilha-state
479
+ snapshot: true → restores signals from snapshot
480
+ }) → skips onMount (skipOnMount: true)
481
+ → data-ilha-state='{"count":42}' → attaches event listeners
482
+ → starts effects + derived watchers
483
+ ```
484
+
485
+ ---
486
+
487
+ ## TypeScript
488
+
489
+ Key exported types:
490
+
491
+ ```ts
492
+ import type {
493
+ Island,
494
+ IslandState,
495
+ IslandDerived,
496
+ DerivedValue,
497
+ SlotAccessor,
498
+ HydratableOptions,
499
+ OnMountContext,
500
+ HandlerContext,
501
+ HandlerContextFor,
502
+ MountOptions,
503
+ MountResult,
504
+ } from "ilha";
505
+ ```
294
506
 
295
- - `context()` signals are global with no scoping or cleanup mechanism
296
- - Implicit string interpolation of islands (`${island}`) is always synchronous, so async derived values fall back to `loading`
507
+ ---
297
508
 
298
509
  ## License
299
510