@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.
- package/README.md +1024 -0
- package/dist/lib/index.d.ts +2 -0
- package/dist/lib/nix/async.d.ts +69 -0
- package/dist/lib/nix/component.d.ts +13 -0
- package/dist/lib/nix/context.d.ts +61 -0
- package/dist/lib/nix/index.d.ts +14 -0
- package/dist/lib/nix/lifecycle.d.ts +70 -0
- package/dist/lib/nix/reactivity.d.ts +116 -0
- package/dist/lib/nix/router.d.ts +115 -0
- package/dist/lib/nix/store.d.ts +40 -0
- package/dist/lib/nix/template.d.ts +62 -0
- package/dist/lib/nix-js.cjs +22 -0
- package/dist/lib/nix-js.js +764 -0
- package/package.json +62 -0
- package/src/index.ts +61 -0
- package/src/nix/async.ts +164 -0
- package/src/nix/component.ts +76 -0
- package/src/nix/context.ts +142 -0
- package/src/nix/index.ts +14 -0
- package/src/nix/lifecycle.ts +112 -0
- package/src/nix/reactivity.ts +308 -0
- package/src/nix/router.ts +393 -0
- package/src/nix/store.ts +117 -0
- package/src/nix/template.ts +793 -0
|
@@ -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
|
+
}
|