ilha 0.0.1 → 0.2.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 +477 -175
  2. package/dist/index.d.ts +83 -32
  3. package/dist/index.js +409 -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,8 +80,12 @@ 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";
85
+ const CSS_ATTR = "data-ilha-css";
86
+ function buildScopedStyle(css) {
87
+ return `<style ${CSS_ATTR}>@scope (:scope) to ([data-ilha]){${css}}</style>`;
88
+ }
26
89
  function makeSlotAccessor(render) {
27
90
  const fn = (props) => ({
28
91
  [RAW]: true,
@@ -48,21 +111,37 @@ function ilhaRaw(value) {
48
111
  value
49
112
  };
50
113
  }
114
+ function ilhaCss(strings, ...values) {
115
+ if (typeof strings === "string") return strings;
116
+ let result = "";
117
+ for (let i = 0; i < strings.length; i++) {
118
+ result += strings[i];
119
+ if (i < values.length) result += String(values[i]);
120
+ }
121
+ return result;
122
+ }
123
+ function interpolateValue(v) {
124
+ if (v == null) return "";
125
+ if (Array.isArray(v)) return v.map(interpolateValue).join("");
126
+ if (typeof v === "object" && RAW in v) return v.value;
127
+ if (isSlotAccessor(v)) return v.toString();
128
+ if (isSignalAccessor(v)) return escapeHtml(v());
129
+ if (typeof v === "function") return escapeHtml(v());
130
+ return escapeHtml(v);
131
+ }
51
132
  function ilhaHtml(strings, ...values) {
52
133
  let result = "";
53
134
  for (let i = 0; i < strings.length; i++) {
54
135
  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
- }
136
+ if (i < values.length) result += interpolateValue(values[i]);
64
137
  }
65
- return dedentString(result);
138
+ return {
139
+ [RAW]: true,
140
+ value: dedentString(result)
141
+ };
142
+ }
143
+ function unwrapHtml(v) {
144
+ return typeof v === "object" && RAW in v ? v.value : v;
66
145
  }
67
146
  const contextRegistry = /* @__PURE__ */ new Map();
68
147
  function ilhaContext(key, initial) {
@@ -75,39 +154,23 @@ function ilhaContext(key, initial) {
75
154
  contextRegistry.set(key, accessor);
76
155
  return accessor;
77
156
  }
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) {
157
+ function buildDerivedSignals(entries, state, input, derivedSnapshot) {
100
158
  const envelopes = /* @__PURE__ */ new Map();
101
159
  const stops = [];
102
160
  for (const entry of entries) {
103
- const env = signal({
161
+ const env = signal(derivedSnapshot?.[entry.key] ?? {
104
162
  loading: true,
105
163
  value: void 0,
106
164
  error: void 0
107
165
  });
108
166
  envelopes.set(entry.key, env);
109
167
  let ac = new AbortController();
168
+ let skipFirst = derivedSnapshot != null && entry.key in derivedSnapshot;
110
169
  const stopEffect = effect(() => {
170
+ if (skipFirst) {
171
+ skipFirst = false;
172
+ return;
173
+ }
111
174
  ac.abort();
112
175
  ac = new AbortController();
113
176
  const currentAc = ac;
@@ -184,8 +247,7 @@ function resolveBindConfig(el) {
184
247
  return input.checked ? input.value : void 0;
185
248
  },
186
249
  write: (el, v) => {
187
- const input = el;
188
- input.checked = String(v ?? "") === input.value;
250
+ el.checked = String(v ?? "") === el.value;
189
251
  }
190
252
  };
191
253
  if (tag === "input" && type === "number") return {
@@ -201,15 +263,15 @@ function resolveBindConfig(el) {
201
263
  write: (el, v) => el.value = String(v ?? "")
202
264
  };
203
265
  }
204
- function applyBindings(el, bindings, state) {
266
+ function applyBindings(host, bindings, state) {
205
267
  const cleanups = [];
206
268
  for (const binding of bindings) {
207
- const targets = binding.selector === "" ? [el] : Array.from(el.querySelectorAll(binding.selector));
269
+ const accessor = typeof binding.target === "function" ? binding.target : state[binding.target];
270
+ const targets = binding.selector === "" ? [host] : Array.from(host.querySelectorAll(binding.selector));
271
+ 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
272
  for (const target of targets) {
209
273
  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";
274
+ const isRadio = target.tagName.toLowerCase() === "input" && target.type?.toLowerCase() === "radio";
213
275
  write(target, accessor());
214
276
  const listener = () => {
215
277
  const raw = read(target);
@@ -229,112 +291,148 @@ function applyBindings(el, bindings, state) {
229
291
  }
230
292
  return () => cleanups.forEach((c) => c());
231
293
  }
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(":");
294
+ function parseOnArgs(selectorOrCombined) {
295
+ const atIdx = selectorOrCombined.lastIndexOf("@");
296
+ const selector = atIdx === -1 ? "" : selectorOrCombined.slice(0, atIdx);
297
+ const parts = (atIdx === -1 ? selectorOrCombined : selectorOrCombined.slice(atIdx + 1)).split(":");
249
298
  const eventType = parts[0];
250
299
  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
300
  return {
257
301
  selector,
258
302
  eventType,
259
- options
303
+ options: {
304
+ once: modifiers.has("once"),
305
+ capture: modifiers.has("capture"),
306
+ passive: modifiers.has("passive")
307
+ }
260
308
  };
261
309
  }
310
+ const _mountedHosts = __DEV__ ? /* @__PURE__ */ new WeakSet() : null;
262
311
  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;
312
+ _cfg;
313
+ constructor(cfg) {
314
+ this._cfg = cfg;
272
315
  }
273
316
  input(schema) {
274
- return new IlhaBuilder(schema, [], [], [], [], {}, null, []);
317
+ return new IlhaBuilder({
318
+ schema,
319
+ states: [],
320
+ deriveds: [],
321
+ ons: [],
322
+ effects: [],
323
+ onMounts: [],
324
+ slots: {},
325
+ transition: null,
326
+ binds: [],
327
+ css: null
328
+ });
275
329
  }
276
330
  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);
331
+ const cfg = this._cfg;
332
+ return new IlhaBuilder({
333
+ ...cfg,
334
+ states: [...cfg.states, {
335
+ key,
336
+ init
337
+ }]
338
+ });
281
339
  }
282
340
  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);
341
+ const cfg = this._cfg;
342
+ return new IlhaBuilder({
343
+ ...cfg,
344
+ deriveds: [...cfg.deriveds, {
345
+ key,
346
+ fn
347
+ }]
348
+ });
287
349
  }
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
- }]);
350
+ bind(selector, target) {
351
+ return new IlhaBuilder({
352
+ ...this._cfg,
353
+ binds: [...this._cfg.binds, {
354
+ selector,
355
+ target
356
+ }]
357
+ });
293
358
  }
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);
359
+ on(selectorOrCombined, handler) {
360
+ const parsed = parseOnArgs(selectorOrCombined);
361
+ return new IlhaBuilder({
362
+ ...this._cfg,
363
+ ons: [...this._cfg.ons, {
364
+ selector: parsed.selector,
365
+ event: parsed.eventType,
366
+ options: parsed.options,
367
+ handler
368
+ }]
369
+ });
303
370
  }
304
371
  effect(fn) {
305
- return new IlhaBuilder(this._schema, this._states, this._deriveds, this._ons, [...this._effects, { fn }], this._slots, this._transition, this._binds);
372
+ return new IlhaBuilder({
373
+ ...this._cfg,
374
+ effects: [...this._cfg.effects, { fn }]
375
+ });
376
+ }
377
+ onMount(fn) {
378
+ return new IlhaBuilder({
379
+ ...this._cfg,
380
+ onMounts: [...this._cfg.onMounts, { fn }]
381
+ });
306
382
  }
307
383
  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);
384
+ return new IlhaBuilder({
385
+ ...this._cfg,
386
+ slots: {
387
+ ...this._cfg.slots,
388
+ [name]: island
389
+ }
390
+ });
312
391
  }
313
392
  transition(opts) {
314
- return new IlhaBuilder(this._schema, this._states, this._deriveds, this._ons, this._effects, this._slots, opts, this._binds);
393
+ return new IlhaBuilder({
394
+ ...this._cfg,
395
+ transition: opts
396
+ });
397
+ }
398
+ css(strings, ...values) {
399
+ let source;
400
+ if (typeof strings === "string") source = strings;
401
+ else {
402
+ let result = "";
403
+ for (let i = 0; i < strings.length; i++) {
404
+ result += strings[i];
405
+ if (i < values.length) result += String(values[i]);
406
+ }
407
+ source = result;
408
+ }
409
+ if (__DEV__ && this._cfg.css !== null) warn("css(): called more than once on the same builder chain. The previous stylesheet has been discarded. Compose styles into a single .css() call instead.");
410
+ return new IlhaBuilder({
411
+ ...this._cfg,
412
+ css: source
413
+ });
315
414
  }
316
415
  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;
416
+ const { schema, states, deriveds, ons, effects, onMounts, slots: slotDefs, transition, binds, css: cssSource } = this._cfg;
417
+ const stylePrefix = cssSource != null ? buildScopedStyle(cssSource) : "";
325
418
  function resolveInput(props) {
326
419
  const value = props ?? {};
327
420
  if (!schema) return value;
328
421
  return validateSchema(schema, value);
329
422
  }
330
- function makeSlotsProxy(ssr) {
423
+ function makeSlotsProxy(ssr, host) {
331
424
  return new Proxy({}, { get(_, prop) {
332
425
  const name = String(prop);
333
426
  if (!slotDefs[name]) return makeSlotAccessor(() => "");
334
- if (ssr) return makeSlotAccessor((props) => slotDefs[name].toString(props));
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
+ });
335
431
  return makeSlotAccessor((props) => {
336
- const json = props ? ` data-props='${escapeHtml(JSON.stringify(props))}'` : "";
337
- return `<div data-ilha-slot="${escapeHtml(name)}"${json}></div>`;
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>`;
338
436
  });
339
437
  } });
340
438
  }
@@ -361,7 +459,7 @@ var IlhaBuilder = class IlhaBuilder {
361
459
  }
362
460
  return state;
363
461
  }
364
- function renderToString(props) {
462
+ function renderToString(props, sync = false) {
365
463
  const input = resolveInput(props);
366
464
  const state = buildPlainState(input);
367
465
  const slots = makeSlotsProxy(true);
@@ -382,19 +480,24 @@ var IlhaBuilder = class IlhaBuilder {
382
480
  };
383
481
  }
384
482
  });
385
- if (!results.some((r) => r.result instanceof Promise)) {
483
+ if (!results.some((r) => r.result instanceof Promise) || sync) {
386
484
  const derived = {};
387
- for (const r of results) derived[r.key] = {
485
+ for (const r of results) if (r.result instanceof Promise) derived[r.key] = {
486
+ loading: true,
487
+ value: void 0,
488
+ error: void 0
489
+ };
490
+ else derived[r.key] = {
388
491
  loading: false,
389
492
  value: r.result,
390
493
  error: void 0
391
494
  };
392
- return fn({
495
+ return stylePrefix + unwrapHtml(fn({
393
496
  state,
394
497
  derived,
395
498
  input,
396
499
  slots
397
- });
500
+ }));
398
501
  }
399
502
  return Promise.all(results.map(async (r) => {
400
503
  try {
@@ -419,54 +522,82 @@ var IlhaBuilder = class IlhaBuilder {
419
522
  })).then((resolved) => {
420
523
  const derived = {};
421
524
  for (const r of resolved) derived[r.key] = r.envelope;
422
- return fn({
525
+ return stylePrefix + unwrapHtml(fn({
423
526
  state,
424
527
  derived,
425
528
  input,
426
529
  slots
427
- });
428
- });
429
- }
430
- function renderToStringSyncOnly(props) {
431
- const input = resolveInput(props);
432
- const state = buildPlainState(input);
433
- const slots = makeSlotsProxy(true);
434
- return fn({
435
- state,
436
- derived: makePlainDerived(deriveds, state, input),
437
- input,
438
- slots
530
+ }));
439
531
  });
440
532
  }
441
- function mountIsland(el, props) {
533
+ function mountIsland(host, props) {
534
+ if (__DEV__ && _mountedHosts) {
535
+ if (_mountedHosts.has(host)) {
536
+ warn(`mount(): this element is already mounted. Call the previous unmount() first to avoid memory leaks and duplicate event listeners.
537
+ Element: ${host.outerHTML.slice(0, 120)}`);
538
+ return () => {};
539
+ }
540
+ _mountedHosts.add(host);
541
+ }
542
+ if (props === void 0) {
543
+ const rawProps = host.getAttribute(PROPS_ATTR);
544
+ if (rawProps) try {
545
+ props = JSON.parse(rawProps);
546
+ } catch {
547
+ warn("Failed to parse data-ilha-props — invalid JSON, falling back to empty props.");
548
+ }
549
+ }
442
550
  const input = resolveInput(props);
443
- let snapshot;
444
- const rawState = el.getAttribute(STATE_ATTR);
551
+ let snapshotRaw;
552
+ const rawState = host.getAttribute(STATE_ATTR);
445
553
  if (rawState) try {
446
- snapshot = JSON.parse(rawState);
554
+ snapshotRaw = JSON.parse(rawState);
447
555
  } catch {
448
- console.warn("[ilha] Failed to parse data-ilha-state");
556
+ warn("Failed to parse data-ilha-state — invalid JSON, snapshot ignored.");
557
+ }
558
+ const stateSnapshot = snapshotRaw ? Object.fromEntries(Object.entries(snapshotRaw).filter(([k]) => k !== "_derived" && k !== "_skipOnMount")) : void 0;
559
+ const derivedSnapshotRaw = snapshotRaw?._derived;
560
+ let derivedSnapshot;
561
+ if (derivedSnapshotRaw) {
562
+ derivedSnapshot = {};
563
+ for (const [k, v] of Object.entries(derivedSnapshotRaw)) if (v.error && !(v.error instanceof Error)) derivedSnapshot[k] = {
564
+ ...v,
565
+ error: new Error(String(v.error))
566
+ };
567
+ else derivedSnapshot[k] = v;
449
568
  }
450
- const state = buildSignalState(input, snapshot);
569
+ const hydrated = snapshotRaw != null;
570
+ const shouldSkipOnMount = hydrated && snapshotRaw?.["_skipOnMount"] === true;
571
+ const state = buildSignalState(input, stateSnapshot);
451
572
  const cleanups = [];
452
573
  if (transition?.enter) {
453
- const result = transition.enter(el);
574
+ const result = transition.enter(host);
454
575
  if (result instanceof Promise) result.catch(console.error);
455
576
  }
456
- const { proxy: derived, stop: stopDerived } = buildDerivedSignals(deriveds, state, input);
577
+ const { proxy: derived, stop: stopDerived } = buildDerivedSignals(deriveds, state, input, derivedSnapshot);
457
578
  cleanups.push(stopDerived);
579
+ const slotCleanups = /* @__PURE__ */ new Map();
458
580
  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);
581
+ function mountSlots() {
582
+ for (const [name, childIsland] of Object.entries(slotDefs)) {
583
+ const slotEl = host.querySelector(`[${SLOT_ATTR}="${name}"]`);
584
+ 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.`);
594
+ }
595
+ const prevSub = setActiveSub(void 0);
596
+ try {
597
+ slotCleanups.set(name, childIsland.mount(slotEl, slotProps));
598
+ } finally {
599
+ setActiveSub(prevSub);
600
+ }
470
601
  }
471
602
  }
472
603
  const listeners = [];
@@ -474,17 +605,22 @@ var IlhaBuilder = class IlhaBuilder {
474
605
  function attachListeners() {
475
606
  for (const entry of ons) {
476
607
  if (entry.options.once && firedOnce.has(entry)) continue;
477
- (entry.selector === "" ? [el] : Array.from(el.querySelectorAll(entry.selector))).forEach((target) => {
608
+ const targets = entry.selector === "" ? [host] : Array.from(host.querySelectorAll(entry.selector));
609
+ 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.`);
610
+ targets.forEach((listenerTarget) => {
478
611
  const listener = (event) => {
479
612
  if (entry.options.once) {
480
613
  firedOnce.add(entry);
481
614
  for (const l of listeners.filter((l) => l.entry === entry)) l.target.removeEventListener(l.type, l.fn, l.options);
482
615
  listeners.splice(0, listeners.length, ...listeners.filter((l) => l.entry !== entry));
483
616
  }
617
+ const eventTarget = event.target instanceof Element ? event.target : listenerTarget;
484
618
  const result = entry.handler({
485
619
  state,
620
+ derived,
486
621
  input,
487
- el,
622
+ host,
623
+ target: eventTarget,
488
624
  event
489
625
  });
490
626
  if (result instanceof Promise) result.catch(console.error);
@@ -493,9 +629,9 @@ var IlhaBuilder = class IlhaBuilder {
493
629
  ...entry.options,
494
630
  once: false
495
631
  };
496
- target.addEventListener(entry.event, listener, opts);
632
+ listenerTarget.addEventListener(entry.event, listener, opts);
497
633
  listeners.push({
498
- target,
634
+ target: listenerTarget,
499
635
  type: entry.event,
500
636
  fn: listener,
501
637
  options: opts,
@@ -508,54 +644,54 @@ var IlhaBuilder = class IlhaBuilder {
508
644
  for (const l of listeners) l.target.removeEventListener(l.type, l.fn, l.options);
509
645
  listeners.length = 0;
510
646
  }
511
- const slots = makeSlotsProxy(false);
512
- el.innerHTML = fn({
647
+ const slots = makeSlotsProxy(false, host);
648
+ if (!(hydrated && host.childNodes.length > 0)) host.innerHTML = stylePrefix + unwrapHtml(fn({
513
649
  state,
514
650
  derived,
515
651
  input,
516
652
  slots
517
- });
653
+ }));
518
654
  attachListeners();
519
- let stopBindings = applyBindings(el, binds, state);
655
+ let stopBindings = applyBindings(host, binds, state);
520
656
  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}"]`);
657
+ mountSlots();
658
+ cleanups.push(() => slotCleanups.forEach((unmount) => unmount()));
659
+ if (!shouldSkipOnMount) for (const entry of onMounts) {
660
+ const prevSub = setActiveSub(void 0);
661
+ let userCleanup;
662
+ try {
663
+ userCleanup = entry.fn({
664
+ state,
665
+ derived,
666
+ input,
667
+ host,
668
+ hydrated
669
+ });
670
+ } finally {
671
+ setActiveSub(prevSub);
537
672
  }
538
- cleanups.push(childIsland.mount(slotEl, slotProps));
673
+ if (userCleanup) cleanups.push(userCleanup);
539
674
  }
540
675
  let initialized = false;
541
676
  const stopRender = effect(() => {
542
- const html = fn({
677
+ const html = stylePrefix + unwrapHtml(fn({
543
678
  state,
544
679
  derived,
545
680
  input,
546
681
  slots
547
- });
682
+ }));
548
683
  if (!initialized) {
549
684
  initialized = true;
550
685
  return;
551
686
  }
552
- snapshotSlots();
553
687
  detachListeners();
554
688
  stopBindings();
555
- el.innerHTML = html;
556
- restoreSlots();
689
+ const tpl = document.createElement("template");
690
+ tpl.innerHTML = `<div>${html}</div>`;
691
+ morphInner(host, tpl.content.firstElementChild);
557
692
  attachListeners();
558
- stopBindings = applyBindings(el, binds, state);
693
+ stopBindings = applyBindings(host, binds, state);
694
+ mountSlots();
559
695
  });
560
696
  cleanups.push(stopRender);
561
697
  cleanups.push(detachListeners);
@@ -569,7 +705,7 @@ var IlhaBuilder = class IlhaBuilder {
569
705
  userCleanup = entry.fn({
570
706
  state,
571
707
  input,
572
- el
708
+ host
573
709
  });
574
710
  });
575
711
  cleanups.push(() => {
@@ -577,9 +713,13 @@ var IlhaBuilder = class IlhaBuilder {
577
713
  if (userCleanup) userCleanup();
578
714
  });
579
715
  }
716
+ let tornDown = false;
580
717
  return () => {
718
+ if (tornDown) return;
719
+ tornDown = true;
720
+ if (__DEV__ && _mountedHosts) _mountedHosts.delete(host);
581
721
  if (transition?.leave) {
582
- const result = transition.leave(el);
722
+ const result = transition.leave(host);
583
723
  if (result instanceof Promise) {
584
724
  result.then(() => cleanups.forEach((c) => c())).catch(console.error);
585
725
  return;
@@ -591,57 +731,111 @@ var IlhaBuilder = class IlhaBuilder {
591
731
  const island = function(props) {
592
732
  return renderToString(props);
593
733
  };
594
- island.toString = (props) => renderToStringSyncOnly(props);
595
- island.mount = (el, props) => mountIsland(el, props);
734
+ island.toString = (props) => renderToString(props, true);
735
+ island.mount = (host, props) => mountIsland(host, props);
736
+ island.hydratable = async (props, opts) => {
737
+ const { name, as: tag = "div", snapshot = false, skipOnMount: explicitSkipOnMount } = opts;
738
+ const resolvedProps = props ?? {};
739
+ const inner = await renderToString(resolvedProps);
740
+ const encodedProps = escapeHtml(JSON.stringify(resolvedProps));
741
+ let stateAttr = "";
742
+ if (snapshot !== false) {
743
+ const doState = snapshot === true || snapshot.state !== false;
744
+ const doDerived = snapshot === true || snapshot.derived !== false;
745
+ const doSkipOnMount = explicitSkipOnMount ?? (doState || doDerived);
746
+ const snapshotData = {};
747
+ const input = resolveInput(resolvedProps);
748
+ const plainState = buildPlainState(input);
749
+ if (doState) for (const entry of states) snapshotData[entry.key] = plainState[entry.key]();
750
+ if (doDerived) {
751
+ const derivedResults = {};
752
+ for (const entry of deriveds) try {
753
+ const result = await Promise.resolve(entry.fn({
754
+ state: plainState,
755
+ input,
756
+ signal: new AbortController().signal
757
+ }));
758
+ derivedResults[entry.key] = {
759
+ loading: false,
760
+ value: result,
761
+ error: void 0
762
+ };
763
+ } catch (err) {
764
+ derivedResults[entry.key] = {
765
+ loading: false,
766
+ value: void 0,
767
+ error: err instanceof Error ? err.message : String(err)
768
+ };
769
+ }
770
+ snapshotData["_derived"] = derivedResults;
771
+ }
772
+ if (doSkipOnMount) snapshotData["_skipOnMount"] = true;
773
+ stateAttr = ` ${STATE_ATTR}='${escapeHtml(JSON.stringify(snapshotData))}'`;
774
+ }
775
+ return `<${tag} data-ilha="${escapeHtml(name)}" ${PROPS_ATTR}='${encodedProps}'${stateAttr}>${inner}</${tag}>`;
776
+ };
596
777
  return island;
597
778
  }
598
779
  };
599
780
  function ilhaFrom(selector, island, props) {
600
- const el = typeof selector === "string" ? document.querySelector(selector) : selector;
601
- if (!el) {
781
+ const host = typeof selector === "string" ? document.querySelector(selector) : selector;
782
+ if (!host) {
602
783
  console.warn(`[ilha] from(): element not found: ${selector}`);
603
784
  return null;
604
785
  }
605
- return island.mount(el, props);
786
+ return island.mount(host, props);
606
787
  }
607
788
  function mountAll(registry, options = {}) {
608
789
  const root = options.root ?? document.body;
609
790
  const lazy = options.lazy ?? false;
610
- const hydrate = options.hydrate ?? false;
611
791
  const unmounts = [];
612
- function activateEl(el) {
613
- const name = el.getAttribute("data-ilha");
792
+ function activateEl(host) {
793
+ const name = host.getAttribute("data-ilha");
614
794
  if (!name) return;
615
795
  const island = registry[name];
616
- if (!island) return;
796
+ if (!island) {
797
+ warn(`mount(): no island registered under the name "${name}". Available names: [${Object.keys(registry).join(", ")}]. Check the data-ilha attribute on the element.`);
798
+ return;
799
+ }
617
800
  let props = {};
618
- const rawProps = el.getAttribute("data-props");
801
+ const rawProps = host.getAttribute(PROPS_ATTR);
619
802
  if (rawProps) try {
620
803
  props = JSON.parse(rawProps);
621
804
  } catch {
622
- console.warn(`[ilha] Failed to parse data-props on [data-ilha="${name}"]`);
805
+ warn(`Failed to parse ${PROPS_ATTR} on [data-ilha="${name}"] — invalid JSON ignored.`);
623
806
  }
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));
807
+ unmounts.push(island.mount(host, props));
630
808
  }
631
809
  const els = Array.from(root.querySelectorAll("[data-ilha]"));
632
810
  if (lazy && typeof IntersectionObserver !== "undefined") {
811
+ let disposed = false;
633
812
  const io = new IntersectionObserver((entries) => {
813
+ if (disposed) return;
634
814
  for (const entry of entries) if (entry.isIntersecting) {
635
815
  activateEl(entry.target);
636
816
  io.unobserve(entry.target);
637
817
  }
638
818
  });
639
819
  els.forEach((el) => io.observe(el));
640
- unmounts.push(() => io.disconnect());
820
+ unmounts.push(() => {
821
+ disposed = true;
822
+ io.disconnect();
823
+ });
641
824
  } else els.forEach(activateEl);
642
825
  return { unmount: () => unmounts.forEach((u) => u()) };
643
826
  }
644
- const rootBuilder = new IlhaBuilder(null, [], [], [], [], {}, null, []);
827
+ const rootBuilder = new IlhaBuilder({
828
+ schema: null,
829
+ states: [],
830
+ deriveds: [],
831
+ ons: [],
832
+ effects: [],
833
+ onMounts: [],
834
+ slots: {},
835
+ transition: null,
836
+ binds: [],
837
+ css: null
838
+ });
645
839
  const ilha = Object.assign(rootBuilder, {
646
840
  html: ilhaHtml,
647
841
  raw: ilhaRaw,
@@ -649,10 +843,20 @@ const ilha = Object.assign(rootBuilder, {
649
843
  from: ilhaFrom,
650
844
  context: ilhaContext
651
845
  });
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
+ }
652
855
  const html = ilhaHtml;
653
856
  const raw = ilhaRaw;
857
+ const css = ilhaCss;
654
858
  const mount = mountAll;
655
859
  const from = ilhaFrom;
656
860
  const context = ilhaContext;
657
861
  //#endregion
658
- export { context, ilha as default, from, html, mount, raw };
862
+ export { context, css, ilha as default, from, html, mount, raw, type };