ilha 0.0.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.
Files changed (4) hide show
  1. package/README.md +477 -175
  2. package/dist/index.d.ts +83 -32
  3. package/dist/index.js +409 -205
  4. package/package.json +4 -20
package/README.md CHANGED
@@ -1,299 +1,601 @@
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).
46
54
 
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` |
55
+ ### `ilha.input(schema)`
58
56
 
59
- ## Events
57
+ Declares the island's external input type using any [Standard Schema](https://standardschema.dev/) compatible validator (e.g. Zod, Valibot, ArkType).
60
58
 
61
- The `.on()` selector string uses `selector@event` syntax with optional modifiers:
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.
70
+
71
+ ---
72
+
73
+ ### `.state(key, init?)`
74
+
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
+ state.query((event.target as HTMLInputElement).value);
133
+ })
134
+ .render(({ state }) => html`<div><button class="inc">+</button></div>`);
135
+ ```
136
+
137
+ **Event modifiers** — append after a `:` separator:
138
+
139
+ | Modifier | Description |
140
+ | --------- | ------------------------ |
141
+ | `once` | Listener fires only once |
142
+ | `capture` | Capture phase |
143
+ | `passive` | `{ passive: true }` |
116
144
 
117
- ### Stale element references
145
+ Multiple modifiers can be combined: `@click:once:capture`.
118
146
 
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:
147
+ The handler receives a `HandlerContext`:
120
148
 
121
149
  ```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
150
+ {
151
+ state: IslandState; // reactive state signals
152
+ input: TInput; // resolved input props
153
+ host: Element; // island root element
154
+ target: Element; // element that fired the event (typed per event name)
155
+ event: Event; // the native event (typed per event name)
156
+ }
157
+ ```
158
+
159
+ ---
160
+
161
+ ### `.effect(fn)`
126
162
 
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
163
+ Registers a reactive effect that runs after mount and re-runs when any signal it reads changes. Optionally returns a cleanup function.
164
+
165
+ ```ts
166
+ ilha
167
+ .state("title", "Hello")
168
+ .effect(({ state }) => {
169
+ document.title = state.title();
170
+ return () => {
171
+ document.title = "";
172
+ }; // cleanup on unmount or re-run
173
+ })
174
+ .render(({ state }) => `<p>${state.title()}</p>`);
130
175
  ```
131
176
 
132
- ## Derived data
177
+ ---
178
+
179
+ ### `.onMount(fn)`
180
+
181
+ 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.
133
182
 
134
- `.derived()` computes values from state or input. It can be sync or async.
183
+ ```ts
184
+ ilha
185
+ .onMount(({ host, hydrated }) => {
186
+ console.log("mounted", hydrated ? "(hydrated)" : "(fresh)");
187
+ return () => console.log("unmounted");
188
+ })
189
+ .render(() => `<div>hello</div>`);
190
+ ```
135
191
 
136
- ### Sync derived
192
+ `.onMount()` is skipped when `snapshot.skipOnMount` is set via `.hydratable()`.
137
193
 
138
- Sync derived is a pure computation — `value` is available immediately with no loading state, and updates synchronously whenever its dependencies change:
194
+ ---
195
+
196
+ ### `.bind(selector, stateKey | externalSignal)`
197
+
198
+ Two-way binds a form element to a state key or an external signal. Handles `input`, `select`, `textarea`, `checkbox`, `radio`, and `number` inputs automatically.
139
199
 
140
200
  ```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))
201
+ ilha
202
+ .state("name", "")
203
+ .state("agreed", false)
204
+ .bind("input.name", "name")
205
+ .bind("input[type=checkbox]", "agreed")
145
206
  .render(
146
- ({ state, derived }) => html`
147
- <p>${state.count()} × 2 = ${derived.doubled.value}</p>
148
- <button data-inc>+</button>
207
+ ({ state }) => html`
208
+ <form>
209
+ <input class="name" value="${state.name}" />
210
+ <input type="checkbox" />
211
+ <p>Hello, ${state.name}! Agreed: ${state.agreed}</p>
212
+ </form>
149
213
  `,
150
214
  );
