ilha 0.2.1 → 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
@@ -289,23 +289,51 @@ ilha.css(styles).render(() => `<div class="card">…</div>`);
289
289
 
290
290
  ---
291
291
 
292
- ### `.slot(name, island)`
292
+ ### Composing Islands
293
293
 
294
- 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.
295
295
 
296
296
  ```ts
297
297
  const Icon = ilha.render(() => `<svg>…</svg>`);
298
298
 
299
- const Card = ilha.slot("icon", Icon).render(
300
- ({ slots }) => html`
299
+ const Card = ilha.render(
300
+ () => html`
301
301
  <div class="card">
302
- ${slots.icon()}
302
+ ${Icon}
303
303
  <p>Card content</p>
304
304
  </div>
305
305
  `,
306
306
  );
307
307
  ```
308
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
+
309
337
  ---
310
338
 
311
339
  ### `.transition(opts)`
@@ -331,7 +359,7 @@ The `leave` transition is awaited before cleanup runs.
331
359
 
332
360
  ### `.render(fn)`
333
361
 
334
- 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`.
335
363
 
336
364
  ```ts
337
365
  const MyIsland = ilha.state("x", 1).render(({ state, input }) => html`<p>${state.x}</p>`);
@@ -471,6 +499,7 @@ Interpolation rules:
471
499
  | `raw(str)` | Inserted as-is (no escaping) |
472
500
  | `html\`…\`` | Inserted as-is (already safe) |
473
501
  | Signal accessor | Called and escaped |
502
+ | Island / Island call | Emitted as `data-ilha-slot` host element |
474
503
  | Array | Each item processed recursively (no commas) |
475
504
 
476
505
  **List rendering pattern:**
@@ -581,7 +610,7 @@ import type {
581
610
  IslandState,
582
611
  IslandDerived,
583
612
  DerivedValue,
584
- SlotAccessor,
613
+ KeyedIsland,
585
614
  HydratableOptions,
586
615
  OnMountContext,
587
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,40 +171,38 @@ 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<T extends Record<string, unknown>>(): IlhaBuilder<T, Record<string, never>, Record<string, never>, Record<string, never>>;
186
- input<S extends StandardSchemaV1>(schema: S): IlhaBuilder<StandardSchemaV1.InferOutput<S> & Record<string, unknown>, Record<string, never>, Record<string, never>, Record<string, never>>;
187
- state<V = undefined, K extends string = string>(key: K, init?: StateInit<TInput, V> | undefined): IlhaBuilder<TInput, MergeState<TStateMap, K, V>, TDerivedMap, TSlots>;
188
- derived<K extends string, V>(key: K, fn: DerivedFn<TInput, TStateMap, V>): IlhaBuilder<TInput, TStateMap, TDerivedMap & Record<K, V>, TSlots>;
189
- bind(selector: string, stateKey: keyof TStateMap & string): IlhaBuilder<TInput, TStateMap, TDerivedMap, TSlots>;
190
- bind<T>(selector: string, externalSignal: ExternalSignal<T>): IlhaBuilder<TInput, TStateMap, TDerivedMap, TSlots>;
191
- 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>;
192
- on(selectorOrCombined: string, handler: (ctx: HandlerContext<TInput, TStateMap, TDerivedMap>) => void | Promise<void>): IlhaBuilder<TInput, TStateMap, TDerivedMap, TSlots>;
193
- effect(fn: (ctx: EffectContext<TInput, TStateMap>) => (() => void) | void): IlhaBuilder<TInput, TStateMap, TDerivedMap, TSlots>;
194
- onMount(fn: (ctx: OnMountContext<TInput, TStateMap, TDerivedMap>) => (() => void) | void): IlhaBuilder<TInput, TStateMap, TDerivedMap, TSlots>;
195
- slot<K extends string, I extends AnyIsland>(name: K, island: I): IlhaBuilder<TInput, TStateMap, TDerivedMap, TSlots & Record<K, I>>;
196
- transition(opts: TransitionOptions): IlhaBuilder<TInput, TStateMap, TDerivedMap, TSlots>;
197
- css(strings: TemplateStringsArray | string, ...values: (string | number)[]): IlhaBuilder<TInput, TStateMap, TDerivedMap, TSlots>;
198
- 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>;
199
201
  }
200
202
  declare function ilhaFrom<TInput, TStateMap extends Record<string, unknown>>(selector: string | Element, island: Island<TInput, TStateMap>, props?: Partial<TInput>): (() => void) | null;
201
203
  type IslandRegistry = Record<string, AnyIsland>;
202
204
  declare function mountAll(registry: IslandRegistry, options?: MountOptions): MountResult;
203
- 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>> & {
204
206
  html: typeof ilhaHtml;
205
207
  raw: typeof ilhaRaw;
206
208
  mount: typeof mountAll;
@@ -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 };
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);
@@ -321,7 +379,6 @@ var IlhaBuilder = class IlhaBuilder {
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ilha",
3
- "version": "0.2.1",
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>",