@deijose/nix-js 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.
@@ -0,0 +1,793 @@
1
+ // ═══════════════════════════════════════════════
2
+ // Nix.js ❄️ — Template Engine (Fase 2)
3
+ // ═══════════════════════════════════════════════
4
+ //
5
+ // html`<p>Hola ${() => name.value}</p>`
6
+ // → NixTemplate { mount(el), _render(parent, before) }
7
+ //
8
+ // Tipos de binding:
9
+ // 1. texto estático → string / number directo
10
+ // 2. texto reactivo → () => primitivo
11
+ // 3. evento → @event=${handler}
12
+ // 4. atributo → attr=${fn | valor}
13
+ // 5. template anidado → html`` directo
14
+ // 6. condicional → () => html`` | null
15
+ // 7. lista → () => html``[]
16
+
17
+ import { effect } from "./reactivity";
18
+ import { isNixComponent } from "./lifecycle";
19
+ import type { NixComponent } from "./lifecycle";
20
+ import {
21
+ _captureContextSnapshot,
22
+ _pushComponentContext,
23
+ _popComponentContext,
24
+ _withComponentContext,
25
+ } from "./context";
26
+
27
+ // ─── Tipos públicos ───────────────────────────────────────────────────────────
28
+
29
+ export interface NixTemplate {
30
+ readonly __isNixTemplate: true;
31
+ /** Monta el template en un contenedor (uso externo / raíz). */
32
+ mount(container: Element | string): NixMountHandle;
33
+ /**
34
+ * @internal — Renderiza el template antes del nodo `before` (o al final
35
+ * de `parent` si `before` es null). Retorna una función de limpieza.
36
+ */
37
+ _render(parent: Node, before: Node | null): () => void;
38
+ }
39
+
40
+ export interface NixMountHandle {
41
+ unmount(): void;
42
+ }
43
+
44
+ /**
45
+ * Contenedor para una referencia directa a un elemento DOM.
46
+ * Se asigna automáticamente cuando el template se monta y se limpia al
47
+ * desmontarse.
48
+ *
49
+ * @example
50
+ * const inputRef = ref<HTMLInputElement>();
51
+ * html`<input ref=${inputRef} />`
52
+ * // después del mount:
53
+ * inputRef.el?.focus();
54
+ */
55
+ export interface NixRef<T extends Element = Element> {
56
+ el: T | null;
57
+ }
58
+
59
+ /**
60
+ * Crea un objeto `NixRef` vacío.
61
+ * Pásalo como valor del atributo especial `ref` en un template para que
62
+ * Nix.js rellene automáticamente `ref.el` con el elemento real del DOM.
63
+ */
64
+ export function ref<T extends Element = Element>(): NixRef<T> {
65
+ return { el: null };
66
+ }
67
+
68
+ /**
69
+ * Resultado de `repeat()` — lista con keys para diffing eficiente.
70
+ * El template engine lo reconoce y solo añade/mueve/elimina los nodos
71
+ * que realmente cambiaron, preservando el DOM de los items estables.
72
+ */
73
+ export interface KeyedList<T = unknown> {
74
+ readonly __isKeyedList: true;
75
+ readonly items: T[];
76
+ readonly keyFn: (item: T, index: number) => string | number;
77
+ readonly renderFn: (item: T, index: number) => NixTemplate | NixComponent;
78
+ }
79
+
80
+ /**
81
+ * Crea una lista con keys para diffing eficiente.
82
+ * Úsalo en lugar de `.map()` cuando la lista cambia frecuentemente.
83
+ *
84
+ * @param items Array reactivo de datos
85
+ * @param keyFn Devuelve una clave única por item (p.ej. `item => item.id`)
86
+ * @param renderFn Devuelve el template/componente para cada item
87
+ *
88
+ * @example
89
+ * ${() => repeat(
90
+ * users.value,
91
+ * u => u.id,
92
+ * u => html`<li>${u.name}</li>`
93
+ * )}
94
+ */
95
+ export function repeat<T>(
96
+ items: T[],
97
+ keyFn: (item: T, index: number) => string | number,
98
+ renderFn: (item: T, index: number) => NixTemplate | NixComponent
99
+ ): KeyedList<T> {
100
+ return { __isKeyedList: true as const, items, keyFn, renderFn };
101
+ }
102
+
103
+ // ─── Contexto de binding ──────────────────────────────────────────────────────
104
+
105
+ type BindingContext =
106
+ | { type: "node" }
107
+ | { type: "event"; eventName: string; modifiers: string[]; hadOpenQuote: boolean }
108
+ | { type: "attr"; attrName: string; hadOpenQuote: boolean };
109
+
110
+ /**
111
+ * Examina el string que PRECEDE a un valor interpolado y determina
112
+ * el contexto en que aparece: nodo, evento o atributo.
113
+ *
114
+ * "<p>" → node
115
+ * "<button @click=" → event (hadOpenQuote = true)
116
+ * "<div class=" → attr (hadOpenQuote = true)
117
+ * "<div class= → attr (hadOpenQuote = false)
118
+ *
119
+ * ⚠️ LIMITACIÓN — Interpolación parcial de atributos NO soportada:
120
+ *
121
+ * ✅ html`<div class="${cls}">` ← el valor completo es una interpolación
122
+ * ❌ html`<div class="prefix-${cls}">` ← literal + interpolación mezclados
123
+ *
124
+ * En el segundo caso, detectContext ve el string previo terminando en
125
+ * `class="prefix-` y no puede identificar que la interpolación es PARTE
126
+ * del valor — la regex del atributo no matchea por el literal intermedio.
127
+ * Solución: calcular siempre el string completo fuera del template:
128
+ *
129
+ * const cls = `prefix-${dynamic}`;
130
+ * html`<div class="${cls}">`
131
+ */
132
+ function detectContext(prevString: string): BindingContext {
133
+ const lastClose = prevString.lastIndexOf(">");
134
+ const lastOpen = prevString.lastIndexOf("<");
135
+
136
+ if (lastOpen <= lastClose) {
137
+ // Estamos entre tags → contexto de nodo
138
+ return { type: "node" };
139
+ }
140
+
141
+ // Estamos dentro de la definición de un tag
142
+ const tagContent = prevString.slice(lastOpen + 1);
143
+
144
+ // Evento: @eventname[.modifier...]= / @eventname[.modifier...]=" / '
145
+ const eventMatch = tagContent.match(/@([\w:.-]+)=["']?$/);
146
+ if (eventMatch) {
147
+ const full = eventMatch[1]; // e.g. "click.prevent.stop"
148
+ const parts = full.split(".");
149
+ const eventName = parts[0];
150
+ const modifiers = parts.slice(1);
151
+ return {
152
+ type: "event",
153
+ eventName,
154
+ modifiers,
155
+ hadOpenQuote:
156
+ eventMatch[0].endsWith('"') || eventMatch[0].endsWith("'"),
157
+ };
158
+ }
159
+
160
+ // Atributo: attrname= / attrname=" / attrname='
161
+ const attrMatch = tagContent.match(/([\w:.-]+)=["']?$/);
162
+ if (attrMatch) {
163
+ return {
164
+ type: "attr",
165
+ attrName: attrMatch[1],
166
+ hadOpenQuote:
167
+ attrMatch[0].endsWith('"') || attrMatch[0].endsWith("'"),
168
+ };
169
+ }
170
+
171
+ return { type: "node" };
172
+ }
173
+
174
+ // ─── Construcción del HTML con marcadores ─────────────────────────────────────
175
+
176
+ /**
177
+ * Construye el string HTML estático reemplazando cada valor interpolado con:
178
+ * - nodo: <!--nix-N-->
179
+ * - evento: atributo data-nix-e-N="eventname"
180
+ * - attr: atributo data-nix-a-N="attrname"
181
+ *
182
+ * Para eventos/attrs, también elimina las comillas de apertura del string
183
+ * anterior y marca el string siguiente para omitir la comilla de cierre.
184
+ */
185
+ function buildHTML(
186
+ strings: readonly string[],
187
+ contexts: BindingContext[]
188
+ ): string {
189
+ const skipLeading = new Array(strings.length).fill(0);
190
+ let result = "";
191
+
192
+ for (let i = 0; i < strings.length; i++) {
193
+ let s = strings[i];
194
+
195
+ // Omitir la comilla de cierre que dejó el binding anterior
196
+ if (skipLeading[i] === 1 && (s[0] === '"' || s[0] === "'")) {
197
+ s = s.slice(1);
198
+ }
199
+
200
+ if (i < contexts.length) {
201
+ const ctx = contexts[i];
202
+
203
+ if (ctx.type === "node") {
204
+ result += s + `<!--nix-${i}-->`;
205
+ } else if (ctx.type === "event") {
206
+ // data-nix-e-N almacena solo el nombre base del evento
207
+ const full = ctx.modifiers.length
208
+ ? `${ctx.eventName}.${ctx.modifiers.join(".")}`
209
+ : ctx.eventName;
210
+ const cut = `@${full}=`.length + (ctx.hadOpenQuote ? 1 : 0);
211
+ result += s.slice(0, -cut) + ` data-nix-e-${i}="${ctx.eventName}"`;
212
+ if (ctx.hadOpenQuote) skipLeading[i + 1] = 1;
213
+ } else {
214
+ // attr
215
+ const cut =
216
+ `${ctx.attrName}=`.length + (ctx.hadOpenQuote ? 1 : 0);
217
+ result += s.slice(0, -cut) + ` data-nix-a-${i}="${ctx.attrName}"`;
218
+ if (ctx.hadOpenQuote) skipLeading[i + 1] = 1;
219
+ }
220
+ } else {
221
+ result += s;
222
+ }
223
+ }
224
+
225
+ return result;
226
+ }
227
+
228
+ // ─── Helpers de inspección del DOM ───────────────────────────────────────────
229
+
230
+ function isNixTemplate(v: unknown): v is NixTemplate {
231
+ return (
232
+ v != null &&
233
+ typeof v === "object" &&
234
+ (v as Record<string, unknown>).__isNixTemplate === true
235
+ );
236
+ }
237
+
238
+ function isKeyedList(v: unknown): v is KeyedList {
239
+ return (
240
+ v != null &&
241
+ typeof v === "object" &&
242
+ (v as Record<string, unknown>).__isKeyedList === true
243
+ );
244
+ }
245
+
246
+ /** Recorre el subárbol y devuelve un mapa de índice → Comment marcador. */
247
+ function findCommentMarkers(root: Node): Map<number, Comment> {
248
+ const map = new Map<number, Comment>();
249
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_COMMENT);
250
+ let node: Node | null;
251
+ while ((node = walker.nextNode())) {
252
+ const c = node as Comment;
253
+ const m = c.nodeValue?.match(/^nix-(\d+)$/);
254
+ if (m) map.set(parseInt(m[1]), c);
255
+ }
256
+ return map;
257
+ }
258
+
259
+ /** Recorre el subárbol buscando atributos data-nix-e-N / data-nix-a-N. */
260
+ function findAttrEventMarkers(
261
+ fragment: DocumentFragment
262
+ ): Map<number, { el: Element; type: "attr" | "event"; name: string }> {
263
+ const map = new Map<
264
+ number,
265
+ { el: Element; type: "attr" | "event"; name: string }
266
+ >();
267
+
268
+ const check = (el: Element) => {
269
+ const attrs = Array.from(el.attributes); // snapshot antes de mutar
270
+ for (const attr of attrs) {
271
+ let m = attr.name.match(/^data-nix-e-(\d+)$/);
272
+ if (m) {
273
+ map.set(parseInt(m[1]), { el, type: "event", name: attr.value });
274
+ el.removeAttribute(attr.name);
275
+ continue;
276
+ }
277
+ m = attr.name.match(/^data-nix-a-(\d+)$/);
278
+ if (m) {
279
+ map.set(parseInt(m[1]), { el, type: "attr", name: attr.value });
280
+ el.removeAttribute(attr.name);
281
+ }
282
+ }
283
+ };
284
+
285
+ fragment.querySelectorAll("*").forEach(check);
286
+ return map;
287
+ }
288
+
289
+ // ─── Activación de bindings ───────────────────────────────────────────────────
290
+
291
+ /**
292
+ * Activa todos los bindings del fragmento clonado.
293
+ * Devuelve un array de funciones dispose para limpiar al desmontar.
294
+ */
295
+ function activateBindings(
296
+ fragment: DocumentFragment,
297
+ contexts: BindingContext[],
298
+ values: unknown[]
299
+ ): { disposes: Array<() => void>; postMountHooks: Array<() => void> } {
300
+ const disposes: Array<() => void> = [];
301
+ const postMountHooks: Array<() => void> = [];
302
+
303
+ const commentMap = findCommentMarkers(fragment);
304
+ const attrEventMap = findAttrEventMarkers(fragment);
305
+
306
+ for (let i = 0; i < contexts.length; i++) {
307
+ const ctx = contexts[i];
308
+ const value = values[i];
309
+
310
+ // ── EVENTOS ──────────────────────────────────────────────
311
+ if (ctx.type === "event") {
312
+ const info = attrEventMap.get(i);
313
+ if (!info) continue;
314
+ const { el, name: eventName } = info;
315
+ const rawHandler = value as EventListener;
316
+ const mods = ctx.modifiers;
317
+
318
+ // Opciones para addEventListener
319
+ const listenerOpts: AddEventListenerOptions = {};
320
+ if (mods.includes("once")) listenerOpts.once = true;
321
+ if (mods.includes("capture")) listenerOpts.capture = true;
322
+ if (mods.includes("passive")) listenerOpts.passive = true;
323
+
324
+ // Mapa de teclas con nombre
325
+ const KEY_MAP: Record<string, string> = {
326
+ enter: "Enter",
327
+ escape: "Escape",
328
+ space: " ",
329
+ tab: "Tab",
330
+ delete: "Delete",
331
+ backspace: "Backspace",
332
+ up: "ArrowUp",
333
+ down: "ArrowDown",
334
+ left: "ArrowLeft",
335
+ right: "ArrowRight",
336
+ };
337
+
338
+ const handler: EventListener = (e: Event) => {
339
+ if (mods.includes("prevent")) e.preventDefault();
340
+ if (mods.includes("stop")) e.stopPropagation();
341
+ if (mods.includes("self") && e.target !== e.currentTarget) return;
342
+
343
+ // Filtros de tecla (solo aplican cuando el evento tiene `key`)
344
+ if ("key" in e) {
345
+ const ke = e as KeyboardEvent;
346
+ for (const mod of mods) {
347
+ const mapped = KEY_MAP[mod];
348
+ if (mapped !== undefined && ke.key !== mapped) return;
349
+ // tecla individual (una letra/dígito)
350
+ if (!KEY_MAP[mod] && mod.length === 1 && ke.key.toLowerCase() !== mod) return;
351
+ }
352
+ }
353
+
354
+ rawHandler(e);
355
+ };
356
+
357
+ el.addEventListener(eventName, handler, listenerOpts);
358
+ disposes.push(() => el.removeEventListener(eventName, handler, listenerOpts));
359
+ continue;
360
+ }
361
+
362
+ // ── ATRIBUTOS ─────────────────────────────────────────────
363
+ if (ctx.type === "attr") {
364
+ const info = attrEventMap.get(i);
365
+ if (!info) continue;
366
+ const { el, name: attrName } = info;
367
+
368
+ // ── REF especial ──────────────────────────────────────────────
369
+ if (attrName === "ref") {
370
+ (value as NixRef<Element>).el = el as Element;
371
+ disposes.push(() => { (value as NixRef<Element>).el = null; });
372
+ continue;
373
+ }
374
+
375
+ if (typeof value === "function") {
376
+ const dispose = effect(() => {
377
+ const v = (value as () => unknown)();
378
+ if (v == null || v === false) {
379
+ el.removeAttribute(attrName);
380
+ } else {
381
+ el.setAttribute(attrName, String(v));
382
+ }
383
+ });
384
+ disposes.push(dispose);
385
+ } else {
386
+ // Valor estático
387
+ if (value != null && value !== false) {
388
+ el.setAttribute(attrName, String(value));
389
+ }
390
+ }
391
+ continue;
392
+ }
393
+
394
+ // ── NODO ──────────────────────────────────────────────────
395
+ const anchor = commentMap.get(i);
396
+ if (!anchor) continue;
397
+
398
+ // Valor completamente estático (string/number/NixTemplate/NixComponent directo)
399
+ if (typeof value !== "function") {
400
+ if (isNixComponent(value)) {
401
+ // Componente clase estático: render + programar onMount tras inserción en DOM
402
+ const inst = value;
403
+ _pushComponentContext();
404
+ let innerCleanup!: () => void;
405
+ try {
406
+ try { inst.onInit?.(); } catch (e) { if (inst.onError) inst.onError(e); else throw e; }
407
+ innerCleanup = inst.render()._render(anchor.parentNode!, anchor);
408
+ } finally {
409
+ _popComponentContext();
410
+ }
411
+ let mountCleanup: (() => void) | undefined;
412
+ postMountHooks.push(() => {
413
+ try {
414
+ const ret = inst.onMount?.();
415
+ if (typeof ret === "function") mountCleanup = ret;
416
+ } catch (e) {
417
+ if (inst.onError) inst.onError(e);
418
+ else throw e;
419
+ }
420
+ });
421
+ disposes.push(() => {
422
+ try { inst.onUnmount?.(); } catch { /* ignore */ }
423
+ try { mountCleanup?.(); } catch { /* ignore */ }
424
+ innerCleanup();
425
+ });
426
+ } else if (isNixTemplate(value)) {
427
+ // Template anidado estático: insertar directamente
428
+ value._render(anchor.parentNode!, anchor);
429
+ } else if (Array.isArray(value)) {
430
+ for (const item of value) {
431
+ if (isNixComponent(item)) {
432
+ let innerCleanupItem!: () => void;
433
+ _pushComponentContext();
434
+ try {
435
+ try { item.onInit?.(); } catch (e) { if (item.onError) item.onError(e); else throw e; }
436
+ innerCleanupItem = item.render()._render(anchor.parentNode!, anchor);
437
+ } finally {
438
+ _popComponentContext();
439
+ }
440
+ let mountCleanupItem: (() => void) | undefined;
441
+ postMountHooks.push(() => {
442
+ try {
443
+ const ret = item.onMount?.();
444
+ if (typeof ret === "function") mountCleanupItem = ret;
445
+ } catch (e) {
446
+ if (item.onError) item.onError(e);
447
+ else throw e;
448
+ }
449
+ });
450
+ disposes.push(() => {
451
+ try { item.onUnmount?.(); } catch { /* ignore */ }
452
+ try { mountCleanupItem?.(); } catch { /* ignore */ }
453
+ innerCleanupItem();
454
+ });
455
+ } else if (isNixTemplate(item)) {
456
+ item._render(anchor.parentNode!, anchor);
457
+ } else if (item != null && item !== false) {
458
+ anchor.parentNode!.insertBefore(
459
+ document.createTextNode(String(item)),
460
+ anchor
461
+ );
462
+ }
463
+ }
464
+ } else if (value != null && value !== false) {
465
+ anchor.parentNode!.insertBefore(
466
+ document.createTextNode(String(value)),
467
+ anchor
468
+ );
469
+ }
470
+ continue;
471
+ }
472
+
473
+ // Valor dinámico (función)
474
+ // Usamos el anchor como "end marker". El contenido se inserta antes de él.
475
+ // Para texto reactivo simple, reutilizamos un TextNode.
476
+ let textNode: Text | null = null;
477
+ // Para templates/condicionales/listas guardamos el cleanup del contenido anterior
478
+ let innerCleanup: (() => void) | null = null;
479
+
480
+ // ── Estado para repeat() keyed diffing ──────────────────────────────
481
+ type Key = string | number;
482
+ interface KEntry {
483
+ start: Comment;
484
+ end: Comment;
485
+ cleanup: () => void;
486
+ }
487
+ let keyedState: Map<Key, KEntry> | null = null;
488
+
489
+ // Capturar el contexto provide/inject vigente en este punto del árbol,
490
+ // para que los componentes dinámicos (reactivos) vean los valores
491
+ // provistos por sus ancestros incluso al re-renderizar.
492
+ const ctxSnapshot = _captureContextSnapshot();
493
+
494
+ const dispose = effect(() => {
495
+ const v = (value as () => unknown)();
496
+
497
+ // ── Texto reactivo simple ──
498
+ if (typeof v === "string" || typeof v === "number") {
499
+ // Limpiamos cualquier template previo si hubiera
500
+ if (innerCleanup) {
501
+ innerCleanup();
502
+ innerCleanup = null;
503
+ }
504
+ if (!textNode) {
505
+ textNode = document.createTextNode(String(v));
506
+ anchor.parentNode!.insertBefore(textNode, anchor);
507
+ } else {
508
+ textNode.nodeValue = String(v);
509
+ }
510
+ return;
511
+ }
512
+
513
+ // Para otros tipos, siempre reconstruimos
514
+ if (textNode) {
515
+ textNode.parentNode?.removeChild(textNode);
516
+ textNode = null;
517
+ }
518
+ if (innerCleanup) {
519
+ innerCleanup();
520
+ innerCleanup = null;
521
+ }
522
+
523
+ if (v == null || v === false) {
524
+ // Nada que renderizar
525
+ } else if (isNixTemplate(v)) {
526
+ // Condicional: template activo
527
+ innerCleanup = v._render(anchor.parentNode!, anchor);
528
+ } else if (isNixComponent(v)) {
529
+ // NixComponent dinámico (condicional de clase)
530
+ const inst = v;
531
+ let templateCleanup!: () => void;
532
+ _withComponentContext(ctxSnapshot, () => {
533
+ try { inst.onInit?.(); } catch (e) { if (inst.onError) inst.onError(e); else throw e; }
534
+ templateCleanup = inst.render()._render(anchor.parentNode!, anchor);
535
+ });
536
+ let mountCleanup: (() => void) | undefined;
537
+ try {
538
+ const ret = inst.onMount?.();
539
+ if (typeof ret === "function") mountCleanup = ret;
540
+ } catch (e) {
541
+ if (inst.onError) inst.onError(e);
542
+ else throw e;
543
+ }
544
+ innerCleanup = () => {
545
+ try { inst.onUnmount?.(); } catch { /* ignore */ }
546
+ try { mountCleanup?.(); } catch { /* ignore */ }
547
+ templateCleanup();
548
+ };
549
+ } else if (isKeyedList(v)) {
550
+ // ── Keyed list (repeat()) ── diffing eficiente con keys ──────
551
+ if (!keyedState) keyedState = new Map();
552
+ const parent = anchor.parentNode!;
553
+ const newKeyOrder: Key[] = v.items.map(
554
+ (item, i) => v.keyFn(item as never, i)
555
+ );
556
+ const newKeySet = new Set(newKeyOrder);
557
+
558
+ // 1. Eliminar entries que ya no están en la nueva lista
559
+ for (const [key, entry] of keyedState) {
560
+ if (!newKeySet.has(key)) {
561
+ entry.cleanup();
562
+ let node: Node = entry.start;
563
+ while (node !== entry.end) {
564
+ const next = node.nextSibling!;
565
+ parent.removeChild(node);
566
+ node = next;
567
+ }
568
+ parent.removeChild(entry.end);
569
+ keyedState.delete(key);
570
+ }
571
+ }
572
+
573
+ // 2. Insertar/mover items en orden inverso para poder usar
574
+ // insertBefore con un insertionPoint que avanza hacia la izquierda.
575
+ let insertionPoint: Node = anchor;
576
+ for (let idx = newKeyOrder.length - 1; idx >= 0; idx--) {
577
+ const key = newKeyOrder[idx];
578
+ const item = v.items[idx];
579
+
580
+ if (keyedState.has(key)) {
581
+ // Item existente — mover solo si no está ya en posición
582
+ const entry = keyedState.get(key)!;
583
+ if (entry.end.nextSibling !== insertionPoint) {
584
+ // Recolectar nodos del item (start … end inclusive) y moverlos
585
+ const nodesToMove: Node[] = [];
586
+ let node: Node = entry.start;
587
+ while (true) {
588
+ nodesToMove.push(node);
589
+ if (node === entry.end) break;
590
+ node = node.nextSibling!;
591
+ }
592
+ for (const n of nodesToMove) {
593
+ parent.insertBefore(n, insertionPoint);
594
+ }
595
+ }
596
+ insertionPoint = entry.start;
597
+ } else {
598
+ // Item nuevo — renderizar y registrar
599
+ const endMarker = document.createComment("nix-ke");
600
+ const startMarker = document.createComment("nix-ks");
601
+ parent.insertBefore(endMarker, insertionPoint);
602
+ parent.insertBefore(startMarker, endMarker);
603
+
604
+ let itemCleanup: () => void;
605
+ const rendered = v.renderFn(item as never, idx);
606
+ if (isNixComponent(rendered)) {
607
+ let tmplCleanup!: () => void;
608
+ _withComponentContext(ctxSnapshot, () => {
609
+ try { rendered.onInit?.(); } catch (e) { if (rendered.onError) rendered.onError(e); else throw e; }
610
+ tmplCleanup = rendered.render()._render(parent, endMarker);
611
+ });
612
+ let mountCleanup: (() => void) | undefined;
613
+ try {
614
+ const ret = rendered.onMount?.();
615
+ if (typeof ret === "function") mountCleanup = ret;
616
+ } catch (e) {
617
+ if (rendered.onError) rendered.onError(e); else throw e;
618
+ }
619
+ itemCleanup = () => {
620
+ try { rendered.onUnmount?.(); } catch { /* ignore */ }
621
+ try { mountCleanup?.(); } catch { /* ignore */ }
622
+ tmplCleanup();
623
+ };
624
+ } else {
625
+ itemCleanup = rendered._render(parent, endMarker);
626
+ }
627
+
628
+ keyedState.set(key, { start: startMarker, end: endMarker, cleanup: itemCleanup });
629
+ insertionPoint = startMarker;
630
+ }
631
+ }
632
+ } else if (Array.isArray(v)) {
633
+ // Lista sin keys — renderizar cada elemento (re-render completo)
634
+ const cleanups: Array<() => void> = [];
635
+ for (const item of v) {
636
+ if (isNixComponent(item)) {
637
+ try { item.onInit?.(); } catch (e) { if (item.onError) item.onError(e); else throw e; }
638
+ const templateCleanup = item.render()._render(anchor.parentNode!, anchor);
639
+ let mountCleanup: (() => void) | undefined;
640
+ try {
641
+ const ret = item.onMount?.();
642
+ if (typeof ret === "function") mountCleanup = ret;
643
+ } catch (e) {
644
+ if (item.onError) item.onError(e);
645
+ else throw e;
646
+ }
647
+ cleanups.push(() => {
648
+ try { item.onUnmount?.(); } catch { /* ignore */ }
649
+ try { mountCleanup?.(); } catch { /* ignore */ }
650
+ templateCleanup();
651
+ });
652
+ } else if (isNixTemplate(item)) {
653
+ cleanups.push(item._render(anchor.parentNode!, anchor));
654
+ } else if (item != null && item !== false) {
655
+ const t = document.createTextNode(String(item));
656
+ anchor.parentNode!.insertBefore(t, anchor);
657
+ cleanups.push(() => t.parentNode?.removeChild(t));
658
+ }
659
+ }
660
+ innerCleanup = () => cleanups.forEach((c) => c());
661
+ } else {
662
+ // Primitivo embuelto en función (primera vez o tipo cambiado)
663
+ textNode = document.createTextNode(String(v));
664
+ anchor.parentNode!.insertBefore(textNode, anchor);
665
+ }
666
+ });
667
+
668
+ disposes.push(() => {
669
+ dispose();
670
+ if (innerCleanup) {
671
+ innerCleanup();
672
+ innerCleanup = null;
673
+ }
674
+ if (textNode) {
675
+ textNode.parentNode?.removeChild(textNode);
676
+ textNode = null;
677
+ }
678
+ if (keyedState) {
679
+ for (const entry of keyedState.values()) {
680
+ entry.cleanup();
681
+ }
682
+ keyedState = null;
683
+ }
684
+ });
685
+ }
686
+
687
+ return { disposes, postMountHooks };
688
+ }
689
+
690
+ // ─── Función principal html`` ─────────────────────────────────────────────────
691
+
692
+ export function html(
693
+ strings: TemplateStringsArray,
694
+ ...values: unknown[]
695
+ ): NixTemplate {
696
+ // 1. Determinar el contexto de cada valor
697
+ //
698
+ // ⚠️ Usamos una cadena ACUMULADA en lugar de solo strings[i], porque
699
+ // cuando hay múltiples bindings en el MISMO tag (e.g. id="${x}" @click=${fn}),
700
+ // el string entre ellos es `" @click=` — sin ningún `<` ni `>` — y
701
+ // detectContext lo clasificaría erróneamente como "node".
702
+ // Al acumular todos los strings anteriores conservamos la información
703
+ // de que seguimos dentro de un tag abierto.
704
+ const contexts: BindingContext[] = [];
705
+ let accumulated = "";
706
+ for (let i = 0; i < strings.length - 1; i++) {
707
+ accumulated += strings[i];
708
+ const ctx = detectContext(accumulated);
709
+ contexts.push(ctx);
710
+ // Avanzar el acumulado más allá del valor interpolado para que la
711
+ // siguiente iteración sepa si seguimos dentro del mismo tag.
712
+ // Usamos un placeholder neutro que no contiene `<` ni `>`.
713
+ accumulated += "__nix__";
714
+ }
715
+
716
+ // 2. Construir el HTML estático con marcadores
717
+ const rawHTML = buildHTML(strings, contexts);
718
+
719
+ // ── Función interna de renderizado ────────────────────────────────────────
720
+ function _render(parent: Node, before: Node | null): () => void {
721
+ // 3. Parsear el HTML a un DocumentFragment
722
+ const tpl = document.createElement("template");
723
+ tpl.innerHTML = rawHTML;
724
+ const fragment = tpl.content;
725
+
726
+ // 4. Activar bindings (ANTES de insertar, para conservar referencias)
727
+ const { disposes, postMountHooks } = activateBindings(fragment, contexts, values);
728
+
729
+ // 5. Insertar el fragmento en el DOM
730
+ // El fragmento queda vacío después, pero los nodos ya están en el DOM
731
+ // y los disposes siguen apuntando a ellos correctamente.
732
+
733
+ // Insertamos un "start marker" que nos permite limpiar luego
734
+ const startMarker = document.createComment("nix-scope");
735
+ parent.insertBefore(startMarker, before);
736
+
737
+ // Mover todos los nodos del fragmento antes de `before`
738
+ let child = fragment.firstChild;
739
+ while (child) {
740
+ const next = child.nextSibling;
741
+ parent.insertBefore(child, before);
742
+ child = next;
743
+ }
744
+
745
+ // ── Lifecycle: onMount de NixComponents embebidos ──────────────────────
746
+ // Se dispara DESPUÉS de la inserción para que el DOM esté presente.
747
+ postMountHooks.forEach((cb) => cb());
748
+
749
+ // 6. Retornar función de limpieza
750
+ return () => {
751
+ // Destruir effects, listeners y NixComponents anidados.
752
+ // Los onUnmount de NixComponents embebidos están dentro de disposes.
753
+ disposes.forEach((d) => d());
754
+
755
+ // Remover todos los nodos entre startMarker y before
756
+ let node = startMarker.nextSibling;
757
+ while (node && node !== before) {
758
+ const next = node.nextSibling;
759
+ node.parentNode?.removeChild(node);
760
+ node = next;
761
+ }
762
+ startMarker.parentNode?.removeChild(startMarker);
763
+ };
764
+ }
765
+
766
+ // ── API pública ────────────────────────────────────────────────────────────
767
+ const nixTemplate: NixTemplate = {
768
+ __isNixTemplate: true,
769
+
770
+ _render,
771
+
772
+ mount(container: Element | string): NixMountHandle {
773
+ const el =
774
+ typeof container === "string"
775
+ ? (document.querySelector(container) as Element)
776
+ : container;
777
+
778
+ if (!el) {
779
+ throw new Error(`[Nix] mount: contenedor no encontrado: ${container}`);
780
+ }
781
+
782
+ const cleanup = _render(el, null);
783
+
784
+ return {
785
+ unmount() {
786
+ cleanup();
787
+ },
788
+ };
789
+ },
790
+ };
791
+
792
+ return nixTemplate;
793
+ }