151
215
  ```
152
216
 
153
- ### Async derived
217
+ You can also bind to an external signal created with `context()`:
218
+
219
+ ```ts
220
+ .bind("input", myContextSignal)
221
+ ```
222
+
223
+ ---
224
+
225
+ ### `.css(strings, ...values)`
226
+
227
+ Attaches scoped styles to the island. Accepts a tagged template literal or a plain string. The CSS is automatically wrapped in a `@scope` rule bounded to the island host, so styles are contained within the island and do not leak into child islands.
228
+
229
+ ```ts
230
+ import { css } from "ilha";
231
+
232
+ const Card = ilha.state("active", false).css`
233
+ .title { font-weight: 700; }
234
+ button { background: teal; color: white; }
235
+ `.render(
236
+ ({ state }) => html`
237
+ <div>
238
+ <p class="title">Hello</p>
239
+ <button>Toggle</button>
240
+ </div>
241
+ `,
242
+ );
243
+ ```
244
+
245
+ Interpolations are supported:
246
+
247
+ ```ts
248
+ const accent = "teal";
249
+
250
+ ilha.css`button { background: ${accent}; }`.render(() => `<button>Go</button>`);
251
+ ```
252
+
253
+ You can also pass a plain string (e.g. from an external `.css` file):
254
+
255
+ ```ts
256
+ import styles from "./card.css?raw";
154
257
 
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:
258
+ ilha.css(styles).render(() => `<div class="card">…</div>`);
259
+ ```
260
+
261
+ **SSR output** — a `<style data-ilha-css>` tag is prepended as the first child of the island's rendered HTML:
262
+
263
+ ```html
264
+ <style data-ilha-css>
265
+ @scope (:scope) to ([data-ilha]) {
266
+ .title {
267
+ font-weight: 700;
268
+ }
269
+ }
270
+ </style>
271
+ <div>…</div>
272
+ ```
273
+
274
+ **Client mount** — the style element is injected once as the first child of the host and preserved across re-renders (morph never replaces it). During hydration, the SSR-emitted `<style>` node is reused and not duplicated.
275
+
276
+ **`.hydratable()` integration** — the style tag is included inside the `data-ilha` wrapper regardless of the `snapshot` option.
277
+
278
+ > **Note:** Calling `.css()` more than once on the same builder chain is not supported. In dev mode a warning is logged and only the last stylesheet is used. Compose all your styles into a single `.css()` call.
279
+
280
+ ---
281
+
282
+ ### `.slot(name, island)`
283
+
284
+ 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.
156
285
 
157
286
  ```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 }>;
287
+ const Icon = ilha.render(() => `<svg>…</svg>`);
288
+
289
+ const Card = ilha.slot("icon", Icon).render(
290
+ ({ slots }) => html`
291
+ <div class="card">
292
+ ${slots.icon()}
293
+ <p>Card content</p>
294
+ </div>
295
+ `,
296
+ );
297
+ ```
298
+
299
+ ---
300
+
301
+ ### `.transition(opts)`
302
+
303
+ Attaches enter/leave transition callbacks called on mount and unmount respectively.
304
+
305
+ ```ts
306
+ ilha
307
+ .transition({
308
+ enter: async (host) => {
309
+ host.animate([{ opacity: 0 }, { opacity: 1 }], 300).finished;
310
+ },
311
+ leave: async (host) => {
312
+ await host.animate([{ opacity: 1 }, { opacity: 0 }], 300).finished;
313
+ },
163
314
  })
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
- });
315
+ .render(() => `<div>content</div>`);
170
316
  ```
171
317
 
172
- ### Async SSR
318
+ The `leave` transition is awaited before cleanup runs.
319
+
320
+ ---
173
321
 
174
- On the server, islands with async derived values can be used in two ways:
322
+ ### `.render(fn)`
175
323
 
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
324
+ Finalises the builder and returns an `Island`. The render function receives `{ state, derived, input, slots }` and must return a string or `RawHtml`.
178
325
 
179
326
  ```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
- });
327
+ const MyIsland = ilha.state("x", 1).render(({ state, input }) => html`<p>${state.x}</p>`);
328
+ ```
329
+
330
+ ---
331
+
332
+ ## Island Interface
187
333
 
188
- // async SSR
189
- await page(); // → "<p>Ada</p>"
334
+ Every island produced by `.render()` exposes:
190
335
 
191
- // sync fallback
192
- page.toString(); // → "<p>Loading…</p>"
193
- `${page}`; // → "<p>Loading…</p>"
336
+ ### `island(props?)` / `island.toString(props?)`
337
+
338
+ 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.
339
+
340
+ Calling `island(props)` returns a `string` (or `Promise<string>` when derived values are async and awaited).
341
+
342
+ ```ts
343
+ MyIsland.toString(); // always sync
344
+ MyIsland.toString({ name: "Ilha" }); // with props
345
+ await MyIsland({ name: "Ilha" }); // async — awaits derived
194
346
  ```
195
347
 
196
- This keeps SSR flexible:
348
+ ---
197
349
 
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
350
+ ### `island.mount(host, props?)`
201
351
 
202
- ### Derived envelope
352
+ 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
353
 
204
- Every `.derived()` value — sync or async — is accessed as:
354
+ Returns an `unmount` function.
205
355
 
206
356
  ```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
