ilha 0.0.1 → 0.1.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.
Files changed (4) hide show
  1. package/README.md +387 -176
  2. package/dist/index.d.ts +79 -32
  3. package/dist/index.js +375 -205
  4. package/package.json +4 -20
package/dist/index.js CHANGED
@@ -1,15 +1,74 @@
1
1
  import { effect, setActiveSub, signal } from "alien-signals";
2
2
  //#region src/index.ts
3
+ const __DEV__ = typeof process !== "undefined" ? process.env?.["NODE_ENV"] !== "production" : true;
4
+ function warn(msg) {
5
+ if (__DEV__) console.warn(`[ilha] ${msg}`);
6
+ }
7
+ function syncAttributes(from, to) {
8
+ for (const { name, value } of to.attributes) if (from.getAttribute(name) !== value) from.setAttribute(name, value);
9
+ for (const { name } of Array.from(from.attributes)) if (!to.hasAttribute(name)) from.removeAttribute(name);
10
+ }
11
+ function morphChildren(fromParent, toParent) {
12
+ const fromNodes = Array.from(fromParent.childNodes);
13
+ const toNodes = Array.from(toParent.childNodes);
14
+ for (let i = fromNodes.length - 1; i >= toNodes.length; i--) fromNodes[i].remove();
15
+ for (let i = 0; i < toNodes.length; i++) {
16
+ const toNode = toNodes[i];
17
+ const fromNode = fromNodes[i];
18
+ if (!fromNode) {
19
+ fromParent.appendChild(toNode.cloneNode(true));
20
+ continue;
21
+ }
22
+ if (fromNode.nodeType !== toNode.nodeType) {
23
+ fromParent.replaceChild(toNode.cloneNode(true), fromNode);
24
+ continue;
25
+ }
26
+ if (fromNode.nodeType === 3 || fromNode.nodeType === 8) {
27
+ if (fromNode.nodeValue !== toNode.nodeValue) fromNode.nodeValue = toNode.nodeValue;
28
+ continue;
29
+ }
30
+ if (fromNode.nodeType === 1) {
31
+ const fromEl = fromNode;
32
+ const toEl = toNode;
33
+ if (fromEl.localName !== toEl.localName || fromEl.namespaceURI !== toEl.namespaceURI) {
34
+ fromParent.replaceChild(toEl.cloneNode(true), fromEl);
35
+ continue;
36
+ }
37
+ if (fromEl.localName === "input" && fromEl.type !== toEl.type) {
38
+ fromParent.replaceChild(toEl.cloneNode(true), fromEl);
39
+ continue;
40
+ }
41
+ syncAttributes(fromEl, toEl);
42
+ if (fromEl.localName === "textarea") {
43
+ const newText = toEl.textContent ?? "";
44
+ if (fromEl.textContent !== newText) fromEl.textContent = newText;
45
+ fromEl.value = fromEl.defaultValue;
46
+ } else morphChildren(fromEl, toEl);
47
+ }
48
+ }
49
+ }
50
+ function morphInner(from, to) {
51
+ if (from.localName !== to.localName || from.namespaceURI !== to.namespaceURI) throw new Error("[ilha] morph: elements must match");
52
+ morphChildren(from, to);
53
+ }
3
54
  function validateSchema(schema, value) {
4
55
  const result = schema["~standard"].validate(value);
5
56
  if (result instanceof Promise) throw new Error("[ilha] Async schemas are not supported.");
6
57
  if (result.issues) throw new Error(`[ilha] Validation failed:\n${result.issues.map((i) => ` - ${i.message}`).join("\n")}`);
7
58
  return result.value;
8
59
  }
