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.
- package/README.md +477 -175
- package/dist/index.d.ts +83 -32
- package/dist/index.js +409 -205
- package/package.json +4 -20
package/README.md
CHANGED
|
@@ -1,299 +1,601 @@
|
|
|
1
|
-
# ilha
|
|
1
|
+
# `ilha`
|
|
2
2
|
|
|
3
|
-
A tiny,
|
|
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
|
-
|
|
5
|
+
---
|
|
6
6
|
|
|
7
|
-
##
|
|
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
|
-
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Quick Start
|
|
16
18
|
|
|
17
19
|
```ts
|
|
18
|
-
import ilha, { html
|
|
19
|
-
import { z } from "zod";
|
|
20
|
+
import ilha, { html } from "ilha";
|
|
20
21
|
|
|
21
|
-
const
|
|
22
|
-
.
|
|
23
|
-
.
|
|
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
|
-
<
|
|
28
|
-
|
|
27
|
+
<div>
|
|
28
|
+
<p>Count: ${state.count}</p>
|
|
29
|
+
<button>Increment</button>
|
|
30
|
+
</div>
|
|
29
31
|
`,
|
|
30
32
|
);
|
|
31
33
|
|
|
32
|
-
// SSR
|
|
33
|
-
|
|
34
|
+
// SSR
|
|
35
|
+
Counter.toString(); // → '<div><p>Count: 0</p><button>Increment</button></div>'
|
|
34
36
|
|
|
35
|
-
// Client
|
|
36
|
-
mount(
|
|
37
|
+
// Client
|
|
38
|
+
Counter.mount(document.getElementById("app"));
|
|
37
39
|
```
|
|
38
40
|
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
53
|
+
Every island starts from the `ilha` builder object (or `ilha.input()` if you need typed props).
|
|
46
54
|
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
.
|
|
66
|
-
.
|
|
67
|
-
.
|
|
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
|
-
|
|
85
|
+
State accessors are **getters and setters** — call without arguments to read, call with a value to write:
|
|
71
86
|
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
|
|
98
|
+
---
|
|
90
99
|
|
|
91
|
-
|
|
92
|
-
- **state → DOM** — when the signal changes programmatically, the element's value/checked syncs
|
|
100
|
+
### `.derived(key, fn)`
|
|
93
101
|
|
|
94
|
-
|
|
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
|
-
|
|
98
|
-
.
|
|
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
|
-
|
|
118
|
+
Each derived value exposes `{ loading, value, error }`.
|
|
102
119
|
|
|
103
|
-
|
|
120
|
+
---
|
|
104
121
|
|
|
105
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
145
|
+
Multiple modifiers can be combined: `@click:once:capture`.
|
|
118
146
|
|
|
119
|
-
|
|
147
|
+
The handler receives a `HandlerContext`:
|
|
120
148
|
|
|
121
149
|
```ts
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
input
|
|
125
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
192
|
+
`.onMount()` is skipped when `snapshot.skipOnMount` is set via `.hydratable()`.
|
|
137
193
|
|
|
138
|
-
|
|
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
|
-
|
|
142
|
-
.state("
|
|
143
|
-
.
|
|
144
|
-
.
|
|
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
|
|
147
|
-
<
|
|
148
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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((
|
|
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
|
-
|
|
318
|
+
The `leave` transition is awaited before cleanup runs.
|
|
319
|
+
|
|
320
|
+
---
|
|
173
321
|
|
|
174
|
-
|
|
322
|
+
### `.render(fn)`
|
|
175
323
|
|
|
176
|
-
|
|
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
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
189
|
-
await page(); // → "<p>Ada</p>"
|
|
334
|
+
Every island produced by `.render()` exposes:
|
|
190
335
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
348
|
+
---
|
|
197
349
|
|
|
198
|
-
|
|
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
|
-
|
|
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
|
-
|
|
354
|
+
Returns an `unmount` function.
|
|
205
355
|
|
|
206
356
|
```ts
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
361
|
+
In dev mode, double-mounting the same element logs a warning and returns a no-op.
|
|
362
|
+
|
|
363
|
+
---
|
|
213
364
|
|
|
214
|
-
|
|
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
|
-
|
|
218
|
-
|
|
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
|
-
|
|
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
|
-
|
|
396
|
+
Auto-discovers all `[data-ilha]` elements in the DOM and mounts the corresponding island from the registry.
|
|
224
397
|
|
|
225
398
|
```ts
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
412
|
+
---
|
|
236
413
|
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
424
|
+
---
|
|
242
425
|
|
|
243
|
-
`context(
|
|
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"); //
|
|
436
|
+
theme("dark"); // → sets to "dark"
|
|
252
437
|
```
|
|
253
438
|
|
|
254
|
-
|
|
439
|
+
Safe to call in both SSR and browser environments.
|
|
440
|
+
|
|
441
|
+
---
|
|
442
|
+
|
|
443
|
+
### `html\`\`` tagged template
|
|
255
444
|
|
|
256
|
-
|
|
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
|
-
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
450
|
+
const name = "<script>alert(1)</script>";
|
|
451
|
+
html`<p>${name}</p>`; // → <p><script>…</p> (escaped)
|
|
452
|
+
html`<p>${raw("<b>hi</b>")}</p>`; // → <p><b>hi</b></p> (raw)
|
|
268
453
|
```
|
|
269
454
|
|
|
270
|
-
|
|
455
|
+
Interpolation rules:
|
|
271
456
|
|
|
272
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
475
|
+
---
|
|
282
476
|
|
|
283
|
-
|
|
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 {
|
|
482
|
+
import { raw } from "ilha";
|
|
287
483
|
|
|
288
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|