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 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
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
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
+ }