357
+ const unmount = MyIsland.mount(document.getElementById("app"));
358
+ unmount(); // → stops effects, removes listeners, runs leave transition
210
359
  ```
211
360
 
212
- ### Derived context
361
+ In dev mode, double-mounting the same element logs a warning and returns a no-op.
362
+
363
+ ---
213
364
 
214
- The `fn` passed to `.derived()` receives:
365
+ ### `island.hydratable(props, options)`
366
+
367
+ Async method that renders the island wrapped in a `data-ilha` hydration container. Used for SSR+hydration pipelines.
215
368
 
216
369
  ```ts
217
- ({ state, input, signal }) => ...
218
- // ^^^^^^ AbortSignal — only meaningful for async
370
+ const html = await MyIsland.hydratable(
371
+ { name: "Ilha" },
372
+ {
373
+ name: "my-island", // registry key for client-side activation
374
+ as: "div", // wrapper tag (default: "div")
375
+ snapshot: true, // embed state + derived as data-ilha-state
376
+ skipOnMount: false, // skip onMount on hydration (default: true when snapshot)
377
+ },
378
+ );
379
+ // → '<div data-ilha="my-island" data-ilha-props="…" data-ilha-state="…">…</div>'
219
380
  ```
220
381
 
221
- ## Slots
382
+ **`snapshot` option:**
383
+
384
+ | Value | Behaviour |
385
+ | --------------------------------- | --------------------------------------------- |
386
+ | `false` | No snapshot — onMount always runs |
387
+ | `true` | Snapshots both state and derived values |
388
+ | `{ state: true, derived: false }` | Fine-grained control over what is snapshotted |
389
+
390
+ ---
391
+
392
+ ## Top-level Helpers
393
+
394
+ ### `ilha.mount(registry, options?)` / `mount(registry, options?)`
222
395
 
223
- Compose islands by nesting them as slots:
396
+ Auto-discovers all `[data-ilha]` elements in the DOM and mounts the corresponding island from the registry.
224
397
 
225
398
  ```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
- );
399
+ import { mount } from "ilha";
400
+
401
+ const { unmount } = mount(
402
+ { counter: Counter, card: Card },
403
+ {
404
+ root: document.getElementById("app"), // default: document.body
405
+ lazy: true, // use IntersectionObserver (mount on visibility)
406
+ },
407
+ );
408
+
409
+ unmount(); // → unmounts all discovered islands
233
410
  ```
234
411
 
235
- Or declaratively in HTML:
412
+ ---
236
413
 
237
- ```html
238
- <div data-ilha-slot="counter" data-props='{"count": 10}'></div>
414
+ ### `ilha.from(selector, island, props?)` / `from(selector, island, props?)`
415
+
416
+ Mounts a single island into the first element matching `selector`. Returns the `unmount` function, or `null` if the element is not found.
417
+
418
+ ```ts
419
+ import { from } from "ilha";
420
+
421
+ const unmount = from("#hero", HeroIsland, { title: "Welcome" });
239
422
  ```
240
423
 
241
- ## Shared state
424
+ ---
242
425
 
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.
426
+ ### `context(key, initial)`
427
+
428
+ Creates a **global context signal** — a named reactive signal shared across all islands. Identical keys always return the same signal instance.
244
429
 
245
430
  ```ts
246
431
  import { context } from "ilha";
247
432
 
248
- const theme = context("theme", "light");
433
+ const theme = context("app.theme", "light");
249
434
 
250
435
  theme(); // → "light"
251
- theme("dark"); // updates all subscribed islands
436
+ theme("dark"); // → sets to "dark"
252
437
  ```
253
438
 
254
- > **Note:** Context signals are global for the lifetime of the page. There is no per-instance scoping or cleanup.
439
+ Safe to call in both SSR and browser environments.
440
+
441
+ ---
442
+
443
+ ### `html\`\`` tagged template
255
444
 
256
- ## Mounting
445
+ XSS-safe HTML template tag. Interpolated values are HTML-escaped by default. Pass `raw()` to opt out of escaping.
257
446
 
258
447
  ```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
448
+ import { html, raw } from "ilha";
264
449
 
265
- // Mount a single island by selector or element
266
- import { from } from "ilha";
267
- from("#my-counter", counter, { count: 5 });
450
+ const name = "<script>alert(1)</script>";
451
+ html`<p>${name}</p>`; // → <p>&lt;script&gt;…</p> (escaped)
452
+ html`<p>${raw("<b>hi</b>")}</p>`; // → <p><b>hi</b></p> (raw)
268
453
  ```
269
454
 
270
- ## SSR hydration
455
+ Interpolation rules:
271
456
 
272
- Set `data-ilha-state` on the element to restore serialised state client-side without re-running validation:
457
+ | Value type | Behaviour |
458
+ | -------------------- | ------------------------------------------- |
459
+ | `string` / `number` | HTML-escaped |
460
+ | `null` / `undefined` | Omitted (empty string) |
461
+ | `raw(str)` | Inserted as-is (no escaping) |
462
+ | `html\`…\`` | Inserted as-is (already safe) |
463
+ | Signal accessor | Called and escaped |
464
+ | Array | Each item processed recursively (no commas) |
273
465
 
