ilha 0.0.1
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 +300 -0
- package/dist/index.d.ts +166 -0
- package/dist/index.js +658 -0
- package/package.json +48 -0
package/README.md
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
# ilha
|
|
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.
|
|
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, …).
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
bun add ilha
|
|
11
|
+
# or
|
|
12
|
+
npm install ilha
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Quick start
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import ilha, { html, mount } from "ilha";
|
|
19
|
+
import { z } from "zod";
|
|
20
|
+
|
|
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))
|
|
25
|
+
.render(
|
|
26
|
+
({ state }) => html`
|
|
27
|
+
<p>${state.count}</p>
|
|
28
|
+
<button data-inc>+</button>
|
|
29
|
+
`,
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
// SSR — sync islands return a string immediately
|
|
33
|
+
counter({ count: 5 }); // → "<p>5</p><button data-inc>+</button>"
|
|
34
|
+
|
|
35
|
+
// Client — mount onto a DOM element
|
|
36
|
+
mount({ counter });
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
```html
|
|
40
|
+
<div data-ilha="counter" data-props='{"count": 5}'></div>
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Builder API
|
|
44
|
+
|
|
45
|
+
Every island is built with a chainable builder. All methods return a new builder — nothing is mutated.
|
|
46
|
+
|
|
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` |
|
|
58
|
+
|
|
59
|
+
## Events
|
|
60
|
+
|
|
61
|
+
The `.on()` selector string uses `selector@event` syntax with optional modifiers:
|
|
62
|
+
|
|
63
|
+
```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)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Two-way binding
|
|
71
|
+
|
|
72
|
+
`.bind(selector, stateKey)` creates a two-way link between a form element and a state key — no event handler boilerplate needed:
|
|
73
|
+
|
|
74
|
+
```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
|
+
);
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Both directions are handled automatically:
|
|
90
|
+
|
|
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
|
|
93
|
+
|
|
94
|
+
The DOM value is automatically coerced to match the type of the state key — no manual conversion needed:
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
.state("count", 0)
|
|
98
|
+
.bind("[data-count]", "count") // input string coerced to number automatically
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
If the input is cleared and the state is a number, the value falls back to `0` rather than `NaN`.
|
|
102
|
+
|
|
103
|
+
Element types and their behaviour:
|
|
104
|
+
|
|
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` |
|
|
112
|
+
|
|
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`:
|
|
114
|
+
|
|
115
|
+
`.bind()` is a no-op during SSR — it only activates on mount.
|
|
116
|
+
|
|
117
|
+
### Stale element references
|
|
118
|
+
|
|
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:
|
|
120
|
+
|
|
121
|
+
```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
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Derived data
|
|
133
|
+
|
|
134
|
+
`.derived()` computes values from state or input. It can be sync or async.
|
|
135
|
+
|
|
136
|
+
### Sync derived
|
|
137
|
+
|
|
138
|
+
Sync derived is a pure computation — `value` is available immediately with no loading state, and updates synchronously whenever its dependencies change:
|
|
139
|
+
|
|
140
|
+
```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))
|
|
145
|
+
.render(
|
|
146
|
+
({ state, derived }) => html`
|
|
147
|
+
<p>${state.count()} × 2 = ${derived.doubled.value}</p>
|
|
148
|
+
<button data-inc>+</button>
|
|
149
|
+
`,
|
|
150
|
+
);
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### Async derived
|
|
154
|
+
|
|
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:
|
|
156
|
+
|
|
157
|
+
```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 }>;
|
|
163
|
+
})
|
|
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
|
+
});
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### Async SSR
|
|
173
|
+
|
|
174
|
+
On the server, islands with async derived values can be used in two ways:
|
|
175
|
+
|
|
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
|
|
178
|
+
|
|
179
|
+
```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
|
+
});
|
|
187
|
+
|
|
188
|
+
// async SSR
|
|
189
|
+
await page(); // → "<p>Ada</p>"
|
|
190
|
+
|
|
191
|
+
// sync fallback
|
|
192
|
+
page.toString(); // → "<p>Loading…</p>"
|
|
193
|
+
`${page}`; // → "<p>Loading…</p>"
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
This keeps SSR flexible:
|
|
197
|
+
|
|
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
|
|
201
|
+
|
|
202
|
+
### Derived envelope
|
|
203
|
+
|
|
204
|
+
Every `.derived()` value — sync or async — is accessed as:
|
|
205
|
+
|
|
206
|
+
```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
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
### Derived context
|
|
213
|
+
|
|
214
|
+
The `fn` passed to `.derived()` receives:
|
|
215
|
+
|
|
216
|
+
```ts
|
|
217
|
+
({ state, input, signal }) => ...
|
|
218
|
+
// ^^^^^^ AbortSignal — only meaningful for async
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
## Slots
|
|
222
|
+
|
|
223
|
+
Compose islands by nesting them as slots:
|
|
224
|
+
|
|
225
|
+
```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
|
+
);
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
Or declaratively in HTML:
|
|
236
|
+
|
|
237
|
+
```html
|
|
238
|
+
<div data-ilha-slot="counter" data-props='{"count": 10}'></div>
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
## Shared state
|
|
242
|
+
|
|
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.
|
|
244
|
+
|
|
245
|
+
```ts
|
|
246
|
+
import { context } from "ilha";
|
|
247
|
+
|
|
248
|
+
const theme = context("theme", "light");
|
|
249
|
+
|
|
250
|
+
theme(); // → "light"
|
|
251
|
+
theme("dark"); // updates all subscribed islands
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
> **Note:** Context signals are global for the lifetime of the page. There is no per-instance scoping or cleanup.
|
|
255
|
+
|
|
256
|
+
## Mounting
|
|
257
|
+
|
|
258
|
+
```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
|
|
264
|
+
|
|
265
|
+
// Mount a single island by selector or element
|
|
266
|
+
import { from } from "ilha";
|
|
267
|
+
from("#my-counter", counter, { count: 5 });
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
## SSR hydration
|
|
271
|
+
|
|
272
|
+
Set `data-ilha-state` on the element to restore serialised state client-side without re-running validation:
|
|
273
|
+
|
|
274
|
+
```html
|
|
275
|
+
<div data-ilha="counter" data-ilha-state='{"count": 42}'>
|
|
276
|
+
<p>42</p>
|
|
277
|
+
<button data-inc>+</button>
|
|
278
|
+
</div>
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
## `html` template tag
|
|
282
|
+
|
|
283
|
+
Safe HTML template helper — escapes all interpolations by default:
|
|
284
|
+
|
|
285
|
+
```ts
|
|
286
|
+
import { html, raw } from "ilha";
|
|
287
|
+
|
|
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
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
## Known limitations
|
|
294
|
+
|
|
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`
|
|
297
|
+
|
|
298
|
+
## License
|
|
299
|
+
|
|
300
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
//#region src/index.d.ts
|
|
2
|
+
interface StandardSchemaV1<Input = unknown, Output = Input> {
|
|
3
|
+
readonly "~standard": StandardSchemaV1.Props<Input, Output>;
|
|
4
|
+
}
|
|
5
|
+
declare namespace StandardSchemaV1 {
|
|
6
|
+
interface Props<Input = unknown, Output = Input> {
|
|
7
|
+
readonly version: 1;
|
|
8
|
+
readonly vendor: string;
|
|
9
|
+
readonly types?: Types<Input, Output> | undefined;
|
|
10
|
+
readonly validate: (value: unknown) => Result<Output> | Promise<Result<Output>>;
|
|
11
|
+
}
|
|
12
|
+
type Result<Output> = SuccessResult<Output> | FailureResult;
|
|
13
|
+
interface SuccessResult<Output> {
|
|
14
|
+
readonly value: Output;
|
|
15
|
+
readonly issues?: undefined;
|
|
16
|
+
}
|
|
17
|
+
interface FailureResult {
|
|
18
|
+
readonly issues: ReadonlyArray<Issue>;
|
|
19
|
+
}
|
|
20
|
+
interface Issue {
|
|
21
|
+
readonly message: string;
|
|
22
|
+
readonly path?: ReadonlyArray<PropertyKey | PathSegment> | undefined;
|
|
23
|
+
}
|
|
24
|
+
interface PathSegment {
|
|
25
|
+
readonly key: PropertyKey;
|
|
26
|
+
}
|
|
27
|
+
interface Types<Input = unknown, Output = Input> {
|
|
28
|
+
readonly input: Input;
|
|
29
|
+
readonly output: Output;
|
|
30
|
+
}
|
|
31
|
+
type InferOutput<Schema extends StandardSchemaV1> = NonNullable<Schema["~standard"]["types"]>["output"];
|
|
32
|
+
}
|
|
33
|
+
declare const RAW: unique symbol;
|
|
34
|
+
declare const SLOT_ACCESSOR: unique symbol;
|
|
35
|
+
declare const SIGNAL_ACCESSOR: unique symbol;
|
|
36
|
+
interface RawHtml {
|
|
37
|
+
[RAW]: true;
|
|
38
|
+
value: string;
|
|
39
|
+
}
|
|
40
|
+
interface SlotAccessor {
|
|
41
|
+
(props?: Record<string, unknown>): RawHtml;
|
|
42
|
+
toString(): string;
|
|
43
|
+
[SLOT_ACCESSOR]: true;
|
|
44
|
+
}
|
|
45
|
+
interface MarkedSignalAccessor<T> {
|
|
46
|
+
(): T;
|
|
47
|
+
(value: T): void;
|
|
48
|
+
[SIGNAL_ACCESSOR]: true;
|
|
49
|
+
}
|
|
50
|
+
declare function ilhaRaw(value: string): RawHtml;
|
|
51
|
+
declare function ilhaHtml(strings: TemplateStringsArray, ...values: unknown[]): string;
|
|
52
|
+
type ContextSignal<T> = {
|
|
53
|
+
(): T;
|
|
54
|
+
(value: T): void;
|
|
55
|
+
};
|
|
56
|
+
declare function ilhaContext<T>(key: string, initial: T): ContextSignal<T>;
|
|
57
|
+
interface DerivedValue<T> {
|
|
58
|
+
loading: boolean;
|
|
59
|
+
value: T | undefined;
|
|
60
|
+
error: Error | undefined;
|
|
61
|
+
}
|
|
62
|
+
type DerivedFnContext<TInput, TStateMap extends Record<string, unknown>> = {
|
|
63
|
+
state: IslandState<TStateMap>;
|
|
64
|
+
input: TInput;
|
|
65
|
+
signal: AbortSignal;
|
|
66
|
+
};
|
|
67
|
+
type DerivedFn<TInput, TStateMap extends Record<string, unknown>, V> = (ctx: DerivedFnContext<TInput, TStateMap>) => V | Promise<V>;
|
|
68
|
+
interface DerivedEntry<TInput, TStateMap extends Record<string, unknown>> {
|
|
69
|
+
key: string;
|
|
70
|
+
fn: DerivedFn<TInput, TStateMap, unknown>;
|
|
71
|
+
}
|
|
72
|
+
type IslandDerived<TDerivedMap extends Record<string, unknown>> = { readonly [K in keyof TDerivedMap]: DerivedValue<TDerivedMap[K]> };
|
|
73
|
+
interface BindEntry<TStateMap extends Record<string, unknown>> {
|
|
74
|
+
selector: string;
|
|
75
|
+
stateKey: keyof TStateMap & string;
|
|
76
|
+
}
|
|
77
|
+
type SignalAccessor<T> = MarkedSignalAccessor<T>;
|
|
78
|
+
type IslandState<TStateMap extends Record<string, unknown>> = { [K in keyof TStateMap]: SignalAccessor<TStateMap[K]> };
|
|
79
|
+
interface Island<TInput, TStateMap extends Record<string, unknown>> {
|
|
80
|
+
(props?: Partial<TInput>): string | Promise<string>;
|
|
81
|
+
toString(props?: Partial<TInput>): string;
|
|
82
|
+
mount(el: Element, props?: Partial<TInput>): () => void;
|
|
83
|
+
}
|
|
84
|
+
type AnyIsland = Island<Record<string, unknown>, Record<string, unknown>>;
|
|
85
|
+
type SlotMap = Record<string, AnyIsland>;
|
|
86
|
+
type SlotsProxy<TSlots extends SlotMap> = { readonly [K in keyof TSlots]: SlotAccessor };
|
|
87
|
+
type RenderContext<TInput, TStateMap extends Record<string, unknown>, TDerivedMap extends Record<string, unknown>, TSlots extends SlotMap> = {
|
|
88
|
+
state: IslandState<TStateMap>;
|
|
89
|
+
derived: IslandDerived<TDerivedMap>;
|
|
90
|
+
input: TInput;
|
|
91
|
+
slots: SlotsProxy<TSlots>;
|
|
92
|
+
};
|
|
93
|
+
type EffectContext<TInput, TStateMap extends Record<string, unknown>> = {
|
|
94
|
+
state: IslandState<TStateMap>;
|
|
95
|
+
input: TInput;
|
|
96
|
+
el: Element;
|
|
97
|
+
};
|
|
98
|
+
type HandlerContext<TInput, TStateMap extends Record<string, unknown>> = {
|
|
99
|
+
state: IslandState<TStateMap>;
|
|
100
|
+
input: TInput;
|
|
101
|
+
el: Element;
|
|
102
|
+
event: Event;
|
|
103
|
+
};
|
|
104
|
+
type StateInit<TInput, V> = V | ((input: TInput) => V);
|
|
105
|
+
interface StateEntry<TInput> {
|
|
106
|
+
key: string;
|
|
107
|
+
init: StateInit<TInput, unknown>;
|
|
108
|
+
}
|
|
109
|
+
interface OnEntry<TInput, TStateMap extends Record<string, unknown>> {
|
|
110
|
+
selector: string;
|
|
111
|
+
event: string;
|
|
112
|
+
options: AddEventListenerOptions;
|
|
113
|
+
handler: (ctx: HandlerContext<TInput, TStateMap>) => void | Promise<void>;
|
|
114
|
+
}
|
|
115
|
+
interface EffectEntry<TInput, TStateMap extends Record<string, unknown>> {
|
|
116
|
+
fn: (ctx: EffectContext<TInput, TStateMap>) => (() => void) | void;
|
|
117
|
+
}
|
|
118
|
+
interface TransitionOptions {
|
|
119
|
+
enter?: (el: Element) => Promise<void> | void;
|
|
120
|
+
leave?: (el: Element) => Promise<void> | void;
|
|
121
|
+
}
|
|
122
|
+
interface MountOptions {
|
|
123
|
+
root?: Element;
|
|
124
|
+
hydrate?: boolean;
|
|
125
|
+
lazy?: boolean;
|
|
126
|
+
}
|
|
127
|
+
interface MountResult {
|
|
128
|
+
unmount: () => void;
|
|
129
|
+
}
|
|
130
|
+
declare class IlhaBuilder<TInput extends Record<string, unknown>, TStateMap extends Record<string, unknown>, TDerivedMap extends Record<string, unknown> = Record<string, never>, TSlots extends SlotMap = Record<string, never>> {
|
|
131
|
+
private readonly _schema;
|
|
132
|
+
private readonly _states;
|
|
133
|
+
private readonly _deriveds;
|
|
134
|
+
private readonly _ons;
|
|
135
|
+
private readonly _effects;
|
|
136
|
+
private readonly _slots;
|
|
137
|
+
private readonly _transition;
|
|
138
|
+
private readonly _binds;
|
|
139
|
+
constructor(_schema: StandardSchemaV1 | null, _states: StateEntry<TInput>[], _deriveds: DerivedEntry<TInput, TStateMap>[], _ons: OnEntry<TInput, TStateMap>[], _effects: EffectEntry<TInput, TStateMap>[], _slots: Record<string, AnyIsland>, _transition: TransitionOptions | null, _binds: BindEntry<TStateMap>[]);
|
|
140
|
+
input<S extends StandardSchemaV1>(schema: S): IlhaBuilder<StandardSchemaV1.InferOutput<S> & Record<string, unknown>, Record<string, never>, Record<string, never>, Record<string, never>>;
|
|
141
|
+
state<K extends string, V>(key: K, init: StateInit<TInput, V>): IlhaBuilder<TInput, TStateMap & Record<K, V>, TDerivedMap, TSlots>;
|
|
142
|
+
derived<K extends string, V>(key: K, fn: DerivedFn<TInput, TStateMap, V>): IlhaBuilder<TInput, TStateMap, TDerivedMap & Record<K, V>, TSlots>;
|
|
143
|
+
bind(selector: string, stateKey: keyof TStateMap & string): IlhaBuilder<TInput, TStateMap, TDerivedMap, TSlots>;
|
|
144
|
+
on(selectorOrCombined: string, callbackOrEventType: ((ctx: HandlerContext<TInput, TStateMap>) => void | Promise<void>) | string, handler?: (ctx: HandlerContext<TInput, TStateMap>) => void | Promise<void>): IlhaBuilder<TInput, TStateMap, TDerivedMap, TSlots>;
|
|
145
|
+
effect(fn: (ctx: EffectContext<TInput, TStateMap>) => (() => void) | void): IlhaBuilder<TInput, TStateMap, TDerivedMap, TSlots>;
|
|
146
|
+
slot<K extends string>(name: K, island: AnyIsland): IlhaBuilder<TInput, TStateMap, TDerivedMap, TSlots & Record<K, AnyIsland>>;
|
|
147
|
+
transition(opts: TransitionOptions): IlhaBuilder<TInput, TStateMap, TDerivedMap, TSlots>;
|
|
148
|
+
render(fn: (ctx: RenderContext<TInput, TStateMap, TDerivedMap, TSlots>) => string): Island<TInput, TStateMap>;
|
|
149
|
+
}
|
|
150
|
+
declare function ilhaFrom<TInput, TStateMap extends Record<string, unknown>>(selector: string | Element, island: Island<TInput, TStateMap>, props?: Partial<TInput>): (() => void) | null;
|
|
151
|
+
type IslandRegistry = Record<string, AnyIsland>;
|
|
152
|
+
declare function mountAll(registry: IslandRegistry, options?: MountOptions): MountResult;
|
|
153
|
+
declare const ilha: IlhaBuilder<Record<string, unknown>, Record<string, never>, Record<string, never>, Record<string, never>> & {
|
|
154
|
+
html: typeof ilhaHtml;
|
|
155
|
+
raw: typeof ilhaRaw;
|
|
156
|
+
mount: typeof mountAll;
|
|
157
|
+
from: typeof ilhaFrom;
|
|
158
|
+
context: typeof ilhaContext;
|
|
159
|
+
};
|
|
160
|
+
declare const html: typeof ilhaHtml;
|
|
161
|
+
declare const raw: typeof ilhaRaw;
|
|
162
|
+
declare const mount: typeof mountAll;
|
|
163
|
+
declare const from: typeof ilhaFrom;
|
|
164
|
+
declare const context: typeof ilhaContext;
|
|
165
|
+
//#endregion
|
|
166
|
+
export { DerivedValue, HandlerContext, Island, IslandDerived, IslandState, MountOptions, MountResult, SignalAccessor, SlotAccessor, context, ilha as default, from, html, mount, raw };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,658 @@
|
|
|
1
|
+
import { effect, setActiveSub, signal } from "alien-signals";
|
|
2
|
+
//#region src/index.ts
|
|
3
|
+
function validateSchema(schema, value) {
|
|
4
|
+
const result = schema["~standard"].validate(value);
|
|
5
|
+
if (result instanceof Promise) throw new Error("[ilha] Async schemas are not supported.");
|
|
6
|
+
if (result.issues) throw new Error(`[ilha] Validation failed:\n${result.issues.map((i) => ` - ${i.message}`).join("\n")}`);
|
|
7
|
+
return result.value;
|
|
8
|
+
}
|
|
9
|
+
function escapeHtml(value) {
|
|
10
|
+
return String(value).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
11
|
+
}
|
|
12
|
+
function dedentString(str) {
|
|
13
|
+
const lines = str.split("\n");
|
|
14
|
+
while (lines.length && lines[0].trim() === "") lines.shift();
|
|
15
|
+
while (lines.length && lines[lines.length - 1].trim() === "") lines.pop();
|
|
16
|
+
if (!lines.length) return "";
|
|
17
|
+
const indent = Math.min(...lines.filter((l) => l.trim() !== "").map((l) => l.match(/^(\s*)/)[1].length));
|
|
18
|
+
return lines.map((l) => l.slice(indent)).join("\n");
|
|
19
|
+
}
|
|
20
|
+
const RAW = Symbol("ilha.raw");
|
|
21
|
+
const SLOT_ACCESSOR = Symbol("ilha.slotAccessor");
|
|
22
|
+
const SIGNAL_ACCESSOR = Symbol("ilha.signalAccessor");
|
|
23
|
+
const SLOT_ATTR = "data-ilha-slot";
|
|
24
|
+
const SLOT_PROPS_ATTR = "data-ilha-props";
|
|
25
|
+
const STATE_ATTR = "data-ilha-state";
|
|
26
|
+
function makeSlotAccessor(render) {
|
|
27
|
+
const fn = (props) => ({
|
|
28
|
+
[RAW]: true,
|
|
29
|
+
value: render(props)
|
|
30
|
+
});
|
|
31
|
+
fn.toString = () => render(void 0);
|
|
32
|
+
fn[SLOT_ACCESSOR] = true;
|
|
33
|
+
return fn;
|
|
34
|
+
}
|
|
35
|
+
function isSlotAccessor(v) {
|
|
36
|
+
return typeof v === "function" && SLOT_ACCESSOR in v;
|
|
37
|
+
}
|
|
38
|
+
function markSignalAccessor(fn) {
|
|
39
|
+
fn[SIGNAL_ACCESSOR] = true;
|
|
40
|
+
return fn;
|
|
41
|
+
}
|
|
42
|
+
function isSignalAccessor(v) {
|
|
43
|
+
return typeof v === "function" && SIGNAL_ACCESSOR in v;
|
|
44
|
+
}
|
|
45
|
+
function ilhaRaw(value) {
|
|
46
|
+
return {
|
|
47
|
+
[RAW]: true,
|
|
48
|
+
value
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function ilhaHtml(strings, ...values) {
|
|
52
|
+
let result = "";
|
|
53
|
+
for (let i = 0; i < strings.length; i++) {
|
|
54
|
+
result += strings[i];
|
|
55
|
+
if (i < values.length) {
|
|
56
|
+
const v = values[i];
|
|
57
|
+
if (v == null) continue;
|
|
58
|
+
if (typeof v === "object" && RAW in v) result += v.value;
|
|
59
|
+
else if (isSlotAccessor(v)) result += v.toString();
|
|
60
|
+
else if (isSignalAccessor(v)) result += escapeHtml(v());
|
|
61
|
+
else if (typeof v === "function") result += escapeHtml(v());
|
|
62
|
+
else result += escapeHtml(v);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return dedentString(result);
|
|
66
|
+
}
|
|
67
|
+
const contextRegistry = /* @__PURE__ */ new Map();
|
|
68
|
+
function ilhaContext(key, initial) {
|
|
69
|
+
if (contextRegistry.has(key)) return contextRegistry.get(key);
|
|
70
|
+
const s = signal(initial);
|
|
71
|
+
const accessor = (...args) => {
|
|
72
|
+
if (args.length === 0) return s();
|
|
73
|
+
s(args[0]);
|
|
74
|
+
};
|
|
75
|
+
contextRegistry.set(key, accessor);
|
|
76
|
+
return accessor;
|
|
77
|
+
}
|
|
78
|
+
function makePlainDerived(entries, state, input) {
|
|
79
|
+
const derived = {};
|
|
80
|
+
for (const entry of entries) {
|
|
81
|
+
const result = entry.fn({
|
|
82
|
+
state,
|
|
83
|
+
input,
|
|
84
|
+
signal: new AbortController().signal
|
|
85
|
+
});
|
|
86
|
+
if (result instanceof Promise) derived[entry.key] = {
|
|
87
|
+
loading: true,
|
|
88
|
+
value: void 0,
|
|
89
|
+
error: void 0
|
|
90
|
+
};
|
|
91
|
+
else derived[entry.key] = {
|
|
92
|
+
loading: false,
|
|
93
|
+
value: result,
|
|
94
|
+
error: void 0
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
return derived;
|
|
98
|
+
}
|
|
99
|
+
function buildDerivedSignals(entries, state, input) {
|
|
100
|
+
const envelopes = /* @__PURE__ */ new Map();
|
|
101
|
+
const stops = [];
|
|
102
|
+
for (const entry of entries) {
|
|
103
|
+
const env = signal({
|
|
104
|
+
loading: true,
|
|
105
|
+
value: void 0,
|
|
106
|
+
error: void 0
|
|
107
|
+
});
|
|
108
|
+
envelopes.set(entry.key, env);
|
|
109
|
+
let ac = new AbortController();
|
|
110
|
+
const stopEffect = effect(() => {
|
|
111
|
+
ac.abort();
|
|
112
|
+
ac = new AbortController();
|
|
113
|
+
const currentAc = ac;
|
|
114
|
+
const result = entry.fn({
|
|
115
|
+
state,
|
|
116
|
+
input,
|
|
117
|
+
signal: currentAc.signal
|
|
118
|
+
});
|
|
119
|
+
if (!(result instanceof Promise)) {
|
|
120
|
+
const prevSub = setActiveSub(void 0);
|
|
121
|
+
env({
|
|
122
|
+
loading: false,
|
|
123
|
+
value: result,
|
|
124
|
+
error: void 0
|
|
125
|
+
});
|
|
126
|
+
setActiveSub(prevSub);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const prevSub = setActiveSub(void 0);
|
|
130
|
+
env({
|
|
131
|
+
loading: true,
|
|
132
|
+
value: env().value,
|
|
133
|
+
error: void 0
|
|
134
|
+
});
|
|
135
|
+
setActiveSub(prevSub);
|
|
136
|
+
result.then((value) => {
|
|
137
|
+
if (currentAc.signal.aborted) return;
|
|
138
|
+
env({
|
|
139
|
+
loading: false,
|
|
140
|
+
value,
|
|
141
|
+
error: void 0
|
|
142
|
+
});
|
|
143
|
+
}).catch((err) => {
|
|
144
|
+
if (currentAc.signal.aborted) return;
|
|
145
|
+
env({
|
|
146
|
+
loading: false,
|
|
147
|
+
value: void 0,
|
|
148
|
+
error: err instanceof Error ? err : new Error(String(err))
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
stops.push(() => {
|
|
153
|
+
stopEffect();
|
|
154
|
+
ac.abort();
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
proxy: new Proxy({}, { get(_, key) {
|
|
159
|
+
const env = envelopes.get(key);
|
|
160
|
+
if (!env) return {
|
|
161
|
+
loading: false,
|
|
162
|
+
value: void 0,
|
|
163
|
+
error: void 0
|
|
164
|
+
};
|
|
165
|
+
return env();
|
|
166
|
+
} }),
|
|
167
|
+
stop: () => stops.forEach((s) => s())
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
function resolveBindConfig(el) {
|
|
171
|
+
const tag = el.tagName.toLowerCase();
|
|
172
|
+
const type = el.type?.toLowerCase() ?? "";
|
|
173
|
+
if (tag === "input" && type === "checkbox") return {
|
|
174
|
+
prop: "checked",
|
|
175
|
+
event: "change",
|
|
176
|
+
read: (el) => el.checked,
|
|
177
|
+
write: (el, v) => el.checked = Boolean(v)
|
|
178
|
+
};
|
|
179
|
+
if (tag === "input" && type === "radio") return {
|
|
180
|
+
prop: "checked",
|
|
181
|
+
event: "change",
|
|
182
|
+
read: (el) => {
|
|
183
|
+
const input = el;
|
|
184
|
+
return input.checked ? input.value : void 0;
|
|
185
|
+
},
|
|
186
|
+
write: (el, v) => {
|
|
187
|
+
const input = el;
|
|
188
|
+
input.checked = String(v ?? "") === input.value;
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
if (tag === "input" && type === "number") return {
|
|
192
|
+
prop: "valueAsNumber",
|
|
193
|
+
event: "input",
|
|
194
|
+
read: (el) => el.valueAsNumber,
|
|
195
|
+
write: (el, v) => el.value = String(v ?? "")
|
|
196
|
+
};
|
|
197
|
+
return {
|
|
198
|
+
prop: "value",
|
|
199
|
+
event: tag === "select" ? "change" : "input",
|
|
200
|
+
read: (el) => el.value,
|
|
201
|
+
write: (el, v) => el.value = String(v ?? "")
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
function applyBindings(el, bindings, state) {
|
|
205
|
+
const cleanups = [];
|
|
206
|
+
for (const binding of bindings) {
|
|
207
|
+
const targets = binding.selector === "" ? [el] : Array.from(el.querySelectorAll(binding.selector));
|
|
208
|
+
for (const target of targets) {
|
|
209
|
+
const { event, read, write } = resolveBindConfig(target);
|
|
210
|
+
const accessor = state[binding.stateKey];
|
|
211
|
+
const input = target;
|
|
212
|
+
const isRadio = input.tagName.toLowerCase() === "input" && input.type?.toLowerCase() === "radio";
|
|
213
|
+
write(target, accessor());
|
|
214
|
+
const listener = () => {
|
|
215
|
+
const raw = read(target);
|
|
216
|
+
if (isRadio && raw === void 0) return;
|
|
217
|
+
const currentVal = accessor();
|
|
218
|
+
let value;
|
|
219
|
+
if (typeof currentVal === "number") {
|
|
220
|
+
const n = Number(raw);
|
|
221
|
+
value = isNaN(n) ? 0 : n;
|
|
222
|
+
} else if (typeof currentVal === "boolean") value = Boolean(raw);
|
|
223
|
+
else value = raw;
|
|
224
|
+
accessor(value);
|
|
225
|
+
};
|
|
226
|
+
target.addEventListener(event, listener);
|
|
227
|
+
cleanups.push(() => target.removeEventListener(event, listener));
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return () => cleanups.forEach((c) => c());
|
|
231
|
+
}
|
|
232
|
+
function parseOnArgs(selectorOrCombined, callbackOrEventType) {
|
|
233
|
+
let selector;
|
|
234
|
+
let rawEvent;
|
|
235
|
+
if (typeof callbackOrEventType === "function") {
|
|
236
|
+
const atIdx = selectorOrCombined.lastIndexOf("@");
|
|
237
|
+
if (atIdx === -1) {
|
|
238
|
+
selector = "";
|
|
239
|
+
rawEvent = selectorOrCombined.startsWith("@") ? selectorOrCombined.slice(1) : selectorOrCombined;
|
|
240
|
+
} else {
|
|
241
|
+
selector = selectorOrCombined.slice(0, atIdx);
|
|
242
|
+
rawEvent = selectorOrCombined.slice(atIdx + 1);
|
|
243
|
+
}
|
|
244
|
+
} else {
|
|
245
|
+
selector = selectorOrCombined;
|
|
246
|
+
rawEvent = callbackOrEventType;
|
|
247
|
+
}
|
|
248
|
+
const parts = rawEvent.split(":");
|
|
249
|
+
const eventType = parts[0];
|
|
250
|
+
const modifiers = new Set(parts.slice(1));
|
|
251
|
+
const options = {
|
|
252
|
+
once: modifiers.has("once"),
|
|
253
|
+
capture: modifiers.has("capture"),
|
|
254
|
+
passive: modifiers.has("passive")
|
|
255
|
+
};
|
|
256
|
+
return {
|
|
257
|
+
selector,
|
|
258
|
+
eventType,
|
|
259
|
+
options
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
var IlhaBuilder = class IlhaBuilder {
|
|
263
|
+
constructor(_schema, _states, _deriveds, _ons, _effects, _slots, _transition, _binds) {
|
|
264
|
+
this._schema = _schema;
|
|
265
|
+
this._states = _states;
|
|
266
|
+
this._deriveds = _deriveds;
|
|
267
|
+
this._ons = _ons;
|
|
268
|
+
this._effects = _effects;
|
|
269
|
+
this._slots = _slots;
|
|
270
|
+
this._transition = _transition;
|
|
271
|
+
this._binds = _binds;
|
|
272
|
+
}
|
|
273
|
+
input(schema) {
|
|
274
|
+
return new IlhaBuilder(schema, [], [], [], [], {}, null, []);
|
|
275
|
+
}
|
|
276
|
+
state(key, init) {
|
|
277
|
+
return new IlhaBuilder(this._schema, [...this._states, {
|
|
278
|
+
key,
|
|
279
|
+
init
|
|
280
|
+
}], this._deriveds, this._ons, this._effects, this._slots, this._transition, this._binds);
|
|
281
|
+
}
|
|
282
|
+
derived(key, fn) {
|
|
283
|
+
return new IlhaBuilder(this._schema, this._states, [...this._deriveds, {
|
|
284
|
+
key,
|
|
285
|
+
fn
|
|
286
|
+
}], this._ons, this._effects, this._slots, this._transition, this._binds);
|
|
287
|
+
}
|
|
288
|
+
bind(selector, stateKey) {
|
|
289
|
+
return new IlhaBuilder(this._schema, this._states, this._deriveds, this._ons, this._effects, this._slots, this._transition, [...this._binds, {
|
|
290
|
+
selector,
|
|
291
|
+
stateKey
|
|
292
|
+
}]);
|
|
293
|
+
}
|
|
294
|
+
on(selectorOrCombined, callbackOrEventType, handler) {
|
|
295
|
+
const parsed = parseOnArgs(selectorOrCombined, callbackOrEventType);
|
|
296
|
+
const resolvedHandler = typeof callbackOrEventType === "function" ? callbackOrEventType : handler;
|
|
297
|
+
return new IlhaBuilder(this._schema, this._states, this._deriveds, [...this._ons, {
|
|
298
|
+
selector: parsed.selector,
|
|
299
|
+
event: parsed.eventType,
|
|
300
|
+
options: parsed.options,
|
|
301
|
+
handler: resolvedHandler
|
|
302
|
+
}], this._effects, this._slots, this._transition, this._binds);
|
|
303
|
+
}
|
|
304
|
+
effect(fn) {
|
|
305
|
+
return new IlhaBuilder(this._schema, this._states, this._deriveds, this._ons, [...this._effects, { fn }], this._slots, this._transition, this._binds);
|
|
306
|
+
}
|
|
307
|
+
slot(name, island) {
|
|
308
|
+
return new IlhaBuilder(this._schema, this._states, this._deriveds, this._ons, this._effects, {
|
|
309
|
+
...this._slots,
|
|
310
|
+
[name]: island
|
|
311
|
+
}, this._transition, this._binds);
|
|
312
|
+
}
|
|
313
|
+
transition(opts) {
|
|
314
|
+
return new IlhaBuilder(this._schema, this._states, this._deriveds, this._ons, this._effects, this._slots, opts, this._binds);
|
|
315
|
+
}
|
|
316
|
+
render(fn) {
|
|
317
|
+
const schema = this._schema;
|
|
318
|
+
const states = this._states;
|
|
319
|
+
const deriveds = this._deriveds;
|
|
320
|
+
const ons = this._ons;
|
|
321
|
+
const effects = this._effects;
|
|
322
|
+
const slotDefs = this._slots;
|
|
323
|
+
const transition = this._transition;
|
|
324
|
+
const binds = this._binds;
|
|
325
|
+
function resolveInput(props) {
|
|
326
|
+
const value = props ?? {};
|
|
327
|
+
if (!schema) return value;
|
|
328
|
+
return validateSchema(schema, value);
|
|
329
|
+
}
|
|
330
|
+
function makeSlotsProxy(ssr) {
|
|
331
|
+
return new Proxy({}, { get(_, prop) {
|
|
332
|
+
const name = String(prop);
|
|
333
|
+
if (!slotDefs[name]) return makeSlotAccessor(() => "");
|
|
334
|
+
if (ssr) return makeSlotAccessor((props) => slotDefs[name].toString(props));
|
|
335
|
+
return makeSlotAccessor((props) => {
|
|
336
|
+
const json = props ? ` data-props='${escapeHtml(JSON.stringify(props))}'` : "";
|
|
337
|
+
return `<div data-ilha-slot="${escapeHtml(name)}"${json}></div>`;
|
|
338
|
+
});
|
|
339
|
+
} });
|
|
340
|
+
}
|
|
341
|
+
function buildPlainState(input) {
|
|
342
|
+
const state = {};
|
|
343
|
+
for (const entry of states) {
|
|
344
|
+
const value = typeof entry.init === "function" ? entry.init(input) : entry.init;
|
|
345
|
+
const accessor = markSignalAccessor((...args) => {
|
|
346
|
+
if (args.length === 0) return value;
|
|
347
|
+
});
|
|
348
|
+
state[entry.key] = accessor;
|
|
349
|
+
}
|
|
350
|
+
return state;
|
|
351
|
+
}
|
|
352
|
+
function buildSignalState(input, snapshot) {
|
|
353
|
+
const state = {};
|
|
354
|
+
for (const entry of states) {
|
|
355
|
+
const s = signal(snapshot && entry.key in snapshot ? snapshot[entry.key] : typeof entry.init === "function" ? entry.init(input) : entry.init);
|
|
356
|
+
const accessor = markSignalAccessor((...args) => {
|
|
357
|
+
if (args.length === 0) return s();
|
|
358
|
+
s(args[0]);
|
|
359
|
+
});
|
|
360
|
+
state[entry.key] = accessor;
|
|
361
|
+
}
|
|
362
|
+
return state;
|
|
363
|
+
}
|
|
364
|
+
function renderToString(props) {
|
|
365
|
+
const input = resolveInput(props);
|
|
366
|
+
const state = buildPlainState(input);
|
|
367
|
+
const slots = makeSlotsProxy(true);
|
|
368
|
+
const results = deriveds.map((entry) => {
|
|
369
|
+
try {
|
|
370
|
+
return {
|
|
371
|
+
key: entry.key,
|
|
372
|
+
result: entry.fn({
|
|
373
|
+
state,
|
|
374
|
+
input,
|
|
375
|
+
signal: new AbortController().signal
|
|
376
|
+
})
|
|
377
|
+
};
|
|
378
|
+
} catch (err) {
|
|
379
|
+
return {
|
|
380
|
+
key: entry.key,
|
|
381
|
+
result: Promise.reject(err)
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
if (!results.some((r) => r.result instanceof Promise)) {
|
|
386
|
+
const derived = {};
|
|
387
|
+
for (const r of results) derived[r.key] = {
|
|
388
|
+
loading: false,
|
|
389
|
+
value: r.result,
|
|
390
|
+
error: void 0
|
|
391
|
+
};
|
|
392
|
+
return fn({
|
|
393
|
+
state,
|
|
394
|
+
derived,
|
|
395
|
+
input,
|
|
396
|
+
slots
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
return Promise.all(results.map(async (r) => {
|
|
400
|
+
try {
|
|
401
|
+
return {
|
|
402
|
+
key: r.key,
|
|
403
|
+
envelope: {
|
|
404
|
+
loading: false,
|
|
405
|
+
value: await Promise.resolve(r.result),
|
|
406
|
+
error: void 0
|
|
407
|
+
}
|
|
408
|
+
};
|
|
409
|
+
} catch (err) {
|
|
410
|
+
return {
|
|
411
|
+
key: r.key,
|
|
412
|
+
envelope: {
|
|
413
|
+
loading: false,
|
|
414
|
+
value: void 0,
|
|
415
|
+
error: err instanceof Error ? err : new Error(String(err))
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
})).then((resolved) => {
|
|
420
|
+
const derived = {};
|
|
421
|
+
for (const r of resolved) derived[r.key] = r.envelope;
|
|
422
|
+
return fn({
|
|
423
|
+
state,
|
|
424
|
+
derived,
|
|
425
|
+
input,
|
|
426
|
+
slots
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
function renderToStringSyncOnly(props) {
|
|
431
|
+
const input = resolveInput(props);
|
|
432
|
+
const state = buildPlainState(input);
|
|
433
|
+
const slots = makeSlotsProxy(true);
|
|
434
|
+
return fn({
|
|
435
|
+
state,
|
|
436
|
+
derived: makePlainDerived(deriveds, state, input),
|
|
437
|
+
input,
|
|
438
|
+
slots
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
function mountIsland(el, props) {
|
|
442
|
+
const input = resolveInput(props);
|
|
443
|
+
let snapshot;
|
|
444
|
+
const rawState = el.getAttribute(STATE_ATTR);
|
|
445
|
+
if (rawState) try {
|
|
446
|
+
snapshot = JSON.parse(rawState);
|
|
447
|
+
} catch {
|
|
448
|
+
console.warn("[ilha] Failed to parse data-ilha-state");
|
|
449
|
+
}
|
|
450
|
+
const state = buildSignalState(input, snapshot);
|
|
451
|
+
const cleanups = [];
|
|
452
|
+
if (transition?.enter) {
|
|
453
|
+
const result = transition.enter(el);
|
|
454
|
+
if (result instanceof Promise) result.catch(console.error);
|
|
455
|
+
}
|
|
456
|
+
const { proxy: derived, stop: stopDerived } = buildDerivedSignals(deriveds, state, input);
|
|
457
|
+
cleanups.push(stopDerived);
|
|
458
|
+
const slotEls = /* @__PURE__ */ new Map();
|
|
459
|
+
function snapshotSlots() {
|
|
460
|
+
slotEls.clear();
|
|
461
|
+
for (const name of Object.keys(slotDefs)) {
|
|
462
|
+
const existing = el.querySelector(`[${SLOT_ATTR}="${name}"]`);
|
|
463
|
+
if (existing) slotEls.set(name, existing);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
function restoreSlots() {
|
|
467
|
+
for (const [name, slotEl] of slotEls) {
|
|
468
|
+
const placeholder = el.querySelector(`[${SLOT_ATTR}="${name}"]`);
|
|
469
|
+
if (placeholder) placeholder.replaceWith(slotEl);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
const listeners = [];
|
|
473
|
+
const firedOnce = /* @__PURE__ */ new Set();
|
|
474
|
+
function attachListeners() {
|
|
475
|
+
for (const entry of ons) {
|
|
476
|
+
if (entry.options.once && firedOnce.has(entry)) continue;
|
|
477
|
+
(entry.selector === "" ? [el] : Array.from(el.querySelectorAll(entry.selector))).forEach((target) => {
|
|
478
|
+
const listener = (event) => {
|
|
479
|
+
if (entry.options.once) {
|
|
480
|
+
firedOnce.add(entry);
|
|
481
|
+
for (const l of listeners.filter((l) => l.entry === entry)) l.target.removeEventListener(l.type, l.fn, l.options);
|
|
482
|
+
listeners.splice(0, listeners.length, ...listeners.filter((l) => l.entry !== entry));
|
|
483
|
+
}
|
|
484
|
+
const result = entry.handler({
|
|
485
|
+
state,
|
|
486
|
+
input,
|
|
487
|
+
el,
|
|
488
|
+
event
|
|
489
|
+
});
|
|
490
|
+
if (result instanceof Promise) result.catch(console.error);
|
|
491
|
+
};
|
|
492
|
+
const opts = {
|
|
493
|
+
...entry.options,
|
|
494
|
+
once: false
|
|
495
|
+
};
|
|
496
|
+
target.addEventListener(entry.event, listener, opts);
|
|
497
|
+
listeners.push({
|
|
498
|
+
target,
|
|
499
|
+
type: entry.event,
|
|
500
|
+
fn: listener,
|
|
501
|
+
options: opts,
|
|
502
|
+
entry
|
|
503
|
+
});
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
function detachListeners() {
|
|
508
|
+
for (const l of listeners) l.target.removeEventListener(l.type, l.fn, l.options);
|
|
509
|
+
listeners.length = 0;
|
|
510
|
+
}
|
|
511
|
+
const slots = makeSlotsProxy(false);
|
|
512
|
+
el.innerHTML = fn({
|
|
513
|
+
state,
|
|
514
|
+
derived,
|
|
515
|
+
input,
|
|
516
|
+
slots
|
|
517
|
+
});
|
|
518
|
+
attachListeners();
|
|
519
|
+
let stopBindings = applyBindings(el, binds, state);
|
|
520
|
+
cleanups.push(() => stopBindings());
|
|
521
|
+
for (const [name, childIsland] of Object.entries(slotDefs)) {
|
|
522
|
+
const slotEl = el.querySelector(`[${SLOT_ATTR}="${name}"]`);
|
|
523
|
+
if (!slotEl) continue;
|
|
524
|
+
slotEls.set(name, slotEl);
|
|
525
|
+
let slotProps;
|
|
526
|
+
const ilhaProps = slotEl.getAttribute(SLOT_PROPS_ATTR);
|
|
527
|
+
const dataProps = slotEl.getAttribute("data-props");
|
|
528
|
+
if (ilhaProps) try {
|
|
529
|
+
slotProps = JSON.parse(ilhaProps);
|
|
530
|
+
} catch {
|
|
531
|
+
console.warn(`[ilha] Failed to parse ${SLOT_PROPS_ATTR} on [${SLOT_ATTR}="${name}"]`);
|
|
532
|
+
}
|
|
533
|
+
else if (dataProps) try {
|
|
534
|
+
slotProps = JSON.parse(dataProps);
|
|
535
|
+
} catch {
|
|
536
|
+
console.warn(`[ilha] Failed to parse data-props on [${SLOT_ATTR}="${name}"]`);
|
|
537
|
+
}
|
|
538
|
+
cleanups.push(childIsland.mount(slotEl, slotProps));
|
|
539
|
+
}
|
|
540
|
+
let initialized = false;
|
|
541
|
+
const stopRender = effect(() => {
|
|
542
|
+
const html = fn({
|
|
543
|
+
state,
|
|
544
|
+
derived,
|
|
545
|
+
input,
|
|
546
|
+
slots
|
|
547
|
+
});
|
|
548
|
+
if (!initialized) {
|
|
549
|
+
initialized = true;
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
snapshotSlots();
|
|
553
|
+
detachListeners();
|
|
554
|
+
stopBindings();
|
|
555
|
+
el.innerHTML = html;
|
|
556
|
+
restoreSlots();
|
|
557
|
+
attachListeners();
|
|
558
|
+
stopBindings = applyBindings(el, binds, state);
|
|
559
|
+
});
|
|
560
|
+
cleanups.push(stopRender);
|
|
561
|
+
cleanups.push(detachListeners);
|
|
562
|
+
for (const entry of effects) {
|
|
563
|
+
let userCleanup;
|
|
564
|
+
const stopEffect = effect(() => {
|
|
565
|
+
if (userCleanup) {
|
|
566
|
+
userCleanup();
|
|
567
|
+
userCleanup = void 0;
|
|
568
|
+
}
|
|
569
|
+
userCleanup = entry.fn({
|
|
570
|
+
state,
|
|
571
|
+
input,
|
|
572
|
+
el
|
|
573
|
+
});
|
|
574
|
+
});
|
|
575
|
+
cleanups.push(() => {
|
|
576
|
+
stopEffect();
|
|
577
|
+
if (userCleanup) userCleanup();
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
return () => {
|
|
581
|
+
if (transition?.leave) {
|
|
582
|
+
const result = transition.leave(el);
|
|
583
|
+
if (result instanceof Promise) {
|
|
584
|
+
result.then(() => cleanups.forEach((c) => c())).catch(console.error);
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
cleanups.forEach((c) => c());
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
const island = function(props) {
|
|
592
|
+
return renderToString(props);
|
|
593
|
+
};
|
|
594
|
+
island.toString = (props) => renderToStringSyncOnly(props);
|
|
595
|
+
island.mount = (el, props) => mountIsland(el, props);
|
|
596
|
+
return island;
|
|
597
|
+
}
|
|
598
|
+
};
|
|
599
|
+
function ilhaFrom(selector, island, props) {
|
|
600
|
+
const el = typeof selector === "string" ? document.querySelector(selector) : selector;
|
|
601
|
+
if (!el) {
|
|
602
|
+
console.warn(`[ilha] from(): element not found: ${selector}`);
|
|
603
|
+
return null;
|
|
604
|
+
}
|
|
605
|
+
return island.mount(el, props);
|
|
606
|
+
}
|
|
607
|
+
function mountAll(registry, options = {}) {
|
|
608
|
+
const root = options.root ?? document.body;
|
|
609
|
+
const lazy = options.lazy ?? false;
|
|
610
|
+
const hydrate = options.hydrate ?? false;
|
|
611
|
+
const unmounts = [];
|
|
612
|
+
function activateEl(el) {
|
|
613
|
+
const name = el.getAttribute("data-ilha");
|
|
614
|
+
if (!name) return;
|
|
615
|
+
const island = registry[name];
|
|
616
|
+
if (!island) return;
|
|
617
|
+
let props = {};
|
|
618
|
+
const rawProps = el.getAttribute("data-props");
|
|
619
|
+
if (rawProps) try {
|
|
620
|
+
props = JSON.parse(rawProps);
|
|
621
|
+
} catch {
|
|
622
|
+
console.warn(`[ilha] Failed to parse data-props on [data-ilha="${name}"]`);
|
|
623
|
+
}
|
|
624
|
+
if (hydrate) {
|
|
625
|
+
const snapshot = el.innerHTML;
|
|
626
|
+
const unmount = island.mount(el, props);
|
|
627
|
+
if (!el.innerHTML) el.innerHTML = snapshot;
|
|
628
|
+
unmounts.push(unmount);
|
|
629
|
+
} else unmounts.push(island.mount(el, props));
|
|
630
|
+
}
|
|
631
|
+
const els = Array.from(root.querySelectorAll("[data-ilha]"));
|
|
632
|
+
if (lazy && typeof IntersectionObserver !== "undefined") {
|
|
633
|
+
const io = new IntersectionObserver((entries) => {
|
|
634
|
+
for (const entry of entries) if (entry.isIntersecting) {
|
|
635
|
+
activateEl(entry.target);
|
|
636
|
+
io.unobserve(entry.target);
|
|
637
|
+
}
|
|
638
|
+
});
|
|
639
|
+
els.forEach((el) => io.observe(el));
|
|
640
|
+
unmounts.push(() => io.disconnect());
|
|
641
|
+
} else els.forEach(activateEl);
|
|
642
|
+
return { unmount: () => unmounts.forEach((u) => u()) };
|
|
643
|
+
}
|
|
644
|
+
const rootBuilder = new IlhaBuilder(null, [], [], [], [], {}, null, []);
|
|
645
|
+
const ilha = Object.assign(rootBuilder, {
|
|
646
|
+
html: ilhaHtml,
|
|
647
|
+
raw: ilhaRaw,
|
|
648
|
+
mount: mountAll,
|
|
649
|
+
from: ilhaFrom,
|
|
650
|
+
context: ilhaContext
|
|
651
|
+
});
|
|
652
|
+
const html = ilhaHtml;
|
|
653
|
+
const raw = ilhaRaw;
|
|
654
|
+
const mount = mountAll;
|
|
655
|
+
const from = ilhaFrom;
|
|
656
|
+
const context = ilhaContext;
|
|
657
|
+
//#endregion
|
|
658
|
+
export { context, ilha as default, from, html, mount, raw };
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ilha",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "A tiny, framework-free island architecture library",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Ryuz <ryuzer@proton.me>",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/guarana-studio/ilha.git"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist"
|
|
13
|
+
],
|
|
14
|
+
"type": "module",
|
|
15
|
+
"module": "src/index.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"import": "./dist/index.js"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsc && tsdown",
|
|
24
|
+
"test": "bun test",
|
|
25
|
+
"playground": "bun playground/index.html",
|
|
26
|
+
"lint": "oxlint",
|
|
27
|
+
"fmt": "oxfmt",
|
|
28
|
+
"prepare": "husky",
|
|
29
|
+
"docs:dev": "bunx docsome dev DOCS.md",
|
|
30
|
+
"docs:build": "bunx docsome build DOCS.md --outDir docs_dist"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"alien-signals": "^3.1.2"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@happy-dom/global-registrator": "^20.8.7",
|
|
37
|
+
"@types/bun": "latest",
|
|
38
|
+
"docsome": "^0.0.6",
|
|
39
|
+
"husky": "^9.1.7",
|
|
40
|
+
"oxfmt": "^0.41.0",
|
|
41
|
+
"oxlint": "^1.56.0",
|
|
42
|
+
"tsdown": "^0.21.4",
|
|
43
|
+
"zod": "^4.3.6"
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"typescript": "^5"
|
|
47
|
+
}
|
|
48
|
+
}
|