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 +54 -29
- package/dist/index.d.ts +33 -31
- package/dist/index.js +201 -85
- package/package.json +1 -2
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
|
|
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
|
-
###
|
|
292
|
+
### Composing Islands
|
|
283
293
|
|
|
284
|
-
|
|
294
|
+
Child islands are interpolated directly inside a parent's html`` 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.
|
|
290
|
-
(
|
|
299
|
+
const Card = ilha.render(
|
|
300
|
+
() => html`
|
|
291
301
|
<div class="card">
|
|
292
|
-
${
|
|
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
|
|
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: "
|
|
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="
|
|
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({
|
|
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({
|
|
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
|
-
|
|
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
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
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
|
|
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
|
|
183
|
-
readonly _cfg: BuilderConfig<TInput, TStateMap, TDerivedMap
|
|
184
|
-
constructor(cfg: BuilderConfig<TInput, TStateMap, TDerivedMap
|
|
185
|
-
input<
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
bind
|
|
190
|
-
|
|
191
|
-
on(selectorOrCombined:
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
transition(opts: TransitionOptions): IlhaBuilder<TInput, TStateMap, TDerivedMap
|
|
196
|
-
css(strings: TemplateStringsArray | string, ...values: (string | number)[]): IlhaBuilder<TInput, TStateMap, TDerivedMap
|
|
197
|
-
render(fn: (ctx: RenderContext<TInput, TStateMap, TDerivedMap
|
|
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
|
|
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,
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
99
|
-
|
|
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 (
|
|
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,
|
|
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
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
-
|
|
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
|
-
|
|
582
|
+
const { html } = await renderWithCtx(() => fn({
|
|
526
583
|
state,
|
|
527
584
|
derived,
|
|
528
|
-
input
|
|
529
|
-
|
|
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
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
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
|
-
|
|
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
|
|
648
|
-
|
|
725
|
+
const hasExistingContent = hydrated && host.childNodes.length > 0;
|
|
726
|
+
const initial = renderWithCtx(() => fn({
|
|
649
727
|
state,
|
|
650
728
|
derived,
|
|
651
|
-
input
|
|
652
|
-
|
|
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(() =>
|
|
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 =
|
|
755
|
+
const { html: rendered, slots: newSlotMap } = renderWithCtx(() => fn({
|
|
678
756
|
state,
|
|
679
757
|
derived,
|
|
680
|
-
input
|
|
681
|
-
|
|
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
|
|
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.
|
|
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
|
}
|