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.
- package/README.md +387 -176
- package/dist/index.d.ts +79 -32
- package/dist/index.js +375 -205
- package/package.json +4 -20
package/README.md
CHANGED
|
@@ -1,299 +1,510 @@
|
|
|
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).
|
|
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
|
-
|
|
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
|
-
|
|
73
|
+
### `.state(key, init?)`
|
|
60
74
|
|
|
61
|
-
The
|
|
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
|
+
// 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
|
-
|
|
146
|
+
Multiple modifiers can be combined: `@click:once:capture`.
|
|
118
147
|
|
|
119
|
-
|
|
148
|
+
The handler receives a `HandlerContext`:
|
|
120
149
|
|
|
121
150
|
```ts
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
input
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
//
|
|
128
|
-
|
|
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
|
-
|
|
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
|
-
|
|
178
|
+
---
|
|
135
179
|
|
|
136
|
-
###
|
|
180
|
+
### `.onMount(fn)`
|
|
137
181
|
|
|
138
|
-
|
|
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
|
-
|
|
142
|
-
.
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
147
|
-
<
|
|
148
|
-
|
|
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
|
-
|
|
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
|
-
|
|
245
|
+
### `.transition(opts)`
|
|
246
|
+
|
|
247
|
+
Attaches enter/leave transition callbacks called on mount and unmount respectively.
|
|
156
248
|
|
|
157
249
|
```ts
|
|
158
|
-
|
|
159
|
-
.
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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((
|
|
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
|
-
|
|
262
|
+
The `leave` transition is awaited before cleanup runs.
|
|
263
|
+
|
|
264
|
+
---
|
|
173
265
|
|
|
174
|
-
|
|
266
|
+
### `.render(fn)`
|
|
175
267
|
|
|
176
|
-
|
|
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
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
292
|
+
---
|
|
197
293
|
|
|
198
|
-
|
|
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
|
-
|
|
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
|
-
|
|
298
|
+
Returns an `unmount` function.
|
|
205
299
|
|
|
206
300
|
```ts
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
305
|
+
In dev mode, double-mounting the same element logs a warning and returns a no-op.
|
|
306
|
+
|
|
307
|
+
---
|
|
213
308
|
|
|
214
|
-
|
|
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
|
-
|
|
218
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
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
|
-
|
|
238
|
-
<div data-ilha-slot="counter" data-props='{"count": 10}'></div>
|
|
365
|
+
const unmount = from("#hero", HeroIsland, { title: "Welcome" });
|
|
239
366
|
```
|
|
240
367
|
|
|
241
|
-
|
|
368
|
+
---
|
|
369
|
+
|
|
370
|
+
### `context(key, initial)`
|
|
242
371
|
|
|
243
|
-
|
|
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"); //
|
|
380
|
+
theme("dark"); // → sets to "dark"
|
|
252
381
|
```
|
|
253
382
|
|
|
254
|
-
|
|
383
|
+
Safe to call in both SSR and browser environments.
|
|
384
|
+
|
|
385
|
+
---
|
|
386
|
+
|
|
387
|
+
### `html\`\`` tagged template
|
|
255
388
|
|
|
256
|
-
|
|
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
|
-
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
394
|
+
const name = "<script>alert(1)</script>";
|
|
395
|
+
html`<p>${name}</p>`; // → <p><script>…</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
|
-
|
|
419
|
+
---
|
|
420
|
+
|
|
421
|
+
### `raw(value)`
|
|
271
422
|
|
|
272
|
-
|
|
423
|
+
Marks a string as trusted raw HTML, bypassing escaping when used inside `html\`\``.
|
|
273
424
|
|
|
274
|
-
```
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
</div>
|
|
425
|
+
```ts
|
|
426
|
+
import { raw } from "ilha";
|
|
427
|
+
|
|
428
|
+
raw("<strong>bold</strong>"); // → passes through unescaped
|
|
279
429
|
```
|
|
280
430
|
|
|
281
|
-
|
|
431
|
+
---
|
|
282
432
|
|
|
283
|
-
|
|
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 {
|
|
438
|
+
import { type } from "ilha";
|
|
287
439
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|