60
+ const ESC = {
61
+ "&": "&amp;",
62
+ "<": "&lt;",
63
+ ">": "&gt;",
64
+ "\"": "&quot;",
65
+ "'": "&#39;"
66
+ };
9
67
  function escapeHtml(value) {
10
- return String(value).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
68
+ return String(value).replace(/[&<>"']/g, (c) => ESC[c]);
11
69
  }
12
70
  function dedentString(str) {
71
+ if (str.length === 0 || str[0] !== "\n") return str;
13
72
  const lines = str.split("\n");
14
73
  while (lines.length && lines[0].trim() === "") lines.shift();
15
74
  while (lines.length && lines[lines.length - 1].trim() === "") lines.pop();
@@ -21,7 +80,7 @@ const RAW = Symbol("ilha.raw");
21
80
  const SLOT_ACCESSOR = Symbol("ilha.slotAccessor");
22
81
  const SIGNAL_ACCESSOR = Symbol("ilha.signalAccessor");
23
82
  const SLOT_ATTR = "data-ilha-slot";
24
- const SLOT_PROPS_ATTR = "data-ilha-props";
83
+ const PROPS_ATTR = "data-ilha-props";
25
84
  const STATE_ATTR = "data-ilha-state";
26
85
  function makeSlotAccessor(render) {
27
86
  const fn = (props) => ({
@@ -48,21 +107,28 @@ function ilhaRaw(value) {
48
107
  value
49
108
  };
50
109
  }
110
+ function interpolateValue(v) {
111
+ if (v == null) return "";
112
+ if (Array.isArray(v)) return v.map(interpolateValue).join("");
113
+ if (typeof v === "object" && RAW in v) return v.value;
114
+ if (isSlotAccessor(v)) return v.toString();
115
+ if (isSignalAccessor(v)) return escapeHtml(v());
116
+ if (typeof v === "function") return escapeHtml(v());
117
+ return escapeHtml(v);
118
+ }
51
119
  function ilhaHtml(strings, ...values) {
52
120
  let result = "";
53
121
  for (let i = 0; i < strings.length; i++) {
54
122
  result += strings[i];
55
- if (i < values.length) {
56
- const v = values[i];
57
- if (v == null) continue;
58
- if (typeof v === "object" && RAW in v) result += v.value;
59
- else if (isSlotAccessor(v)) result += v.toString();
60
- else if (isSignalAccessor(v)) result += escapeHtml(v());
61
- else if (typeof v === "function") result += escapeHtml(v());
62
- else result += escapeHtml(v);
63
- }
123
+ if (i < values.length) result += interpolateValue(values[i]);
64
124
  }
65
- return dedentString(result);
125
+ return {
126
+ [RAW]: true,
127
+ value: dedentString(result)
128
+ };
129
+ }
130
+ function unwrapHtml(v) {
131
+ return typeof v === "object" && RAW in v ? v.value : v;
66
132
  }
67
133
  const contextRegistry = /* @__PURE__ */ new Map();
68
134
  function ilhaContext(key, initial) {
@@ -75,39 +141,23 @@ function ilhaContext(key, initial) {
75
141
  contextRegistry.set(key, accessor);
76
142
  return accessor;
77
143
  }
78
- function makePlainDerived(entries, state, input) {
79
- const derived = {};
80
- for (const entry of entries) {
81
- const result = entry.fn({
82
- state,
83
- input,
84
- signal: new AbortController().signal
85
- });
86
- if (result instanceof Promise) derived[entry.key] = {
87
- loading: true,
88
- value: void 0,
89
- error: void 0
90
- };
91
- else derived[entry.key] = {
92
- loading: false,
93
- value: result,
94
- error: void 0
95
- };
96
- }
97
- return derived;
98
- }
99
- function buildDerivedSignals(entries, state, input) {
144
+ function buildDerivedSignals(entries, state, input, derivedSnapshot) {
100
145
  const envelopes = /* @__PURE__ */ new Map();
101
146
  const stops = [];
102
147
  for (const entry of entries) {
103
- const env = signal({
148
+ const env = signal(derivedSnapshot?.[entry.key] ?? {
104
149
  loading: true,
105
150
  value: void 0,
106
151
  error: void 0
107
152
  });
108
153
  envelopes.set(entry.key, env);
109
154
  let ac = new AbortController();
155
+ let skipFirst = derivedSnapshot != null && entry.key in derivedSnapshot;
110
156
  const stopEffect = effect(() => {
157
+ if (skipFirst) {
158
+ skipFirst = false;
159
+ return;
160
+ }
111
161
  ac.abort();
112
162
  ac = new AbortController();
113
163
  const currentAc = ac;
@@ -184,8 +234,7 @@ function resolveBindConfig(el) {
184
234
  return input.checked ? input.value : void 0;
185
235
  },
186
236
  write: (el, v) => {
187
- const input = el;
188
- input.checked = String(v ?? "") === input.value;
237
+ el.checked = String(v ?? "") === el.value;
189
238
  }
190
239
  };
191
240
  if (tag === "input" && type === "number") return {
@@ -201,15 +250,15 @@ function resolveBindConfig(el) {
201
250
  write: (el, v) => el.value = String(v ?? "")
202
251
  };
203
252
  }
204
- function applyBindings(el, bindings, state) {
253
+ function applyBindings(host, bindings, state) {
205
254
  const cleanups = [];
206
255
  for (const binding of bindings) {
207
- const targets = binding.selector === "" ? [el] : Array.from(el.querySelectorAll(binding.selector));
256
+ const accessor = typeof binding.target === "function" ? binding.target : state[binding.target];
257
+ const targets = binding.selector === "" ? [host] : Array.from(host.querySelectorAll(binding.selector));
258
+ if (__DEV__ && binding.selector !== "" && targets.length === 0) warn(`bind(): selector "${binding.selector}" matched no elements inside the island host. Check that the element exists in your render output.`);
208
259
  for (const target of targets) {
209
260
  const { event, read, write } = resolveBindConfig(target);
210
- const accessor = state[binding.stateKey];
211
- const input = target;
212
- const isRadio = input.tagName.toLowerCase() === "input" && input.type?.toLowerCase() === "radio";
261
+ const isRadio = target.tagName.toLowerCase() === "input" && target.type?.toLowerCase() === "radio";
213
262
  write(target, accessor());
214
263
  const listener = () => {
215
264
  const raw = read(target);
@@ -229,112 +278,129 @@ function applyBindings(el, bindings, state) {
229
278
  }
230
279
  return () => cleanups.forEach((c) => c());
231
280
  }
232
- function parseOnArgs(selectorOrCombined, callbackOrEventType) {
233
- let selector;
234
- let rawEvent;
235
- if (typeof callbackOrEventType === "function") {
236
- const atIdx = selectorOrCombined.lastIndexOf("@");
237
- if (atIdx === -1) {
238
- selector = "";
239
- rawEvent = selectorOrCombined.startsWith("@") ? selectorOrCombined.slice(1) : selectorOrCombined;
240
- } else {
241
- selector = selectorOrCombined.slice(0, atIdx);
242
- rawEvent = selectorOrCombined.slice(atIdx + 1);
243
- }
244
- } else {
245
- selector = selectorOrCombined;
246
- rawEvent = callbackOrEventType;
247
- }
248
- const parts = rawEvent.split(":");
281
+ function parseOnArgs(selectorOrCombined) {
282
+ const atIdx = selectorOrCombined.lastIndexOf("@");
283
+ const selector = atIdx === -1 ? "" : selectorOrCombined.slice(0, atIdx);
284
+ const parts = (atIdx === -1 ? selectorOrCombined : selectorOrCombined.slice(atIdx + 1)).split(":");
249
285
  const eventType = parts[0];
250
286
  const modifiers = new Set(parts.slice(1));
251
- const options = {
252
- once: modifiers.has("once"),
253
- capture: modifiers.has("capture"),
254
- passive: modifiers.has("passive")
255
- };
256
287
  return {
257
288
  selector,
258
289
  eventType,
259
- options
290
+ options: {
291
+ once: modifiers.has("once"),
292
+ capture: modifiers.has("capture"),
293
+ passive: modifiers.has("passive")
294
+ }
260
295
  };
261
296
  }
297
+ const _mountedHosts = __DEV__ ? /* @__PURE__ */ new WeakSet() : null;
262
298
  var IlhaBuilder = class IlhaBuilder {
263
- constructor(_schema, _states, _deriveds, _ons, _effects, _slots, _transition, _binds) {
264
- this._schema = _schema;
265
- this._states = _states;
266
- this._deriveds = _deriveds;
267
- this._ons = _ons;
268
- this._effects = _effects;
269
- this._slots = _slots;
270
- this._transition = _transition;
271
- this._binds = _binds;
299
+ _cfg;
300
+ constructor(cfg) {
301
+ this._cfg = cfg;
272
302
  }
273
303
  input(schema) {
274
- return new IlhaBuilder(schema, [], [], [], [], {}, null, []);
304
+ return new IlhaBuilder({
305
+ schema,
306
+ states: [],
307
+ deriveds: [],
308
+ ons: [],
309
+ effects: [],
310
+ onMounts: [],
311
+ slots: {},
312
+ transition: null,
313
+ binds: []
314
+ });
275
315
  }
276
316
  state(key, init) {
277
- return new IlhaBuilder(this._schema, [...this._states, {
278
- key,
279
- init
280
- }], this._deriveds, this._ons, this._effects, this._slots, this._transition, this._binds);
317
+ const cfg = this._cfg;
318
+ return new IlhaBuilder({
319
+ ...cfg,
320
+ states: [...cfg.states, {
321
+ key,
322
+ init
323
+ }]
324
+ });
281
325
  }
282
326
  derived(key, fn) {
283
- return new IlhaBuilder(this._schema, this._states, [...this._deriveds, {
284
- key,
285
- fn
286
- }], this._ons, this._effects, this._slots, this._transition, this._binds);
327
+ const cfg = this._cfg;
328
+ return new IlhaBuilder({
329
+ ...cfg,
330
+ deriveds: [...cfg.deriveds, {
331
+ key,
332
+ fn
333
+ }]
334
+ });
287
335
  }
288
- bind(selector, stateKey) {
289
- return new IlhaBuilder(this._schema, this._states, this._deriveds, this._ons, this._effects, this._slots, this._transition, [...this._binds, {
290
- selector,
291
- stateKey
292
- }]);
336
+ bind(selector, target) {
337
+ return new IlhaBuilder({
338
+ ...this._cfg,
339
+ binds: [...this._cfg.binds, {
340
+ selector,
341
+ target
342
+ }]
343
+ });
293
344
  }
294
- on(selectorOrCombined, callbackOrEventType, handler) {
295
- const parsed = parseOnArgs(selectorOrCombined, callbackOrEventType);
296
- const resolvedHandler = typeof callbackOrEventType === "function" ? callbackOrEventType : handler;
297
- return new IlhaBuilder(this._schema, this._states, this._deriveds, [...this._ons, {
298
- selector: parsed.selector,
299
- event: parsed.eventType,
300
- options: parsed.options,
301
- handler: resolvedHandler
302
- }], this._effects, this._slots, this._transition, this._binds);
345
+ on(selectorOrCombined, handler) {
346
+ const parsed = parseOnArgs(selectorOrCombined);
347
+ return new IlhaBuilder({
348
+ ...this._cfg,
349
+ ons: [...this._cfg.ons, {
350
+ selector: parsed.selector,
351
+ event: parsed.eventType,
352
+ options: parsed.options,
353
+ handler
354
+ }]
355
+ });
303
356
  }
304
357
  effect(fn) {
305
- return new IlhaBuilder(this._schema, this._states, this._deriveds, this._ons, [...this._effects, { fn }], this._slots, this._transition, this._binds);
358
+ return new IlhaBuilder({
359
+ ...this._cfg,
360
+ effects: [...this._cfg.effects, { fn }]
361
+ });
362
+ }
363
+ onMount(fn) {
364
+ return new IlhaBuilder({
365
+ ...this._cfg,
366
+ onMounts: [...this._cfg.onMounts, { fn }]
367
+ });
306
368
  }
307
369
  slot(name, island) {
308
- return new IlhaBuilder(this._schema, this._states, this._deriveds, this._ons, this._effects, {
309
- ...this._slots,
310
- [name]: island
311
- }, this._transition, this._binds);
370
+ return new IlhaBuilder({
371
+ ...this._cfg,
372
+ slots: {
373
+ ...this._cfg.slots,
374
+ [name]: island
375
+ }
376
+ });
312
377
  }
313
378
  transition(opts) {
314
- return new IlhaBuilder(this._schema, this._states, this._deriveds, this._ons, this._effects, this._slots, opts, this._binds);
379
+ return new IlhaBuilder({
380
+ ...this._cfg,
381
+ transition: opts
382
+ });
315
383
  }
316
384
  render(fn) {
317
- const schema = this._schema;
318
- const states = this._states;
319
- const deriveds = this._deriveds;
320
- const ons = this._ons;
321
- const effects = this._effects;
322
- const slotDefs = this._slots;
323
- const transition = this._transition;
324
- const binds = this._binds;
385
+ const { schema, states, deriveds, ons, effects, onMounts, slots: slotDefs, transition, binds } = this._cfg;
325
386
  function resolveInput(props) {
326
387
  const value = props ?? {};
327
388
  if (!schema) return value;
328
389
  return validateSchema(schema, value);
329
390
  }
330
- function makeSlotsProxy(ssr) {
391
+ function makeSlotsProxy(ssr, host) {
331
392
  return new Proxy({}, { get(_, prop) {
332
393
  const name = String(prop);
333
394
  if (!slotDefs[name]) return makeSlotAccessor(() => "");
334
- if (ssr) return makeSlotAccessor((props) => slotDefs[name].toString(props));
395
+ if (ssr) return makeSlotAccessor((props) => {
396
+ const json = props ? ` ${PROPS_ATTR}='${escapeHtml(JSON.stringify(props))}'` : "";
397
+ return `<div ${SLOT_ATTR}="${escapeHtml(name)}"${json}>${slotDefs[name].toString(props)}</div>`;
398
+ });
335
399
  return makeSlotAccessor((props) => {
336
- const json = props ? ` data-props='${escapeHtml(JSON.stringify(props))}'` : "";
337
- return `<div data-ilha-slot="${escapeHtml(name)}"${json}></div>`;
400
+ const liveSlot = host?.querySelector(`[${SLOT_ATTR}="${escapeHtml(name)}"]`);
401
+ if (liveSlot) return liveSlot.outerHTML;
402
+ const json = props ? ` ${PROPS_ATTR}='${escapeHtml(JSON.stringify(props))}'` : "";
403
+ return `<div ${SLOT_ATTR}="${escapeHtml(name)}"${json}></div>`;
338
404
  });
339
405
  } });
340
406
  }
@@ -361,7 +427,7 @@ var IlhaBuilder = class IlhaBuilder {
361
427
  }
362
428
  return state;
363
429
  }
364
- function renderToString(props) {
430
+ function renderToString(props, sync = false) {
365
431
  const input = resolveInput(props);
366
432
  const state = buildPlainState(input);
367
433
  const slots = makeSlotsProxy(true);
@@ -382,19 +448,24 @@ var IlhaBuilder = class IlhaBuilder {
382
448
  };
383
449
  }
384
450
  });
385
- if (!results.some((r) => r.result instanceof Promise)) {
451
+ if (!results.some((r) => r.result instanceof Promise) || sync) {
386
452
  const derived = {};
387
- for (const r of results) derived[r.key] = {
453
+ for (const r of results) if (r.result instanceof Promise) derived[r.key] = {
454
+ loading: true,
455
+ value: void 0,
456
+ error: void 0
457
+ };
458
+ else derived[r.key] = {
388
459
  loading: false,
389
460
  value: r.result,
390
461
  error: void 0
391
462
  };
392
- return fn({
463
+ return unwrapHtml(fn({
393
464
  state,
394
465
  derived,
395
466
  input,
396
467
  slots
397
- });
468
+ }));
398
469
  }
399
470
  return Promise.all(results.map(async (r) => {
400
471
  try {
@@ -419,54 +490,82 @@ var IlhaBuilder = class IlhaBuilder {
419
490
  })).then((resolved) => {
420
491
  const derived = {};
421
492
  for (const r of resolved) derived[r.key] = r.envelope;
422
- return fn({
493
+ return unwrapHtml(fn({
423
494
  state,
424
495
  derived,
425
496
  input,
426
497
  slots
427
- });
498
+ }));
428
499
  });
429
500
  }
430
- function renderToStringSyncOnly(props) {
431
- const input = resolveInput(props);
432
- const state = buildPlainState(input);
433
- const slots = makeSlotsProxy(true);
434
- return fn({
435
- state,
436
- derived: makePlainDerived(deriveds, state, input),
437
- input,
438
- slots
439
- });
440
- }
441
- function mountIsland(el, props) {
501
+ function mountIsland(host, props) {
502
+ if (__DEV__ && _mountedHosts) {
503
+ if (_mountedHosts.has(host)) {
504
+ warn(`mount(): this element is already mounted. Call the previous unmount() first to avoid memory leaks and duplicate event listeners.
505
+ Element: ${host.outerHTML.slice(0, 120)}`);
506
+ return () => {};
507
+ }
508
+ _mountedHosts.add(host);
509
+ }
510
+ if (props === void 0) {
511
+ const rawProps = host.getAttribute(PROPS_ATTR);
512
+ if (rawProps) try {
513
+ props = JSON.parse(rawProps);
514
+ } catch {
515
+ warn("Failed to parse data-ilha-props — invalid JSON, falling back to empty props.");
516
+ }
517
+ }
442
518
  const input = resolveInput(props);
443
- let snapshot;
444
- const rawState = el.getAttribute(STATE_ATTR);
519
+ let snapshotRaw;
520
+ const rawState = host.getAttribute(STATE_ATTR);
445
521
  if (rawState) try {
446
- snapshot = JSON.parse(rawState);
522
+ snapshotRaw = JSON.parse(rawState);
447
523
  } catch {
448
- console.warn("[ilha] Failed to parse data-ilha-state");
524
+ warn("Failed to parse data-ilha-state — invalid JSON, snapshot ignored.");
525
+ }
526
+ const stateSnapshot = snapshotRaw ? Object.fromEntries(Object.entries(snapshotRaw).filter(([k]) => k !== "_derived" && k !== "_skipOnMount")) : void 0;
527
+ const derivedSnapshotRaw = snapshotRaw?._derived;
528
+ let derivedSnapshot;
529
+ if (derivedSnapshotRaw) {
530
+ derivedSnapshot = {};
531
+ for (const [k, v] of Object.entries(derivedSnapshotRaw)) if (v.error && !(v.error instanceof Error)) derivedSnapshot[k] = {
532
+ ...v,
533
+ error: new Error(String(v.error))
534
+ };
535
+ else derivedSnapshot[k] = v;
449
536
  }
450
- const state = buildSignalState(input, snapshot);
537
+ const hydrated = snapshotRaw != null;
538
+ const shouldSkipOnMount = hydrated && snapshotRaw?.["_skipOnMount"] === true;
539
+ const state = buildSignalState(input, stateSnapshot);
451
540
  const cleanups = [];
452
541
  if (transition?.enter) {
453
- const result = transition.enter(el);
542
+ const result = transition.enter(host);
454
543
  if (result instanceof Promise) result.catch(console.error);
455
544
  }
456
- const { proxy: derived, stop: stopDerived } = buildDerivedSignals(deriveds, state, input);
545
+ const { proxy: derived, stop: stopDerived } = buildDerivedSignals(deriveds, state, input, derivedSnapshot);
457
546
  cleanups.push(stopDerived);
547
+ const slotCleanups = /* @__PURE__ */ new Map();
458
548
  const slotEls = /* @__PURE__ */ new Map();
459
- function snapshotSlots() {
460
- slotEls.clear();
461
- for (const name of Object.keys(slotDefs)) {
462
- const existing = el.querySelector(`[${SLOT_ATTR}="${name}"]`);
463
- if (existing) slotEls.set(name, existing);
464
- }
465
- }
466
- function restoreSlots() {
467
- for (const [name, slotEl] of slotEls) {
468
- const placeholder = el.querySelector(`[${SLOT_ATTR}="${name}"]`);
469
- if (placeholder) placeholder.replaceWith(slotEl);
549
+ function mountSlots() {
550
+ for (const [name, childIsland] of Object.entries(slotDefs)) {
551
+ const slotEl = host.querySelector(`[${SLOT_ATTR}="${name}"]`);
552
+ if (!slotEl) continue;
553
+ if (slotEls.get(name) === slotEl) continue;
554
+ slotEls.set(name, slotEl);
555
+ slotCleanups.get(name)?.();
556
+ let slotProps;
557
+ const rawProps = slotEl.getAttribute(PROPS_ATTR) ?? slotEl.getAttribute("data-props");
558
+ if (rawProps) try {
559
+ slotProps = JSON.parse(rawProps);
560
+ } catch {
561
+ warn(`Failed to parse props on [${SLOT_ATTR}="${name}"] — invalid JSON ignored.`);
562
+ }
563
+ const prevSub = setActiveSub(void 0);
564
+ try {
565
+ slotCleanups.set(name, childIsland.mount(slotEl, slotProps));
566
+ } finally {
567
+ setActiveSub(prevSub);
568
+ }
470
569
  }
471
570
  }
472
571
  const listeners = [];
@@ -474,17 +573,22 @@ var IlhaBuilder = class IlhaBuilder {
474
573
  function attachListeners() {
475
574
  for (const entry of ons) {
476
575
  if (entry.options.once && firedOnce.has(entry)) continue;
477
- (entry.selector === "" ? [el] : Array.from(el.querySelectorAll(entry.selector))).forEach((target) => {
576
+ const targets = entry.selector === "" ? [host] : Array.from(host.querySelectorAll(entry.selector));
577
+ if (__DEV__ && entry.selector !== "" && targets.length === 0) warn(`on(): selector "${entry.selector}" matched no elements at mount time. If the element is rendered later, this is expected — otherwise check your selector.`);
578
+ targets.forEach((listenerTarget) => {
478
579
  const listener = (event) => {
479
580
  if (entry.options.once) {
480
581
  firedOnce.add(entry);
481
582
  for (const l of listeners.filter((l) => l.entry === entry)) l.target.removeEventListener(l.type, l.fn, l.options);
482
583
  listeners.splice(0, listeners.length, ...listeners.filter((l) => l.entry !== entry));
483
584
  }
585
+ const eventTarget = event.target instanceof Element ? event.target : listenerTarget;
484
586
  const result = entry.handler({
485
587
  state,
588
+ derived,
486
589
  input,
487
- el,
590
+ host,
591
+ target: eventTarget,
488
592
  event
489
593
  });
490
594
  if (result instanceof Promise) result.catch(console.error);
@@ -493,9 +597,9 @@ var IlhaBuilder = class IlhaBuilder {
493
597
  ...entry.options,
494
598
  once: false
495
599
  };
496
- target.addEventListener(entry.event, listener, opts);
600
+ listenerTarget.addEventListener(entry.event, listener, opts);
497
601
  listeners.push({
498
- target,
602
+ target: listenerTarget,
499
603
  type: entry.event,
500
604
  fn: listener,
501
605
  options: opts,
@@ -508,54 +612,54 @@ var IlhaBuilder = class IlhaBuilder {
508
612
  for (const l of listeners) l.target.removeEventListener(l.type, l.fn, l.options);
509
613
  listeners.length = 0;
510
614
  }
511
- const slots = makeSlotsProxy(false);
512
- el.innerHTML = fn({
615
+ const slots = makeSlotsProxy(false, host);
616
+ if (!(hydrated && host.childNodes.length > 0)) host.innerHTML = unwrapHtml(fn({
513
617
  state,
514
618
  derived,
515
619
  input,
516
620
  slots
517
- });
621
+ }));
518
622
  attachListeners();
519
- let stopBindings = applyBindings(el, binds, state);
623
+ let stopBindings = applyBindings(host, binds, state);
520
624
  cleanups.push(() => stopBindings());
521
- for (const [name, childIsland] of Object.entries(slotDefs)) {
522
- const slotEl = el.querySelector(`[${SLOT_ATTR}="${name}"]`);
523
- if (!slotEl) continue;
524
- slotEls.set(name, slotEl);
525
- let slotProps;
526
- const ilhaProps = slotEl.getAttribute(SLOT_PROPS_ATTR);
527
- const dataProps = slotEl.getAttribute("data-props");
528
- if (ilhaProps) try {
529
- slotProps = JSON.parse(ilhaProps);
530
- } catch {
531
- console.warn(`[ilha] Failed to parse ${SLOT_PROPS_ATTR} on [${SLOT_ATTR}="${name}"]`);
532
- }
533
- else if (dataProps) try {
534
- slotProps = JSON.parse(dataProps);
535
- } catch {
536
- console.warn(`[ilha] Failed to parse data-props on [${SLOT_ATTR}="${name}"]`);
625
+ mountSlots();
626
+ cleanups.push(() => slotCleanups.forEach((unmount) => unmount()));
627
+ if (!shouldSkipOnMount) for (const entry of onMounts) {
628
+ const prevSub = setActiveSub(void 0);
629
+ let userCleanup;
630
+ try {
631
+ userCleanup = entry.fn({
632
+ state,
633
+ derived,
634
+ input,
635
+ host,
636
+ hydrated
637
+ });
638
+ } finally {
639
+ setActiveSub(prevSub);
537
640
  }
538
- cleanups.push(childIsland.mount(slotEl, slotProps));
641
+ if (userCleanup) cleanups.push(userCleanup);
539
642
  }
540
643
  let initialized = false;
541
644
  const stopRender = effect(() => {
542
- const html = fn({
645
+ const html = unwrapHtml(fn({
543
646
  state,
544
647
  derived,
545
648
  input,
546
649
  slots
547
- });
650
+ }));
548
651
  if (!initialized) {
549
652
  initialized = true;
550
653
  return;
551
654
  }
552
- snapshotSlots();
553
655
  detachListeners();
554
656
  stopBindings();
555
- el.innerHTML = html;
556
- restoreSlots();
657
+ const tpl = document.createElement("template");
658
+ tpl.innerHTML = `<div>${html}</div>`;
659
+ morphInner(host, tpl.content.firstElementChild);
557
660
  attachListeners();
558
- stopBindings = applyBindings(el, binds, state);
661
+ stopBindings = applyBindings(host, binds, state);
662
+ mountSlots();
559
663
  });
560
664
  cleanups.push(stopRender);
561
665
  cleanups.push(detachListeners);
@@ -569,7 +673,7 @@ var IlhaBuilder = class IlhaBuilder {
569
673
  userCleanup = entry.fn({
570
674
  state,
571
675
  input,
572
- el
676
+ host
573
677
  });
574
678
  });
575
679
  cleanups.push(() => {
@@ -577,9 +681,13 @@ var IlhaBuilder = class IlhaBuilder {
577
681
  if (userCleanup) userCleanup();
578
682
  });
579
683
  }
684
+ let tornDown = false;
580
685
  return () => {
686
+ if (tornDown) return;
687
+ tornDown = true;
688
+ if (__DEV__ && _mountedHosts) _mountedHosts.delete(host);
581
689
  if (transition?.leave) {
582
- const result = transition.leave(el);
690
+ const result = transition.leave(host);
583
691
  if (result instanceof Promise) {
584
692
  result.then(() => cleanups.forEach((c) => c())).catch(console.error);
585
693
  return;
@@ -591,57 +699,110 @@ var IlhaBuilder = class IlhaBuilder {
591
699
  const island = function(props) {
592
700
  return renderToString(props);
593
701
  };
594
- island.toString = (props) => renderToStringSyncOnly(props);
595
- island.mount = (el, props) => mountIsland(el, props);
702
+ island.toString = (props) => renderToString(props, true);
703
+ island.mount = (host, props) => mountIsland(host, props);
704
+ island.hydratable = async (props, opts) => {
705
+ const { name, as: tag = "div", snapshot = false, skipOnMount: explicitSkipOnMount } = opts;
706
+ const resolvedProps = props ?? {};
707
+ const inner = await renderToString(resolvedProps);
708
+ const encodedProps = escapeHtml(JSON.stringify(resolvedProps));
709
+ let stateAttr = "";
710
+ if (snapshot !== false) {
711
+ const doState = snapshot === true || snapshot.state !== false;
712
+ const doDerived = snapshot === true || snapshot.derived !== false;
713
+ const doSkipOnMount = explicitSkipOnMount ?? (doState || doDerived);
714
+ const snapshotData = {};
715
+ const input = resolveInput(resolvedProps);
716
+ const plainState = buildPlainState(input);
717
+ if (doState) for (const entry of states) snapshotData[entry.key] = plainState[entry.key]();
718
+ if (doDerived) {
719
+ const derivedResults = {};
720
+ for (const entry of deriveds) try {
721
+ const result = await Promise.resolve(entry.fn({
722
+ state: plainState,
723
+ input,
724
+ signal: new AbortController().signal
725
+ }));
726
+ derivedResults[entry.key] = {
727
+ loading: false,
728
+ value: result,
729
+ error: void 0
730
+ };
731
+ } catch (err) {
732
+ derivedResults[entry.key] = {
733
+ loading: false,
734
+ value: void 0,
735
+ error: err instanceof Error ? err.message : String(err)
736
+ };
737
+ }
738
+ snapshotData["_derived"] = derivedResults;
739
+ }
740
+ if (doSkipOnMount) snapshotData["_skipOnMount"] = true;
741
+ stateAttr = ` ${STATE_ATTR}='${escapeHtml(JSON.stringify(snapshotData))}'`;
742
+ }
743
+ return `<${tag} data-ilha="${escapeHtml(name)}" ${PROPS_ATTR}='${encodedProps}'${stateAttr}>${inner}</${tag}>`;
744
+ };
596
745
  return island;
597
746
  }
598
747
  };
599
748
  function ilhaFrom(selector, island, props) {
600
- const el = typeof selector === "string" ? document.querySelector(selector) : selector;
601
- if (!el) {
749
+ const host = typeof selector === "string" ? document.querySelector(selector) : selector;
750
+ if (!host) {
602
751
  console.warn(`[ilha] from(): element not found: ${selector}`);
603
752
  return null;
604
753
  }
605
- return island.mount(el, props);
754
+ return island.mount(host, props);
606
755
  }
607
756
  function mountAll(registry, options = {}) {
608
757
  const root = options.root ?? document.body;
609
758
  const lazy = options.lazy ?? false;
610
- const hydrate = options.hydrate ?? false;
611
759
  const unmounts = [];
612
- function activateEl(el) {
613
- const name = el.getAttribute("data-ilha");
760
+ function activateEl(host) {
761
+ const name = host.getAttribute("data-ilha");
614
762
  if (!name) return;
615
763
  const island = registry[name];
616
- if (!island) return;
764
+ if (!island) {
765
+ warn(`mount(): no island registered under the name "${name}". Available names: [${Object.keys(registry).join(", ")}]. Check the data-ilha attribute on the element.`);
766
+ return;
767
+ }
617
768
  let props = {};
618
- const rawProps = el.getAttribute("data-props");
769
+ const rawProps = host.getAttribute(PROPS_ATTR);
619
770
  if (rawProps) try {
620
771
  props = JSON.parse(rawProps);
621
772
  } catch {
622
- console.warn(`[ilha] Failed to parse data-props on [data-ilha="${name}"]`);
773
+ warn(`Failed to parse ${PROPS_ATTR} on [data-ilha="${name}"] — invalid JSON ignored.`);
623
774
  }
624
- if (hydrate) {
625
- const snapshot = el.innerHTML;
626
- const unmount = island.mount(el, props);
627
- if (!el.innerHTML) el.innerHTML = snapshot;
628
- unmounts.push(unmount);
629
- } else unmounts.push(island.mount(el, props));
775
+ unmounts.push(island.mount(host, props));
630
776
  }
631
777
  const els = Array.from(root.querySelectorAll("[data-ilha]"));
632
778
  if (lazy && typeof IntersectionObserver !== "undefined") {
779
+ let disposed = false;
633
780
  const io = new IntersectionObserver((entries) => {
781
+ if (disposed) return;
634
782
  for (const entry of entries) if (entry.isIntersecting) {
635
783
  activateEl(entry.target);
636
784
  io.unobserve(entry.target);
637
785
  }
638
786
  });
639
787
  els.forEach((el) => io.observe(el));
640
- unmounts.push(() => io.disconnect());
788
+ unmounts.push(() => {
789
+ disposed = true;
790
+ io.disconnect();
791
+ });
641
792
  } else els.forEach(activateEl);
642
793
  return { unmount: () => unmounts.forEach((u) => u()) };
643
794
  }
644
- const rootBuilder = new IlhaBuilder(null, [], [], [], [], {}, null, []);
795
+ const rootBuilder = new IlhaBuilder({
796
+ schema: null,
797
+ states: [],
798
+ deriveds: [],
799
+ ons: [],
800
+ effects: [],
801
+ onMounts: [],
802
+ slots: {},
803
+ transition: null,
804
+ binds: []
805
+ });
645
806
  const ilha = Object.assign(rootBuilder, {
646
807
  html: ilhaHtml,
647
808
  raw: ilhaRaw,
@@ -649,10 +810,19 @@ const ilha = Object.assign(rootBuilder, {
649
810
  from: ilhaFrom,
650
811
  context: ilhaContext
651
812
  });
813
+ function type(coerce) {
814
+ return { "~standard": {
815
+ version: 1,
816
+ vendor: "ilha",
817
+ validate(value) {
818
+ return { value: coerce ? coerce(value) : value };
819
+ }
820
+ } };
821
+ }
652
822
  const html = ilhaHtml;
653
823
  const raw = ilhaRaw;
654
824
  const mount = mountAll;
655
825
  const from = ilhaFrom;
656
826
  const context = ilhaContext;
657
827
  //#endregion
658
- export { context, ilha as default, from, html, mount, raw };
828
+ export { context, ilha as default, from, html, mount, raw, type };