274
- ```html
275
- <div data-ilha="counter" data-ilha-state='{"count": 42}'>
276
- <p>42</p>
277
- <button data-inc>+</button>
278
- </div>
466
+ **List rendering pattern:**
467
+
468
+ ```ts
469
+ const items = ["apple", "banana", "cherry"];
470
+ html`<ul>
471
+ ${items.map((item) => html`<li>${item}</li>`)}
472
+ </ul>`;
279
473
  ```
280
474
 
281
- ## `html` template tag
475
+ ---
282
476
 
283
- Safe HTML template helper — escapes all interpolations by default:
477
+ ### `raw(value)`
478
+
479
+ Marks a string as trusted raw HTML, bypassing escaping when used inside `html\`\``.
284
480
 
285
481
  ```ts
286
- import { html, raw } from "ilha";
482
+ import { raw } from "ilha";
287
483
 
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
484
+ raw("<strong>bold</strong>"); // → passes through unescaped
291
485
  ```
292
486
 
293
- ## Known limitations
487
+ ---
488
+
489
+ ### `css\`\`` tagged template
490
+
491
+ A passthrough tagged template for CSS strings. Functionally identical to a plain template literal — no runtime transformation occurs. Its purpose is purely to enable editor tooling (LSP syntax highlighting, Prettier formatting) to recognise the contents as CSS.
492
+
493
+ ```ts
494
+ import { css } from "ilha";
495
+
496
+ const styles = css`
497
+ button {
498
+ background: teal;
499
+ color: white;
500
+ }
501
+ .label {
502
+ font-weight: 700;
503
+ }
504
+ `;
505
+
506
+ ilha.css(styles).render(() => `<button class="label">Go</button>`);
507
+ ```
508
+
509
+ Interpolations work as normal string concatenation:
510
+
511
+ ```ts
512
+ const accent = "coral";
513
+ const styles = css`
514
+ button {
515
+ background: ${accent};
516
+ }
517
+ `;
518
+ ```
519
+
520
+ > **Note:** `css` (the named export) is the plain passthrough tag for tooling. `ilha.css` is the builder chain method that attaches styles to an island. They are intentionally separate.
521
+
522
+ ---
523
+
524
+ ### `type(coerce?)`
525
+
526
+ Creates a lightweight Standard Schema validator for use with `.input()` — useful when you don't want a full validation library.
527
+
528
+ ```ts
529
+ import { type } from "ilha";
530
+
531
+ const MyIsland = ilha
532
+ .input(type((v: unknown) => v as { count: number }))
533
+ .render(({ input }) => `<p>${input.count}</p>`);
534
+ ```
535
+
536
+ ---
537
+
538
+ ## SSR + Hydration
539
+
540
+ The recommended SSR + hydration pattern uses `.hydratable()` on the server and `ilha.mount()` on the client.
541
+
542
+ ### Server
543
+
544
+ ```ts
545
+ import { MyIsland } from "./islands";
546
+
547
+ const html = await MyIsland.hydratable({ count: 42 }, { name: "my-island", snapshot: true });
548
+
549
+ return `<!doctype html><html><body>${html}</body></html>`;
550
+ ```
551
+
552
+ ### Client
553
+
554
+ ```ts
555
+ import { mount } from "ilha";
556
+ import { MyIsland } from "./islands";
557
+
558
+ mount({ "my-island": MyIsland });
559
+ ```
560
+
561
+ 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.
562
+
563
+ ### State snapshot flow
564
+
565
+ ```
566
+ server client
567
+ ────────────────────────────────────── ──────────────────────────────────────────
568
+ .hydratable({ count: 42 }, { mount({ "my-island": MyIsland })
569
+ name: "my-island", → reads data-ilha-state
570
+ snapshot: true → restores signals from snapshot
571
+ }) → skips onMount (skipOnMount: true)
572
+ → data-ilha-state='{"count":42}' → attaches event listeners
573
+ → starts effects + derived watchers
574
+ ```
575
+
576
+ ---
577
+
578
+ ## TypeScript
579
+
580
+ Key exported types:
581
+
582
+ ```ts
583
+ import type {
584
+ Island,
585
+ IslandState,
586
+ IslandDerived,
587
+ DerivedValue,
588
+ SlotAccessor,
589
+ HydratableOptions,
590
+ OnMountContext,
591
+ HandlerContext,
592
+ HandlerContextFor,
593
+ MountOptions,
594
+ MountResult,
595
+ } from "ilha";
596
+ ```
294
597
 
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`
598
+ ---
297
599
 
298
600
  ## License
299
601