ilha 0.2.0 → 0.3.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 CHANGED
@@ -25,7 +25,7 @@ const Counter = ilha
25
25
  .render(
26
26
  ({ state }) => html`
27
27
  <div>
28
- <p>Count: ${state.count}</p>
28
+ <p>Count: ${state.count()}</p>
29
29
  <button>Increment</button>
30
30
  </div>
31
31
  `,
@@ -50,11 +50,21 @@ State is managed with signals — when a signal changes, only the affected islan
50
50
 
51
51
  ## Builder API
52
52
 
53
- Every island starts from the `ilha` builder object (or `ilha.input()` if you need typed props).
53
+ Every island starts from the `ilha` builder object (or `ilha.input<T>()` / `ilha.input(schema)` if you need typed props).
54
54
 
55
- ### `ilha.input(schema)`
55
+ ### `ilha.input<T>()` / `ilha.input(schema)`
56
56
 
57
- Declares the island's external input type using any [Standard Schema](https://standardschema.dev/) compatible validator (e.g. Zod, Valibot, ArkType).
57
+ Declares the island's external input type. Two forms:
58
+
59
+ **1. Type-only (no runtime validation):**
60
+
61
+ ```ts
62
+ const MyIsland = ilha
63
+ .input<{ name: string }>()
64
+ .render(({ input }) => `<p>Hello, ${input.name}!</p>`);
65
+ ```
66
+
67
+ **2. With a [Standard Schema](https://standardschema.dev/) validator** (Zod, Valibot, ArkType, etc.) — runs validation at render time and uses the schema's inferred output type:
58
68
 
59
69
  ```ts
60
70
  import { z } from "zod";
@@ -279,23 +289,51 @@ ilha.css(styles).render(() => `<div class="card">…</div>`);
279
289
 
280
290
  ---
281
291
 
282
- ### `.slot(name, island)`
292
+ ### Composing Islands
283
293
 
284
- Embeds a child island as a named slot. The child island is mounted and managed independently. During SSR the slot renders the child's HTML inline; during client mount the child island is activated for interactivity.
294
+ Child islands are interpolated directly inside a parent's html&grave;&grave; template. During SSR the child's HTML is rendered inline; during client mount the child is activated independently inside its own host element.
285
295
 
286
296
  ```ts
287
297
  const Icon = ilha.render(() => `<svg>…</svg>`);
288
298
 
289
- const Card = ilha.slot("icon", Icon).render(
290
- ({ slots }) => html`
299
+ const Card = ilha.render(
300
+ () => html`
291
301
  <div class="card">
292
- ${slots.icon()}
302
+ ${Icon}
293
303
  <p>Card content</p>
294
304
  </div>
295
305
  `,
296
306
  );
297
307
  ```
298
308
 
309
+ **Passing props** — call the child island with a props object:
310
+
311
+ ```ts
312
+ const Badge = ilha
313
+ .input(z.object({ label: z.string(), color: z.string().default("teal") }))
314
+ .render(({ input }) => html`<span style="background:${input.color}">${input.label}</span>`);
315
+
316
+ const Card = ilha.render(
317
+ () => html`
318
+ <div>
319
+ ${Badge({ label: "New", color: "coral" })}
320
+ <p>Content</p>
321
+ </div>
322
+ `,
323
+ );
324
+ ```
325
+
326
+ **Keyed children** — use `.key()` when a child may reorder or appear conditionally. Keys must be unique within a parent render:
327
+
328
+ ```ts
329
+ const List = ilha.render(
330
+ () =>
331
+ html`<ul>
332
+ ${items.map((item) => html`<li>${Item.key(item.id)({ name: item.name })}</li>`)}
333
+ </ul>`,
334
+ );
335
+ ```
336
+
299
337
  ---
300
338
 
301
339
  ### `.transition(opts)`
@@ -321,7 +359,7 @@ The `leave` transition is awaited before cleanup runs.
321
359
 
322
360
  ### `.render(fn)`
323
361
 
324
- Finalises the builder and returns an `Island`. The render function receives `{ state, derived, input, slots }` and must return a string or `RawHtml`.
362
+ Finalises the builder and returns an `Island`. The render function receives `{ state, derived, input }` and must return a string or `RawHtml`.
325
363
 
326
364
  ```ts
327
365
  const MyIsland = ilha.state("x", 1).render(({ state, input }) => html`<p>${state.x}</p>`);
@@ -370,13 +408,13 @@ Async method that renders the island wrapped in a `data-ilha` hydration containe
370
408
  const html = await MyIsland.hydratable(
371
409
  { name: "Ilha" },
372
410
  {
373
- name: "my-island", // registry key for client-side activation
411
+ name: "MyIsland", // registry key for client-side activation
374
412
  as: "div", // wrapper tag (default: "div")
375
413
  snapshot: true, // embed state + derived as data-ilha-state
376
414
  skipOnMount: false, // skip onMount on hydration (default: true when snapshot)
377
415
  },
378
416
  );
379
- // → '<div data-ilha="my-island" data-ilha-props="…" data-ilha-state="…">…</div>'
417
+ // → '<div data-ilha="MyIsland" data-ilha-props="…" data-ilha-state="…">…</div>'
380
418
  ```
381
419
 
382
420
  **`snapshot` option:**
@@ -461,6 +499,7 @@ Interpolation rules:
461
499
  | `raw(str)` | Inserted as-is (no escaping) |
462
500
  | `html\`…\`` | Inserted as-is (already safe) |
463
501
  | Signal accessor | Called and escaped |
502
+ | Island / Island call | Emitted as `data-ilha-slot` host element |
464
503
  | Array | Each item processed recursively (no commas) |
465
504
 
466
505
  **List rendering pattern:**
@@ -521,20 +560,6 @@ const styles = css`
521
560
 
522
561
  ---
523
562
 
524
- ### `type(coerce?)`
525
-
526
- Creates a lightweight Standard Schema validator for use with `.input()` — useful when you don't want a full validation library.
527
-
528
- ```ts
529
- import { type } from "ilha";
530
-
531
- const MyIsland = ilha
532
- .input(type((v: unknown) => v as { count: number }))
533
- .render(({ input }) => `<p>${input.count}</p>`);
534
- ```
535
-
536
- ---
537
-
538
563
  ## SSR + Hydration
539
564
 
540
565
  The recommended SSR + hydration pattern uses `.hydratable()` on the server and `ilha.mount()` on the client.
@@ -555,7 +580,7 @@ return `<!doctype html><html><body>${html}</body></html>`;
555
580
  import { mount } from "ilha";
556
581
  import { MyIsland } from "./islands";
557
582
 
558
- mount({ "my-island": MyIsland });
583
+ mount({ MyIsland });
559
584
  ```
560
585
 
561
586
  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.
@@ -565,7 +590,7 @@ The client reads `data-ilha-state` to restore signal values from the snapshot, s
565
590
  ```
566
591
  server client
567
592
  ────────────────────────────────────── ──────────────────────────────────────────
568
- .hydratable({ count: 42 }, { mount({ "my-island": MyIsland })
593
+ .hydratable({ count: 42 }, { mount({ MyIsland })
569
594
  name: "my-island", → reads data-ilha-state
570
595
  snapshot: true → restores signals from snapshot
571
596
  }) → skips onMount (skipOnMount: true)
@@ -585,7 +610,7 @@ import type {
585
610
  IslandState,
586
611
  IslandDerived,
587
612
  DerivedValue,
588
- SlotAccessor,
613
+ KeyedIsland,
589
614
  HydratableOptions,
590
615
  OnMountContext,
591
616
  HandlerContext,
package/dist/index.d.ts CHANGED
@@ -31,16 +31,18 @@ declare namespace StandardSchemaV1 {
31
31
  type InferOutput<Schema extends StandardSchemaV1> = NonNullable<Schema["~standard"]["types"]>["output"];
32
32
  }
33
33
  declare const RAW: unique symbol;
34
- declare const SLOT_ACCESSOR: unique symbol;
35
34
  declare const SIGNAL_ACCESSOR: unique symbol;
35
+ declare const ISLAND: unique symbol;
36
+ declare const ISLAND_CALL: unique symbol;
36
37
  interface RawHtml {
37
38
  [RAW]: true;
38
39
  value: string;
39
40
  }
40
- interface SlotAccessor<TProps = Record<string, unknown>> {
41
- (props?: TProps): RawHtml;
42
- toString(): string;
43
- [SLOT_ACCESSOR]: true;
41
+ interface IslandCall {
42
+ [ISLAND_CALL]: true;
43
+ island: AnyIsland;
44
+ props: Record<string, unknown> | undefined;
45
+ key: string | undefined;
44
46
  }
45
47
  interface MarkedSignalAccessor<T> {
46
48
  (): T;
@@ -96,16 +98,18 @@ interface Island<TInput = Record<string, unknown>, _TStateMap extends Record<str
96
98
  toString(props?: Partial<TInput>): string;
97
99
  mount(host: Element, props?: Partial<TInput>): () => void;
98
100
  hydratable(props: Partial<TInput>, options: HydratableOptions): Promise<string>;
101
+ key(key: string): KeyedIsland<TInput>;
102
+ [ISLAND]: true;
103
+ }
104
+ interface KeyedIsland<TInput> {
105
+ (props?: Partial<TInput>): IslandCall;
106
+ [ISLAND_CALL]: true;
99
107
  }
100
- type InferIslandInput<T> = T extends Island<infer TInput, any> ? TInput : Record<string, unknown>;
101
108
  type AnyIsland = Island<any, any>;
102
- type SlotMap = Record<string, AnyIsland>;
103
- type SlotsProxy<TSlots extends SlotMap> = { readonly [K in keyof TSlots]: SlotAccessor<Partial<InferIslandInput<TSlots[K]>>> };
104
- type RenderContext<TInput, TStateMap extends Record<string, unknown>, TDerivedMap extends Record<string, unknown>, TSlots extends SlotMap> = {
109
+ type RenderContext<TInput, TStateMap extends Record<string, unknown>, TDerivedMap extends Record<string, unknown>> = {
105
110
  state: IslandState<TStateMap>;
106
111
  derived: IslandDerived<TDerivedMap>;
107
112
  input: TInput;
108
- slots: SlotsProxy<TSlots>;
109
113
  };
110
114
  type EffectContext<TInput, TStateMap extends Record<string, unknown>> = {
111
115
  state: IslandState<TStateMap>;
@@ -167,46 +171,44 @@ interface MountOptions {
167
171
  interface MountResult {
168
172
  unmount: () => void;
169
173
  }
170
- interface BuilderConfig<TInput, TStateMap extends Record<string, unknown>, TDerivedMap extends Record<string, unknown>, _TSlots extends SlotMap> {
174
+ interface BuilderConfig<TInput, TStateMap extends Record<string, unknown>, TDerivedMap extends Record<string, unknown>> {
171
175
  schema: StandardSchemaV1 | null;
172
176
  states: StateEntry<TInput>[];
173
177
  deriveds: DerivedEntry<TInput, TStateMap>[];
174
178
  ons: OnEntry<TInput, TStateMap, TDerivedMap>[];
175
179
  effects: EffectEntry<TInput, TStateMap>[];
176
180
  onMounts: OnMountEntry<TInput, TStateMap, TDerivedMap>[];
177
- slots: Record<string, AnyIsland>;
178
181
  transition: TransitionOptions | null;
179
182
  binds: BindEntry<TStateMap>[];
180
183
  css: string | null;
181
184
  }
182
- 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>> {
183
- readonly _cfg: BuilderConfig<TInput, TStateMap, TDerivedMap, TSlots>;
184
- constructor(cfg: BuilderConfig<TInput, TStateMap, TDerivedMap, TSlots>);
185
- input<S extends StandardSchemaV1>(schema: S): IlhaBuilder<StandardSchemaV1.InferOutput<S> & Record<string, unknown>, Record<string, never>, Record<string, never>, Record<string, never>>;
186
- state<V = undefined, K extends string = string>(key: K, init?: StateInit<TInput, V> | undefined): IlhaBuilder<TInput, MergeState<TStateMap, K, V>, TDerivedMap, TSlots>;
187
- derived<K extends string, V>(key: K, fn: DerivedFn<TInput, TStateMap, V>): IlhaBuilder<TInput, TStateMap, TDerivedMap & Record<K, V>, TSlots>;
188
- bind(selector: string, stateKey: keyof TStateMap & string): IlhaBuilder<TInput, TStateMap, TDerivedMap, TSlots>;
189
- bind<T>(selector: string, externalSignal: ExternalSignal<T>): IlhaBuilder<TInput, TStateMap, TDerivedMap, TSlots>;
190
- on<S extends OnSelectorString>(selectorOrCombined: S, handler: (ctx: S extends `${string}@${infer E}:${string}` ? HandlerContextFor<TInput, TStateMap, E, TDerivedMap> : S extends `${string}@${infer E}` ? HandlerContextFor<TInput, TStateMap, E, TDerivedMap> : HandlerContext<TInput, TStateMap, TDerivedMap>) => void | Promise<void>): IlhaBuilder<TInput, TStateMap, TDerivedMap, TSlots>;
191
- on(selectorOrCombined: string, handler: (ctx: HandlerContext<TInput, TStateMap, TDerivedMap>) => void | Promise<void>): IlhaBuilder<TInput, TStateMap, TDerivedMap, TSlots>;
192
- effect(fn: (ctx: EffectContext<TInput, TStateMap>) => (() => void) | void): IlhaBuilder<TInput, TStateMap, TDerivedMap, TSlots>;
193
- onMount(fn: (ctx: OnMountContext<TInput, TStateMap, TDerivedMap>) => (() => void) | void): IlhaBuilder<TInput, TStateMap, TDerivedMap, TSlots>;
194
- slot<K extends string, I extends AnyIsland>(name: K, island: I): IlhaBuilder<TInput, TStateMap, TDerivedMap, TSlots & Record<K, I>>;
195
- transition(opts: TransitionOptions): IlhaBuilder<TInput, TStateMap, TDerivedMap, TSlots>;
196
- css(strings: TemplateStringsArray | string, ...values: (string | number)[]): IlhaBuilder<TInput, TStateMap, TDerivedMap, TSlots>;
197
- render(fn: (ctx: RenderContext<TInput, TStateMap, TDerivedMap, TSlots>) => string | RawHtml): Island<TInput, TStateMap>;
185
+ declare class IlhaBuilder<TInput extends Record<string, unknown>, TStateMap extends Record<string, unknown>, TDerivedMap extends Record<string, unknown> = Record<string, never>> {
186
+ readonly _cfg: BuilderConfig<TInput, TStateMap, TDerivedMap>;
187
+ constructor(cfg: BuilderConfig<TInput, TStateMap, TDerivedMap>);
188
+ input<T extends Record<string, unknown>>(): IlhaBuilder<T, Record<string, never>, Record<string, never>>;
189
+ input<S extends StandardSchemaV1>(schema: S): IlhaBuilder<StandardSchemaV1.InferOutput<S> & Record<string, unknown>, Record<string, never>, Record<string, never>>;
190
+ state<V = undefined, K extends string = string>(key: K, init?: StateInit<TInput, V> | undefined): IlhaBuilder<TInput, MergeState<TStateMap, K, V>, TDerivedMap>;
191
+ derived<K extends string, V>(key: K, fn: DerivedFn<TInput, TStateMap, V>): IlhaBuilder<TInput, TStateMap, TDerivedMap & Record<K, V>>;
192
+ bind(selector: string, stateKey: keyof TStateMap & string): IlhaBuilder<TInput, TStateMap, TDerivedMap>;
193
+ bind<T>(selector: string, externalSignal: ExternalSignal<T>): IlhaBuilder<TInput, TStateMap, TDerivedMap>;
194
+ on<S extends OnSelectorString>(selectorOrCombined: S, handler: (ctx: S extends `${string}@${infer E}:${string}` ? HandlerContextFor<TInput, TStateMap, E, TDerivedMap> : S extends `${string}@${infer E}` ? HandlerContextFor<TInput, TStateMap, E, TDerivedMap> : HandlerContext<TInput, TStateMap, TDerivedMap>) => void | Promise<void>): IlhaBuilder<TInput, TStateMap, TDerivedMap>;
195
+ on(selectorOrCombined: string, handler: (ctx: HandlerContext<TInput, TStateMap, TDerivedMap>) => void | Promise<void>): IlhaBuilder<TInput, TStateMap, TDerivedMap>;
196
+ effect(fn: (ctx: EffectContext<TInput, TStateMap>) => (() => void) | void): IlhaBuilder<TInput, TStateMap, TDerivedMap>;
197
+ onMount(fn: (ctx: OnMountContext<TInput, TStateMap, TDerivedMap>) => (() => void) | void): IlhaBuilder<TInput, TStateMap, TDerivedMap>;
198
+ transition(opts: TransitionOptions): IlhaBuilder<TInput, TStateMap, TDerivedMap>;
199
+ css(strings: TemplateStringsArray | string, ...values: (string | number)[]): IlhaBuilder<TInput, TStateMap, TDerivedMap>;
200
+ render(fn: (ctx: RenderContext<TInput, TStateMap, TDerivedMap>) => string | RawHtml): Island<TInput, TStateMap>;
198
201
  }
199
202
  declare function ilhaFrom<TInput, TStateMap extends Record<string, unknown>>(selector: string | Element, island: Island<TInput, TStateMap>, props?: Partial<TInput>): (() => void) | null;
200
203
  type IslandRegistry = Record<string, AnyIsland>;
201
204
  declare function mountAll(registry: IslandRegistry, options?: MountOptions): MountResult;
202
- declare const ilha: IlhaBuilder<Record<string, unknown>, Record<string, never>, Record<string, never>, Record<string, never>> & {
205
+ declare const ilha: IlhaBuilder<Record<string, unknown>, Record<string, never>, Record<string, never>> & {
203
206
  html: typeof ilhaHtml;
204
207
  raw: typeof ilhaRaw;
205
208
  mount: typeof mountAll;
206
209
  from: typeof ilhaFrom;
207
210
  context: typeof ilhaContext;
208
211
  };
209
- declare function type<TInput, TOutput = TInput>(coerce?: (input: TInput) => TOutput): StandardSchemaV1<TInput, TOutput>;
210
212
  declare const html: typeof ilhaHtml;
211
213
  declare const raw: typeof ilhaRaw;
212
214
  declare const css: typeof ilhaCss;
@@ -214,4 +216,4 @@ declare const mount: typeof mountAll;
214
216
  declare const from: typeof ilhaFrom;
215
217
  declare const context: typeof ilhaContext;
216
218
  //#endregion
217
- export { DerivedValue, HandlerContext, HandlerContextFor, HydratableOptions, Island, IslandDerived, IslandState, MountOptions, MountResult, OnMountContext, RawHtml, SignalAccessor, SlotAccessor, context, css, ilha as default, from, html, mount, raw, type };
219
+ export { DerivedValue, HandlerContext, HandlerContextFor, HydratableOptions, Island, IslandDerived, IslandState, KeyedIsland, MountOptions, MountResult, OnMountContext, RawHtml, SignalAccessor, context, css, ilha as default, from, html, mount, raw };
package/dist/index.js CHANGED
@@ -77,8 +77,9 @@ function dedentString(str) {
77
77
  return lines.map((l) => l.slice(indent)).join("\n");
78
78
  }
79
79
  const RAW = Symbol("ilha.raw");
80
- const SLOT_ACCESSOR = Symbol("ilha.slotAccessor");
81
80
  const SIGNAL_ACCESSOR = Symbol("ilha.signalAccessor");
81
+ const ISLAND = Symbol("ilha.island");
82
+ const ISLAND_CALL = Symbol("ilha.islandCall");
82
83
  const SLOT_ATTR = "data-ilha-slot";
83
84
  const PROPS_ATTR = "data-ilha-props";
84
85
  const STATE_ATTR = "data-ilha-state";
@@ -86,17 +87,70 @@ const CSS_ATTR = "data-ilha-css";
86
87
  function buildScopedStyle(css) {
87
88
  return `<style ${CSS_ATTR}>@scope (:scope) to ([data-ilha]){${css}}</style>`;
88
89
  }
89
- function makeSlotAccessor(render) {
90
- const fn = (props) => ({
91
- [RAW]: true,
92
- value: render(props)
90
+ const renderCtxStack = [];
91
+ function pushRenderCtx(liveHost, asyncChildren) {
92
+ const ctx = {
93
+ slots: /* @__PURE__ */ new Map(),
94
+ positional: 0,
95
+ liveHost,
96
+ pending: asyncChildren ? /* @__PURE__ */ new Map() : void 0
97
+ };
98
+ renderCtxStack.push(ctx);
99
+ return ctx;
100
+ }
101
+ function popRenderCtx() {
102
+ renderCtxStack.pop();
103
+ }
104
+ function currentRenderCtx() {
105
+ return renderCtxStack[renderCtxStack.length - 1];
106
+ }
107
+ function isIsland(v) {
108
+ return typeof v === "function" && ISLAND in v;
109
+ }
110
+ function isIslandCall(v) {
111
+ return (typeof v === "object" || typeof v === "function") && v !== null && ISLAND_CALL in v;
112
+ }
113
+ function emitIslandSlot(island, props, key) {
114
+ const ctx = currentRenderCtx();
115
+ let id;
116
+ if (key !== void 0) {
117
+ id = `k:${key}`;
118
+ if (ctx && __DEV__ && ctx.slots.has(id)) warn(`Duplicate slot key "${key}" — two children with the same key in a single render will collide. Each .key() call must be unique.`);
119
+ } else id = ctx ? `p:${ctx.positional++}` : "p:0";
120
+ if (ctx) ctx.slots.set(id, {
121
+ island,
122
+ props
93
123
  });
94
- fn.toString = () => render(void 0);
95
- fn[SLOT_ACCESSOR] = true;
96
- return fn;
124
+ const propsAttr = props ? ` ${PROPS_ATTR}='${escapeHtml(JSON.stringify(props))}'` : "";
125
+ if (ctx?.liveHost) return `<div ${SLOT_ATTR}="${escapeHtml(id)}"${propsAttr}></div>`;
126
+ if (ctx?.pending) {
127
+ popRenderCtx();
128
+ try {
129
+ const result = island(props);
130
+ if (result instanceof Promise) {
131
+ ctx.pending.set(id, result.then(String));
132
+ return `<div ${SLOT_ATTR}="${escapeHtml(id)}"${propsAttr}></div>`;
133
+ }
134
+ return `<div ${SLOT_ATTR}="${escapeHtml(id)}"${propsAttr}>${result}</div>`;
135
+ } finally {
136
+ renderCtxStack.push(ctx);
137
+ }
138
+ }
139
+ const inner = island.toString(props);
140
+ return `<div ${SLOT_ATTR}="${escapeHtml(id)}"${propsAttr}>${inner}</div>`;
97
141
  }
98
- function isSlotAccessor(v) {
99
- return typeof v === "function" && SLOT_ACCESSOR in v;
142
+ async function resolveAsyncChildren(html, ctx) {
143
+ for (const [id, promise] of ctx.pending) {
144
+ const inner = await promise;
145
+ const escaped = escapeHtml(id);
146
+ if (escaped.length > 500) throw new Error(`Slot id exceeds safe length for regex replacement (${escaped.length} > 500).`);
147
+ const attrPattern = escaped.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
148
+ const placeholder = new RegExp(`<div\\s[^>]*${SLOT_ATTR}="${attrPattern}"[^>]*></div>`, "g");
149
+ html = html.replace(placeholder, (match) => {
150
+ return match.slice(0, -6) + `>${inner}</div>`;
151
+ });
152
+ }
153
+ return html;
100
154
  }
101
155
  function markSignalAccessor(fn) {
102
156
  fn[SIGNAL_ACCESSOR] = true;
@@ -124,7 +178,11 @@ function interpolateValue(v) {
124
178
  if (v == null) return "";
125
179
  if (Array.isArray(v)) return v.map(interpolateValue).join("");
126
180
  if (typeof v === "object" && RAW in v) return v.value;
127
- if (isSlotAccessor(v)) return v.toString();
181
+ if (isIslandCall(v)) {
182
+ const call = typeof v === "function" ? v() : v;
183
+ return emitIslandSlot(call.island, call.props, call.key);
184
+ }
185
+ if (isIsland(v)) return emitIslandSlot(v, void 0, void 0);
128
186
  if (isSignalAccessor(v)) return escapeHtml(v());
129
187
  if (typeof v === "function") return escapeHtml(v());
130
188
  return escapeHtml(v);
@@ -315,13 +373,12 @@ var IlhaBuilder = class IlhaBuilder {
315
373
  }
316
374
  input(schema) {
317
375
  return new IlhaBuilder({
318
- schema,
376
+ schema: schema ?? null,
319
377
  states: [],
320
378
  deriveds: [],
321
379
  ons: [],
322
380
  effects: [],
323
381
  onMounts: [],
324
- slots: {},
325
382
  transition: null,
326
383
  binds: [],
327
384
  css: null
@@ -380,15 +437,6 @@ var IlhaBuilder = class IlhaBuilder {
380
437
  onMounts: [...this._cfg.onMounts, { fn }]
381
438
  });
382
439
  }
383
- slot(name, island) {
384
- return new IlhaBuilder({
385
- ...this._cfg,
386
- slots: {
387
- ...this._cfg.slots,
388
- [name]: island
389
- }
390
- });
391
- }
392
440
  transition(opts) {
393
441
  return new IlhaBuilder({
394
442
  ...this._cfg,
@@ -413,28 +461,38 @@ var IlhaBuilder = class IlhaBuilder {
413
461
  });
414
462
  }
415
463
  render(fn) {
416
- const { schema, states, deriveds, ons, effects, onMounts, slots: slotDefs, transition, binds, css: cssSource } = this._cfg;
464
+ const { schema, states, deriveds, ons, effects, onMounts, transition, binds, css: cssSource } = this._cfg;
417
465
  const stylePrefix = cssSource != null ? buildScopedStyle(cssSource) : "";
418
466
  function resolveInput(props) {
419
467
  const value = props ?? {};
420
468
  if (!schema) return value;
421
469
  return validateSchema(schema, value);
422
470
  }
423
- function makeSlotsProxy(ssr, host) {
424
- return new Proxy({}, { get(_, prop) {
425
- const name = String(prop);
426
- if (!slotDefs[name]) return makeSlotAccessor(() => "");
427
- if (ssr) return makeSlotAccessor((props) => {
428
- const json = props ? ` ${PROPS_ATTR}='${escapeHtml(JSON.stringify(props))}'` : "";
429
- return `<div ${SLOT_ATTR}="${escapeHtml(name)}"${json}>${slotDefs[name].toString(props)}</div>`;
430
- });
431
- return makeSlotAccessor((props) => {
432
- const liveSlot = host?.querySelector(`[${SLOT_ATTR}="${escapeHtml(name)}"]`);
433
- if (liveSlot) return liveSlot.outerHTML;
434
- const json = props ? ` ${PROPS_ATTR}='${escapeHtml(JSON.stringify(props))}'` : "";
435
- return `<div ${SLOT_ATTR}="${escapeHtml(name)}"${json}></div>`;
436
- });
437
- } });
471
+ function renderWithCtx(fn, liveHost, asyncChildren) {
472
+ const ctx = pushRenderCtx(liveHost, asyncChildren);
473
+ let isAsync = false;
474
+ try {
475
+ const html = unwrapHtml(fn());
476
+ if (ctx.pending && ctx.pending.size > 0) {
477
+ isAsync = true;
478
+ return (async () => {
479
+ try {
480
+ return {
481
+ html: await resolveAsyncChildren(html, ctx),
482
+ slots: ctx.slots
483
+ };
484
+ } finally {
485
+ popRenderCtx();
486
+ }
487
+ })();
488
+ }
489
+ return {
490
+ html,
491
+ slots: ctx.slots
492
+ };
493
+ } finally {
494
+ if (!isAsync) popRenderCtx();
495
+ }
438
496
  }
439
497
  function buildPlainState(input) {
440
498
  const state = {};
@@ -462,7 +520,6 @@ var IlhaBuilder = class IlhaBuilder {
462
520
  function renderToString(props, sync = false) {
463
521
  const input = resolveInput(props);
464
522
  const state = buildPlainState(input);
465
- const slots = makeSlotsProxy(true);
466
523
  const results = deriveds.map((entry) => {
467
524
  try {
468
525
  return {
@@ -492,12 +549,12 @@ var IlhaBuilder = class IlhaBuilder {
492
549
  value: r.result,
493
550
  error: void 0
494
551
  };
495
- return stylePrefix + unwrapHtml(fn({
552
+ const { html } = renderWithCtx(() => fn({
496
553
  state,
497
554
  derived,
498
- input,
499
- slots
555
+ input
500
556
  }));
557
+ return stylePrefix + html;
501
558
  }
502
559
  return Promise.all(results.map(async (r) => {
503
560
  try {
@@ -519,15 +576,15 @@ var IlhaBuilder = class IlhaBuilder {
519
576
  }
520
577
  };
521
578
  }
522
- })).then((resolved) => {
579
+ })).then(async (resolved) => {
523
580
  const derived = {};
524
581
  for (const r of resolved) derived[r.key] = r.envelope;
525
- return stylePrefix + unwrapHtml(fn({
582
+ const { html } = await renderWithCtx(() => fn({
526
583
  state,
527
584
  derived,
528
- input,
529
- slots
530
- }));
585
+ input
586
+ }), void 0, true);
587
+ return stylePrefix + html;
531
588
  });
532
589
  }
533
590
  function mountIsland(host, props) {
@@ -576,28 +633,49 @@ Element: ${host.outerHTML.slice(0, 120)}`);
576
633
  }
577
634
  const { proxy: derived, stop: stopDerived } = buildDerivedSignals(deriveds, state, input, derivedSnapshot);
578
635
  cleanups.push(stopDerived);
579
- const slotCleanups = /* @__PURE__ */ new Map();
580
- const slotEls = /* @__PURE__ */ new Map();
581
- function mountSlots() {
582
- for (const [name, childIsland] of Object.entries(slotDefs)) {
583
- const slotEl = host.querySelector(`[${SLOT_ATTR}="${name}"]`);
636
+ const mountedSlots = /* @__PURE__ */ new Map();
637
+ function findSlot(id) {
638
+ const escaped = typeof CSS !== "undefined" && CSS.escape ? CSS.escape(id) : id.replace(/["\\]/g, "\\$&");
639
+ const candidates = host.querySelectorAll(`[${SLOT_ATTR}="${escaped}"]`);
640
+ for (const candidate of candidates) {
641
+ let el = candidate;
642
+ while (el && el !== host) {
643
+ if (el.hasAttribute("data-ilha")) break;
644
+ el = el.parentElement;
645
+ }
646
+ if (el === host) return candidate;
647
+ }
648
+ return null;
649
+ }
650
+ function mountSlots(slotMap) {
651
+ for (const [id, entry] of mountedSlots) if (!slotMap.has(id)) {
652
+ entry.unmount();
653
+ mountedSlots.delete(id);
654
+ }
655
+ for (const [id, { island: childIsland, props }] of slotMap) {
656
+ if (mountedSlots.has(id)) continue;
657
+ const slotEl = findSlot(id);
584
658
  if (!slotEl) continue;
585
- if (slotEls.get(name) === slotEl) continue;
586
- slotEls.set(name, slotEl);
587
- slotCleanups.get(name)?.();
588
- let slotProps;
589
- const rawProps = slotEl.getAttribute(PROPS_ATTR) ?? slotEl.getAttribute("data-props");
590
- if (rawProps) try {
591
- slotProps = JSON.parse(rawProps);
592
- } catch {
593
- warn(`Failed to parse props on [${SLOT_ATTR}="${name}"] — invalid JSON ignored.`);
659
+ let slotProps = props;
660
+ if (slotProps === void 0) {
661
+ const rawProps = slotEl.getAttribute(PROPS_ATTR) ?? slotEl.getAttribute("data-props");
662
+ if (rawProps) try {
663
+ slotProps = JSON.parse(rawProps);
664
+ } catch {
665
+ warn(`Failed to parse props on [${SLOT_ATTR}="${id}"] — invalid JSON ignored.`);
666
+ }
594
667
  }
595
668
  const prevSub = setActiveSub(void 0);
669
+ let unmount;
596
670
  try {
597
- slotCleanups.set(name, childIsland.mount(slotEl, slotProps));
671
+ unmount = childIsland.mount(slotEl, slotProps);
598
672
  } finally {
599
673
  setActiveSub(prevSub);
600
674
  }
675
+ mountedSlots.set(id, {
676
+ el: slotEl,
677
+ unmount
678
+ });
601
679
  }
602
680
  }
603
681
  const listeners = [];
@@ -644,18 +722,18 @@ Element: ${host.outerHTML.slice(0, 120)}`);
644
722
  for (const l of listeners) l.target.removeEventListener(l.type, l.fn, l.options);
645
723
  listeners.length = 0;
646
724
  }
647
- const slots = makeSlotsProxy(false, host);
648
- if (!(hydrated && host.childNodes.length > 0)) host.innerHTML = stylePrefix + unwrapHtml(fn({
725
+ const hasExistingContent = hydrated && host.childNodes.length > 0;
726
+ const initial = renderWithCtx(() => fn({
649
727
  state,
650
728
  derived,
651
- input,
652
- slots
653
- }));
729
+ input
730
+ }), hasExistingContent ? host : void 0);
731
+ if (!hasExistingContent) host.innerHTML = stylePrefix + initial.html;
654
732
  attachListeners();
655
733
  let stopBindings = applyBindings(host, binds, state);
656
734
  cleanups.push(() => stopBindings());
657
- mountSlots();
658
- cleanups.push(() => slotCleanups.forEach((unmount) => unmount()));
735
+ mountSlots(initial.slots);
736
+ cleanups.push(() => mountedSlots.forEach((entry) => entry.unmount()));
659
737
  if (!shouldSkipOnMount) for (const entry of onMounts) {
660
738
  const prevSub = setActiveSub(void 0);
661
739
  let userCleanup;
@@ -674,24 +752,42 @@ Element: ${host.outerHTML.slice(0, 120)}`);
674
752
  }
675
753
  let initialized = false;
676
754
  const stopRender = effect(() => {
677
- const html = stylePrefix + unwrapHtml(fn({
755
+ const { html: rendered, slots: newSlotMap } = renderWithCtx(() => fn({
678
756
  state,
679
757
  derived,
680
- input,
681
- slots
682
- }));
758
+ input
759
+ }), host);
760
+ const html = stylePrefix + rendered;
683
761
  if (!initialized) {
684
762
  initialized = true;
685
763
  return;
686
764
  }
687
765
  detachListeners();
688
766
  stopBindings();
767
+ for (const [id, entry] of mountedSlots) if (!newSlotMap.has(id)) {
768
+ entry.unmount();
769
+ mountedSlots.delete(id);
770
+ }
771
+ const preserved = /* @__PURE__ */ new Map();
772
+ for (const [id, entry] of mountedSlots) {
773
+ if (!newSlotMap.has(id)) continue;
774
+ if (!entry.el.isConnected) continue;
775
+ const stub = document.createElement("div");
776
+ stub.setAttribute(SLOT_ATTR, id);
777
+ entry.el.replaceWith(stub);
778
+ preserved.set(id, entry.el);
779
+ }
689
780
  const tpl = document.createElement("template");
690
781
  tpl.innerHTML = `<div>${html}</div>`;
691
782
  morphInner(host, tpl.content.firstElementChild);
783
+ for (const [id, liveEl] of preserved) {
784
+ const stub = findSlot(id);
785
+ if (!stub) continue;
786
+ stub.replaceWith(liveEl);
787
+ }
692
788
  attachListeners();
693
789
  stopBindings = applyBindings(host, binds, state);
694
- mountSlots();
790
+ mountSlots(newSlotMap);
695
791
  });
696
792
  cleanups.push(stopRender);
697
793
  cleanups.push(detachListeners);
@@ -728,11 +824,41 @@ Element: ${host.outerHTML.slice(0, 120)}`);
728
824
  cleanups.forEach((c) => c());
729
825
  };
730
826
  }
827
+ /**
828
+ * Dual-mode island invocation:
829
+ * - When called inside an `html` interpolation (i.e. {@link currentRenderCtx}
830
+ * is active), returns an {@link IslandCall} carrying props to be emitted
831
+ * as a child slot marker; {@link interpolateValue} consumes this marker.
832
+ * - When called at the top level with no active render context, returns
833
+ * SSR HTML via {@link renderToString} (backward-compatible public API).
834
+ *
835
+ * The declared return type omits {@link IslandCall} because TypeScript
836
+ * cannot narrow based on runtime stack state.
837
+ */
731
838
  const island = function(props) {
839
+ if (currentRenderCtx()) return {
840
+ [ISLAND_CALL]: true,
841
+ island,
842
+ props,
843
+ key: void 0
844
+ };
732
845
  return renderToString(props);
733
846
  };
734
847
  island.toString = (props) => renderToString(props, true);
735
848
  island.mount = (host, props) => mountIsland(host, props);
849
+ island.key = (key) => {
850
+ if (typeof key !== "string" || key.trim().length === 0) throw new Error("island.key() requires a non-empty string.");
851
+ if (key.includes(":")) throw new Error(`island.key() key cannot contain the slot separator ":" (got "${key}").`);
852
+ const keyed = ((props) => ({
853
+ [ISLAND_CALL]: true,
854
+ island,
855
+ props,
856
+ key
857
+ }));
858
+ keyed[ISLAND_CALL] = true;
859
+ return keyed;
860
+ };
861
+ island[ISLAND] = true;
736
862
  island.hydratable = async (props, opts) => {
737
863
  const { name, as: tag = "div", snapshot = false, skipOnMount: explicitSkipOnMount } = opts;
738
864
  const resolvedProps = props ?? {};
@@ -831,7 +957,6 @@ const rootBuilder = new IlhaBuilder({
831
957
  ons: [],
832
958
  effects: [],
833
959
  onMounts: [],
834
- slots: {},
835
960
  transition: null,
836
961
  binds: [],
837
962
  css: null
@@ -843,15 +968,6 @@ const ilha = Object.assign(rootBuilder, {
843
968
  from: ilhaFrom,
844
969
  context: ilhaContext
845
970
  });
846
- function type(coerce) {
847
- return { "~standard": {
848
- version: 1,
849
- vendor: "ilha",
850
- validate(value) {
851
- return { value: coerce ? coerce(value) : value };
852
- }
853
- } };
854
- }
855
971
  const html = ilhaHtml;
856
972
  const raw = ilhaRaw;
857
973
  const css = ilhaCss;
@@ -859,4 +975,4 @@ const mount = mountAll;
859
975
  const from = ilhaFrom;
860
976
  const context = ilhaContext;
861
977
  //#endregion
862
- export { context, css, ilha as default, from, html, mount, raw, type };
978
+ export { context, css, ilha as default, from, html, mount, raw };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ilha",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "A tiny, framework-free island architecture library",
5
5
  "license": "MIT",
6
6
  "author": "Ryuz <ryuzer@proton.me>",
@@ -26,7 +26,6 @@
26
26
  "alien-signals": "3.1.2"
27
27
  },
28
28
  "devDependencies": {
29
- "docsome": "^0.0.6",
30
29
  "zod": "^4.3.6"
31
30
  }
32
31
  }