@sprlab/wccompiler 0.13.0 → 0.14.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.
@@ -1,1013 +1,1013 @@
1
- /**
2
- * Tree Walker for wcCompiler v2.
3
- *
4
- * Walks a jsdom DOM tree to discover:
5
- * - Text bindings {{var}} with childNodes[n] paths
6
- * - Event bindings @event="handler"
7
- * - Show bindings show="expression"
8
- * - Conditional chains (if / else-if / else)
9
- *
10
- * Produces { bindings, events, showBindings } arrays with path metadata.
11
- * processIfChains() detects conditional chains, validates them,
12
- * extracts branch templates, and replaces chains with comment anchors.
13
- */
14
-
15
- import { parseHTML } from 'linkedom';
16
- import { BOOLEAN_ATTRIBUTES } from './types.js';
17
-
18
- /** @import { Binding, EventBinding, IfBlock, IfBranch, ShowBinding, AttrBinding, ForBlock, ModelBinding, ModelPropBinding, SlotBinding, SlotProp, RefBinding, ChildComponentBinding, ChildPropBinding, DynamicComponentBinding, DynPropBinding, DynEventBinding } from './types.js' */
19
-
20
- /**
21
- * Walk a DOM tree rooted at rootEl, discovering bindings and events.
22
- *
23
- * @param {Element} rootEl — jsdom DOM element (parsed template root)
24
- * @param {Set<string>} signalNames — Set of signal variable names
25
- * @param {Set<string>} computedNames — Set of computed variable names
26
- * @param {Set<string>} [propNames] — Set of prop names from defineProps
27
- * @returns {{ bindings: Binding[], events: EventBinding[], showBindings: ShowBinding[], modelBindings: ModelBinding[], modelPropBindings: ModelPropBinding[], attrBindings: AttrBinding[], slots: SlotBinding[], childComponents: ChildComponentBinding[] }}
28
- */
29
- export function walkTree(rootEl, signalNames, computedNames, propNames = new Set()) {
30
- /** @type {Binding[]} */
31
- const bindings = [];
32
- /** @type {EventBinding[]} */
33
- const events = [];
34
- /** @type {ShowBinding[]} */
35
- const showBindings = [];
36
- /** @type {ModelBinding[]} */
37
- const modelBindings = [];
38
- /** @type {ModelPropBinding[]} */
39
- const modelPropBindings = [];
40
- /** @type {AttrBinding[]} */
41
- const attrBindings = [];
42
- /** @type {SlotBinding[]} */
43
- const slots = [];
44
- /** @type {ChildComponentBinding[]} */
45
- const childComponents = [];
46
- let bindIdx = 0;
47
- let eventIdx = 0;
48
- let showIdx = 0;
49
- let modelIdx = 0;
50
- let modelPropIdx = 0;
51
- let attrIdx = 0;
52
- let slotIdx = 0;
53
- let childIdx = 0;
54
-
55
- /**
56
- * Determine the binding type for a variable name.
57
- * Priority: prop → signal → computed → method
58
- *
59
- * @param {string} name
60
- * @returns {'prop' | 'signal' | 'computed' | 'method'}
61
- */
62
- function bindingType(name) {
63
- if (propNames.has(name)) return 'prop';
64
- if (signalNames.has(name)) return 'signal';
65
- if (computedNames.has(name)) return 'computed';
66
- return 'method';
67
- }
68
-
69
- /**
70
- * Recursively walk a DOM node, collecting bindings and events.
71
- *
72
- * @param {Node} node — DOM node to walk
73
- * @param {string[]} pathParts — Current path segments from root
74
- */
75
- function walk(node, pathParts) {
76
- // --- Element node ---
77
- if (node.nodeType === 1) {
78
- const el = /** @type {Element} */ (node);
79
-
80
- // Skip <template #name> elements — they are slot content passed to child components
81
- // Their interpolations are resolved by the provider, not the consumer
82
- if (el.tagName === 'TEMPLATE') {
83
- for (const attr of Array.from(el.attributes)) {
84
- if (attr.name.startsWith('#')) return;
85
- }
86
- }
87
-
88
- // Detect <slot> elements — replace with <span data-slot="..."> placeholder
89
- if (el.tagName === 'SLOT') {
90
- const slotName = el.getAttribute('name') || '';
91
- const safeName = slotName ? slotName.replace(/[^a-zA-Z0-9_]/g, '_') : 'default';
92
- const varName = `__slot_${safeName}_${slotIdx}`;
93
- slotIdx++;
94
- const defaultContent = el.innerHTML.trim();
95
-
96
- // Collect :prop="expr" attributes (slot props for scoped slots)
97
- /** @type {SlotProp[]} */
98
- const slotProps = [];
99
- for (const attr of Array.from(el.attributes)) {
100
- if (attr.name.startsWith(':')) {
101
- slotProps.push({ prop: attr.name.slice(1), source: attr.value });
102
- }
103
- }
104
-
105
- slots.push({
106
- varName,
107
- name: slotName,
108
- path: [...pathParts],
109
- defaultContent,
110
- slotProps,
111
- });
112
-
113
- // Replace <slot> with <span data-slot="name">
114
- const doc = el.ownerDocument;
115
- const placeholder = doc.createElement('span');
116
- placeholder.setAttribute('data-slot', slotName || 'default');
117
- if (defaultContent) placeholder.innerHTML = defaultContent;
118
- el.parentNode.replaceChild(placeholder, el);
119
- return; // Don't recurse into the replaced element
120
- }
121
-
122
- // Detect child custom elements (tag name contains a hyphen)
123
- const tagLower = el.tagName.toLowerCase();
124
- if (tagLower.includes('-') && tagLower !== rootEl.tagName?.toLowerCase()) {
125
- /** @type {ChildPropBinding[]} */
126
- const propBindings = [];
127
- for (const attr of Array.from(el.attributes)) {
128
- // Skip directive attributes (@event, :bind, show, model, etc.)
129
- if (attr.name.startsWith('@') || attr.name.startsWith(':') || attr.name.startsWith('bind:') || attr.name.startsWith('model:')) continue;
130
- if (['show', 'model', 'if', 'else-if', 'else', 'each', 'ref'].includes(attr.name)) continue;
131
-
132
- // Check for {{interpolation}} in attribute value
133
- const interpMatch = attr.value.match(/^\{\{([\w.()]+)\}\}$/);
134
- if (interpMatch) {
135
- const rawExpr = interpMatch[1];
136
- const expr = rawExpr.endsWith('()') ? rawExpr.slice(0, -2) : rawExpr;
137
- propBindings.push({
138
- attr: attr.name,
139
- expr,
140
- type: propNames.has(expr) ? 'prop' : signalNames.has(expr) ? 'signal' : computedNames.has(expr) ? 'computed' : 'method',
141
- });
142
- // Clear the interpolation from the attribute — the effect sets it at runtime
143
- el.setAttribute(attr.name, '');
144
- }
145
- }
146
-
147
- // Always register child component for auto-import (even without prop bindings)
148
- childComponents.push({
149
- tag: tagLower,
150
- varName: `__child${childIdx++}`,
151
- path: [...pathParts],
152
- propBindings,
153
- });
154
- }
155
-
156
- // Check for @event attributes
157
- const attrsToRemove = [];
158
- for (const attr of Array.from(el.attributes)) {
159
- if (attr.name.startsWith('@')) {
160
- const eventName = attr.name.slice(1);
161
- const handlerName = attr.value.replace(/[^a-zA-Z0-9_]/g, '_').slice(0, 20);
162
- const varName = `__evt_${eventName.replace(/-/g, '_')}_${handlerName}_${eventIdx}`;
163
- eventIdx++;
164
- events.push({
165
- varName,
166
- event: eventName,
167
- handler: attr.value,
168
- path: [...pathParts],
169
- });
170
- attrsToRemove.push(attr.name);
171
- } else if (attr.name.startsWith(':') || attr.name.startsWith('bind:')) {
172
- // Attribute binding: :attr="expr" or bind:attr="expr"
173
- const attrName = attr.name.startsWith(':') ? attr.name.slice(1) : attr.name.slice(5);
174
- const expression = attr.value;
175
-
176
- // Classify binding kind
177
- let kind;
178
- if (attrName === 'class') {
179
- kind = 'class';
180
- } else if (attrName === 'style') {
181
- kind = 'style';
182
- } else if (BOOLEAN_ATTRIBUTES.has(attrName)) {
183
- kind = 'bool';
184
- } else {
185
- kind = 'attr';
186
- }
187
-
188
- const varName = `__attr_${attrName.replace(/-/g, '_')}_${attrIdx}`;
189
- attrIdx++;
190
- attrBindings.push({
191
- varName,
192
- attr: attrName,
193
- expression,
194
- kind,
195
- path: [...pathParts],
196
- });
197
- attrsToRemove.push(attr.name);
198
- }
199
- }
200
- attrsToRemove.forEach((a) => el.removeAttribute(a));
201
-
202
- // Detect show attribute
203
- if (el.hasAttribute('show')) {
204
- const varName = `__show_${showIdx}`;
205
- showIdx++;
206
- showBindings.push({
207
- varName,
208
- expression: el.getAttribute('show'),
209
- path: [...pathParts],
210
- });
211
- el.removeAttribute('show');
212
- }
213
-
214
- // Detect model attribute
215
- if (el.hasAttribute('model')) {
216
- const signalName = el.getAttribute('model');
217
- const tag = el.tagName.toLowerCase();
218
-
219
- // Validate element is a form element
220
- if (!['input', 'textarea', 'select'].includes(tag)) {
221
- const error = new Error(`model is only valid on <input>, <textarea>, or <select>, not on <${tag}>`);
222
- /** @ts-expect-error — custom error code */
223
- error.code = 'INVALID_MODEL_ELEMENT';
224
- throw error;
225
- }
226
-
227
- // Validate model value is a valid identifier
228
- if (!signalName || !/^[a-zA-Z_$][\w$]*$/.test(signalName)) {
229
- const error = new Error(`model requires a valid signal name, received: '${signalName || ''}'`);
230
- /** @ts-expect-error — custom error code */
231
- error.code = 'INVALID_MODEL_TARGET';
232
- throw error;
233
- }
234
-
235
- // Determine prop, event, coerce, radioValue based on tag and type
236
- const type = el.getAttribute('type') || 'text';
237
- let prop, event, coerce = false, radioValue = null;
238
-
239
- if (tag === 'select') {
240
- prop = 'value'; event = 'change';
241
- } else if (tag === 'textarea') {
242
- prop = 'value'; event = 'input';
243
- } else if (type === 'checkbox') {
244
- prop = 'checked'; event = 'change';
245
- } else if (type === 'radio') {
246
- prop = 'checked'; event = 'change';
247
- radioValue = el.getAttribute('value');
248
- } else if (type === 'number') {
249
- prop = 'value'; event = 'input'; coerce = true;
250
- } else {
251
- prop = 'value'; event = 'input';
252
- }
253
-
254
- const varName = `__model_${signalName}_${modelIdx}`;
255
- modelIdx++;
256
- modelBindings.push({ varName, signal: signalName, prop, event, coerce, radioValue, path: [...pathParts] });
257
- el.removeAttribute('model');
258
- }
259
-
260
- // Detect model:propName="signalName" attributes (for custom element binding)
261
- const modelPropAttrsToRemove = [];
262
- for (const attr of Array.from(el.attributes)) {
263
- if (attr.name.startsWith('model:')) {
264
- const propName = attr.name.slice(6); // after 'model:'
265
- const signal = attr.value;
266
- const tag = el.tagName.toLowerCase();
267
-
268
- // Validate the element is a custom element (tag contains a hyphen)
269
- if (!tag.includes('-')) {
270
- const error = new Error(`model:propName is only valid on custom elements (tag must contain a hyphen)`);
271
- /** @ts-expect-error — custom error code */
272
- error.code = 'MODEL_PROP_INVALID_TARGET';
273
- throw error;
274
- }
275
-
276
- const varName = `__modelProp_${propName}`;
277
- modelPropIdx++;
278
- modelPropBindings.push({ varName, propName, signal, path: [...pathParts] });
279
- modelPropAttrsToRemove.push(attr.name);
280
- }
281
- }
282
- modelPropAttrsToRemove.forEach((a) => el.removeAttribute(a));
283
- }
284
-
285
- // --- Text node with interpolations ---
286
- if (node.nodeType === 3 && /\{\{(?:[^}]|\}(?!\}))+\}\}/.test(node.textContent)) {
287
- const text = node.textContent;
288
- const trimmed = text.trim();
289
- const soleMatch = trimmed.match(/^\{\{((?:[^}]|\}(?!\}))+)\}\}$/);
290
- const parent = node.parentNode;
291
-
292
- // Strip trailing () from expression to get the base name for type lookup
293
- function baseName(expr) {
294
- return expr.endsWith('()') ? expr.slice(0, -2) : expr;
295
- }
296
-
297
- // Case 1: {{var}} is the sole content of the parent element and parent has only one child text node
298
- if (soleMatch && parent.childNodes.length === 1) {
299
- const name = baseName(soleMatch[1]);
300
- const safeName = name.replace(/[^a-zA-Z0-9_]/g, '_').slice(0, 30);
301
- const varName = `__text_${safeName}_${bindIdx}`;
302
- bindIdx++;
303
- bindings.push({
304
- varName,
305
- name,
306
- type: bindingType(name),
307
- path: pathParts.slice(0, -1), // path to parent, not text node
308
- });
309
- parent.textContent = '';
310
- return;
311
- }
312
-
313
- // Case 2: Mixed text and interpolations — split into spans
314
- const doc = node.ownerDocument;
315
- const fragment = doc.createDocumentFragment();
316
- const parts = text.split(/(\{\{(?:[^}]|\}(?!\}))+\}\})/);
317
- const parentPath = pathParts.slice(0, -1);
318
-
319
- // Find the index of this text node among its siblings
320
- let baseIndex = 0;
321
- for (const child of parent.childNodes) {
322
- if (child === node) break;
323
- baseIndex++;
324
- }
325
-
326
- let offset = 0;
327
- for (const part of parts) {
328
- const bm = part.match(/^\{\{((?:[^}]|\}(?!\}))+)\}\}$/);
329
- if (bm) {
330
- fragment.appendChild(doc.createElement('span'));
331
- const name = baseName(bm[1]);
332
- const safeName = name.replace(/[^a-zA-Z0-9_]/g, '_').slice(0, 30);
333
- const varName = `__text_${safeName}_${bindIdx}`;
334
- bindIdx++;
335
- bindings.push({
336
- varName,
337
- name,
338
- type: bindingType(name),
339
- path: [...parentPath, `childNodes[${baseIndex + offset}]`],
340
- });
341
- offset++;
342
- } else if (part) {
343
- fragment.appendChild(doc.createTextNode(part));
344
- offset++;
345
- }
346
- }
347
- parent.replaceChild(fragment, node);
348
- return;
349
- }
350
-
351
- // --- Recurse into children ---
352
- const children = Array.from(node.childNodes);
353
- for (let i = 0; i < children.length; i++) {
354
- walk(children[i], [...pathParts, `childNodes[${i}]`]);
355
- }
356
- }
357
-
358
- walk(rootEl, []);
359
- return { bindings, events, showBindings, modelBindings, modelPropBindings, attrBindings, slots, childComponents };
360
- }
361
-
362
- // ── Conditional chain processing (if / else-if / else) ──────────────
363
-
364
- /**
365
- * Recompute the path from rootEl to a specific node after DOM normalization.
366
- * Walks up from the node to rootEl, building the path segments.
367
- *
368
- * @param {Element} rootEl - The root element
369
- * @param {Node} targetNode - The node to find the path to
370
- * @returns {string[]} Path segments from rootEl to targetNode
371
- */
372
- export function recomputeAnchorPath(rootEl, targetNode) {
373
- const segments = [];
374
- let current = targetNode;
375
- while (current && current !== rootEl) {
376
- const parent = current.parentNode;
377
- if (!parent) break;
378
- const children = Array.from(parent.childNodes);
379
- const idx = children.indexOf(current);
380
- segments.unshift(`childNodes[${idx}]`);
381
- current = parent;
382
- }
383
- return segments;
384
- }
385
-
386
- /**
387
- * Check if an element is a valid predecessor in a conditional chain
388
- * (has `if` or `else-if` attribute).
389
- *
390
- * @param {Element} el
391
- * @returns {boolean}
392
- */
393
- function isChainPredecessor(el) {
394
- return el.hasAttribute('if') || el.hasAttribute('else-if');
395
- }
396
-
397
- /**
398
- * Process a branch's HTML to extract internal bindings and events.
399
- * Creates a temporary DOM and runs walkTree on it.
400
- *
401
- * @param {string} html - The branch HTML (outerHTML of the branch element)
402
- * @param {Set<string>} signalNames
403
- * @param {Set<string>} computedNames
404
- * @param {Set<string>} propNames
405
- * @returns {{ bindings: Binding[], events: EventBinding[], showBindings: ShowBinding[], attrBindings: AttrBinding[], modelBindings: ModelBinding[], modelPropBindings: ModelPropBinding[], slots: SlotBinding[], processedHtml: string }}
406
- */
407
- export function walkBranch(html, signalNames, computedNames, propNames) {
408
- const { document } = parseHTML(`<div id="__branchRoot">${html}</div>`);
409
- const branchRoot = document.getElementById('__branchRoot');
410
-
411
- // Process nested structural directives FIRST (before walkTree modifies the DOM).
412
- // This is critical because walkTree clears textContent of elements with sole
413
- // {{interpolation}} children, which would destroy content needed by
414
- // processForBlocks/processIfChains when they clone nested elements for their
415
- // own walkBranch calls.
416
- const forBlocks = processForBlocks(branchRoot, [], signalNames, computedNames, propNames);
417
- const ifBlocks = processIfChains(branchRoot, [], signalNames, computedNames, propNames);
418
-
419
- // Now run walkTree on the remaining DOM (nested directive elements have been
420
- // replaced with comment nodes, so walkTree won't process their contents).
421
- const result = walkTree(branchRoot, signalNames, computedNames, propNames);
422
-
423
- // Capture the processed HTML AFTER all processing
424
- const processedHtml = branchRoot.innerHTML;
425
-
426
- // Strip the first path segment from all paths since at runtime
427
- // `node = clone.firstChild` is the element itself, not the wrapper div.
428
- function stripFirstSegment(items) {
429
- for (const item of items) {
430
- if (item.path && item.path.length > 0 && item.path[0].startsWith('childNodes[')) {
431
- item.path = item.path.slice(1);
432
- }
433
- }
434
- }
435
- stripFirstSegment(result.bindings);
436
- stripFirstSegment(result.events);
437
- stripFirstSegment(result.showBindings);
438
- stripFirstSegment(result.attrBindings);
439
- stripFirstSegment(result.modelBindings);
440
- stripFirstSegment(result.modelPropBindings);
441
- stripFirstSegment(result.slots);
442
- stripFirstSegment(result.childComponents);
443
-
444
- // Strip first path segment from nested forBlock/ifBlock anchor paths
445
- function stripFirstAnchorSegment(items) {
446
- for (const item of items) {
447
- if (item.anchorPath && item.anchorPath.length > 0 && item.anchorPath[0].startsWith('childNodes[')) {
448
- item.anchorPath = item.anchorPath.slice(1);
449
- }
450
- }
451
- }
452
- stripFirstAnchorSegment(forBlocks);
453
- stripFirstAnchorSegment(ifBlocks);
454
-
455
- return {
456
- bindings: result.bindings,
457
- events: result.events,
458
- showBindings: result.showBindings,
459
- attrBindings: result.attrBindings,
460
- modelBindings: result.modelBindings,
461
- modelPropBindings: result.modelPropBindings,
462
- slots: result.slots,
463
- childComponents: result.childComponents,
464
- forBlocks,
465
- ifBlocks,
466
- processedHtml,
467
- };
468
- }
469
-
470
- /**
471
- * Build an IfBlock from a completed chain, replacing elements with a comment node.
472
- *
473
- * @param {{ elements: Element[], branches: { type: 'if' | 'else-if' | 'else', expression: string | null, element: Element }[] }} chain
474
- * @param {Element} parent
475
- * @param {string[]} parentPath
476
- * @param {number} idx
477
- * @param {Set<string>} signalNames
478
- * @param {Set<string>} computedNames
479
- * @param {Set<string>} propNames
480
- * @returns {IfBlock}
481
- */
482
- function buildIfBlock(chain, parent, parentPath, idx, signalNames, computedNames, propNames) {
483
- const doc = parent.ownerDocument;
484
-
485
- // Extract HTML for each branch (without the directive attribute)
486
- /** @type {IfBranch[]} */
487
- const branches = chain.branches.map((branch) => {
488
- const el = branch.element;
489
- // Clone the element to extract HTML without modifying the original yet
490
- const clone = /** @type {Element} */ (el.cloneNode(true));
491
- // Remove the directive attribute from the clone
492
- clone.removeAttribute('if');
493
- clone.removeAttribute('else-if');
494
- clone.removeAttribute('else');
495
- const templateHtml = clone.outerHTML;
496
-
497
- // Process internal bindings/events via partial walk
498
- const { bindings, events, showBindings, attrBindings, modelBindings, slots, childComponents, processedHtml } = walkBranch(templateHtml, signalNames, computedNames, propNames);
499
-
500
- return {
501
- type: branch.type,
502
- expression: branch.expression,
503
- templateHtml: processedHtml,
504
- bindings,
505
- events,
506
- showBindings,
507
- attrBindings,
508
- modelBindings,
509
- slots,
510
- childComponents,
511
- };
512
- });
513
-
514
- // Replace all chain elements with a single comment node
515
- const comment = doc.createComment(' if ');
516
- const firstEl = chain.elements[0];
517
- parent.insertBefore(comment, firstEl);
518
-
519
- // Remove all chain elements from the DOM
520
- for (const el of chain.elements) {
521
- parent.removeChild(el);
522
- }
523
-
524
- // Calculate anchorPath: find the index of the comment node among parent's childNodes
525
- const childNodes = Array.from(parent.childNodes);
526
- const commentIndex = childNodes.indexOf(comment);
527
- const anchorPath = [...parentPath, `childNodes[${commentIndex}]`];
528
-
529
- return {
530
- varName: `__if${idx}`,
531
- anchorPath,
532
- _anchorNode: comment,
533
- branches,
534
- };
535
- }
536
-
537
- /**
538
- * Process conditional chains (if/else-if/else) in a DOM tree.
539
- * Recursively searches all descendants for chains.
540
- *
541
- * @param {Element} parent - Root element to search
542
- * @param {string[]} parentPath - DOM path to parent from __root
543
- * @param {Set<string>} signalNames
544
- * @param {Set<string>} computedNames
545
- * @param {Set<string>} propNames
546
- * @returns {IfBlock[]}
547
- */
548
- export function processIfChains(parent, parentPath, signalNames, computedNames, propNames) {
549
- /** @type {IfBlock[]} */
550
- const ifBlocks = [];
551
- let ifIdx = 0;
552
-
553
- /**
554
- * Recursively search for if chains in the subtree.
555
- * @param {Element} node
556
- * @param {string[]} currentPath
557
- */
558
- function findIfChains(node, currentPath) {
559
- const children = Array.from(node.childNodes);
560
-
561
- // First pass: validate all element children for conflicting directives
562
- for (const child of children) {
563
- if (child.nodeType !== 1) continue;
564
- const el = /** @type {Element} */ (child);
565
-
566
- const hasIf = el.hasAttribute('if');
567
- const hasElseIf = el.hasAttribute('else-if');
568
- const hasElse = el.hasAttribute('else');
569
- const hasShow = el.hasAttribute('show');
570
-
571
- // CONFLICTING_DIRECTIVES: if + else or if + else-if on same element
572
- if (hasIf && (hasElse || hasElseIf)) {
573
- const error = new Error('Las directivas condicionales son mutuamente excluyentes en un mismo elemento');
574
- /** @ts-expect-error — custom error code */
575
- error.code = 'CONFLICTING_DIRECTIVES';
576
- throw error;
577
- }
578
-
579
- // CONFLICTING_DIRECTIVES: show + if on same element
580
- if (hasShow && hasIf) {
581
- const error = new Error('show y if no deben usarse en el mismo elemento');
582
- /** @ts-expect-error — custom error code */
583
- error.code = 'CONFLICTING_DIRECTIVES';
584
- throw error;
585
- }
586
-
587
- // INVALID_V_ELSE: else with a non-empty value
588
- if (hasElse && el.getAttribute('else') !== '') {
589
- const error = new Error('else no acepta expresión');
590
- /** @ts-expect-error — custom error code */
591
- error.code = 'INVALID_V_ELSE';
592
- throw error;
593
- }
594
- }
595
-
596
- // Second pass: detect chains by iterating element nodes in order
597
- /** @type {{ elements: Element[], branches: { type: 'if' | 'else-if' | 'else', expression: string | null, element: Element }[] } | null} */
598
- let currentChain = null;
599
- /** @type {Element | null} */
600
- let prevElement = null;
601
-
602
- for (const child of children) {
603
- if (child.nodeType !== 1) continue;
604
- const el = /** @type {Element} */ (child);
605
-
606
- const hasIf = el.hasAttribute('if');
607
- const hasElseIf = el.hasAttribute('else-if');
608
- const hasElse = el.hasAttribute('else');
609
-
610
- if (hasIf) {
611
- // Close any open chain
612
- if (currentChain) {
613
- ifBlocks.push(buildIfBlock(currentChain, node, currentPath, ifIdx++, signalNames, computedNames, propNames));
614
- currentChain = null;
615
- }
616
- // Start new chain
617
- currentChain = {
618
- elements: [el],
619
- branches: [{ type: 'if', expression: el.getAttribute('if'), element: el }],
620
- };
621
- } else if (hasElseIf) {
622
- // Validate: must follow an if or else-if
623
- if (!currentChain || !prevElement || !isChainPredecessor(prevElement)) {
624
- const error = new Error('else-if/else requiere un if previo en el mismo nivel');
625
- /** @ts-expect-error — custom error code */
626
- error.code = 'ORPHAN_ELSE';
627
- throw error;
628
- }
629
- currentChain.elements.push(el);
630
- currentChain.branches.push({ type: 'else-if', expression: el.getAttribute('else-if'), element: el });
631
- } else if (hasElse) {
632
- // Validate: must follow an if or else-if
633
- if (!currentChain || !prevElement || !isChainPredecessor(prevElement)) {
634
- const error = new Error('else-if/else requiere un if previo en el mismo nivel');
635
- /** @ts-expect-error — custom error code */
636
- error.code = 'ORPHAN_ELSE';
637
- throw error;
638
- }
639
- currentChain.elements.push(el);
640
- currentChain.branches.push({ type: 'else', expression: null, element: el });
641
- // Close chain
642
- ifBlocks.push(buildIfBlock(currentChain, node, currentPath, ifIdx++, signalNames, computedNames, propNames));
643
- currentChain = null;
644
- } else {
645
- // Non-conditional element: close any open chain
646
- if (currentChain) {
647
- ifBlocks.push(buildIfBlock(currentChain, node, currentPath, ifIdx++, signalNames, computedNames, propNames));
648
- currentChain = null;
649
- }
650
- // Recurse into non-conditional elements to find nested if chains
651
- const childIdx = Array.from(node.childNodes).indexOf(el);
652
- findIfChains(el, [...currentPath, `childNodes[${childIdx}]`]);
653
- }
654
-
655
- prevElement = el;
656
- }
657
-
658
- // Close any remaining open chain
659
- if (currentChain) {
660
- ifBlocks.push(buildIfBlock(currentChain, node, currentPath, ifIdx++, signalNames, computedNames, propNames));
661
- currentChain = null;
662
- }
663
- }
664
-
665
- findIfChains(parent, parentPath);
666
-
667
- // Normalize the DOM to merge adjacent text nodes created by element removal
668
- parent.normalize();
669
-
670
- // Recompute anchor paths after normalization since text node merging
671
- // may have changed childNode indices
672
- for (const ib of ifBlocks) {
673
- ib.anchorPath = recomputeAnchorPath(parent, ib._anchorNode);
674
- }
675
-
676
- return ifBlocks;
677
- }
678
-
679
- // ── each directive processing ───────────────────────────────────────
680
-
681
- // Forma simple: "item in source"
682
- const simpleRe = /^\s*(\w+)\s+in\s+(.+)\s*$/;
683
- // Forma con índice: "(item, index) in source"
684
- const destructuredRe = /^\s*\(\s*(\w+)\s*,\s*(\w+)\s*\)\s+in\s+(.+)\s*$/;
685
-
686
- /**
687
- * Parse an each expression.
688
- * Supports:
689
- * "item in source"
690
- * "(item, index) in source"
691
- *
692
- * @param {string} expr - The each attribute value
693
- * @returns {{ itemVar: string, indexVar: string | null, source: string }}
694
- * @throws {Error} with code INVALID_V_FOR if syntax is invalid
695
- */
696
- export function parseEachExpression(expr) {
697
- // Check if expression contains "in" keyword
698
- if (!/\bin\b/.test(expr)) {
699
- const error = new Error('each requiere la sintaxis \'item in source\' o \'(item, index) in source\'');
700
- /** @ts-expect-error — custom error code */
701
- error.code = 'INVALID_V_FOR';
702
- throw error;
703
- }
704
-
705
- // Try destructured form first (more specific)
706
- const destructuredMatch = destructuredRe.exec(expr);
707
- if (destructuredMatch) {
708
- const itemVar = destructuredMatch[1];
709
- const indexVar = destructuredMatch[2];
710
- const source = destructuredMatch[3].trim();
711
-
712
- if (!itemVar) {
713
- const error = new Error('each requiere una variable de iteración');
714
- /** @ts-expect-error — custom error code */
715
- error.code = 'INVALID_V_FOR';
716
- throw error;
717
- }
718
- if (!source) {
719
- const error = new Error('each requiere una expresión fuente');
720
- /** @ts-expect-error — custom error code */
721
- error.code = 'INVALID_V_FOR';
722
- throw error;
723
- }
724
-
725
- return { itemVar, indexVar, source };
726
- }
727
-
728
- // Try simple form
729
- const simpleMatch = simpleRe.exec(expr);
730
- if (simpleMatch) {
731
- const itemVar = simpleMatch[1];
732
- const source = simpleMatch[2].trim();
733
-
734
- if (!itemVar) {
735
- const error = new Error('each requiere una variable de iteración');
736
- /** @ts-expect-error — custom error code */
737
- error.code = 'INVALID_V_FOR';
738
- throw error;
739
- }
740
- if (!source) {
741
- const error = new Error('each requiere una expresión fuente');
742
- /** @ts-expect-error — custom error code */
743
- error.code = 'INVALID_V_FOR';
744
- throw error;
745
- }
746
-
747
- return { itemVar, indexVar: null, source };
748
- }
749
-
750
- // If neither regex matched, check for specific error conditions
751
- const inIndex = expr.indexOf(' in ');
752
- if (inIndex !== -1) {
753
- const left = expr.substring(0, inIndex).trim();
754
- const right = expr.substring(inIndex + 4).trim();
755
-
756
- if (!left) {
757
- const error = new Error('each requiere una variable de iteración');
758
- /** @ts-expect-error — custom error code */
759
- error.code = 'INVALID_V_FOR';
760
- throw error;
761
- }
762
- if (!right) {
763
- const error = new Error('each requiere una expresión fuente');
764
- /** @ts-expect-error — custom error code */
765
- error.code = 'INVALID_V_FOR';
766
- throw error;
767
- }
768
- }
769
-
770
- // Fallback: invalid syntax
771
- const error = new Error('each requiere la sintaxis \'item in source\' o \'(item, index) in source\'');
772
- /** @ts-expect-error — custom error code */
773
- error.code = 'INVALID_V_FOR';
774
- throw error;
775
- }
776
-
777
- /**
778
- * Process each directives in descendants of a parent element.
779
- * Recursively detects elements with `each` attribute, validates them,
780
- * extracts item templates, and replaces them with comment anchors.
781
- *
782
- * @param {Element} parent - Root element to search
783
- * @param {string[]} parentPath - DOM path to parent from __root
784
- * @param {Set<string>} signalNames
785
- * @param {Set<string>} computedNames
786
- * @param {Set<string>} propNames
787
- * @returns {ForBlock[]}
788
- */
789
- export function processForBlocks(parent, parentPath, signalNames, computedNames, propNames) {
790
- /** @type {ForBlock[]} */
791
- const forBlocks = [];
792
- let forIdx = 0;
793
-
794
- /**
795
- * Recursively search for elements with each in the subtree.
796
- * @param {Element} node
797
- * @param {string[]} currentPath
798
- */
799
- function findForElements(node, currentPath) {
800
- const children = Array.from(node.childNodes);
801
- for (let i = 0; i < children.length; i++) {
802
- const child = children[i];
803
- if (child.nodeType !== 1) continue;
804
- const el = /** @type {Element} */ (child);
805
-
806
- if (el.hasAttribute('each')) {
807
- // Validate no conflicting if directive
808
- if (el.hasAttribute('if')) {
809
- const error = new Error('each y if no deben usarse en el mismo elemento');
810
- /** @ts-expect-error — custom error code */
811
- error.code = 'CONFLICTING_DIRECTIVES';
812
- throw error;
813
- }
814
-
815
- // Parse the each expression
816
- const expr = el.getAttribute('each');
817
- const { itemVar, indexVar, source } = parseEachExpression(expr);
818
-
819
- // Extract :key if present
820
- const keyExpr = el.hasAttribute(':key') ? el.getAttribute(':key') : null;
821
-
822
- // Clone the element and remove each and :key from the clone
823
- const clone = /** @type {Element} */ (el.cloneNode(true));
824
- clone.removeAttribute('each');
825
- clone.removeAttribute(':key');
826
- const templateHtml = clone.outerHTML;
827
-
828
- // Process internal bindings/events via partial walk
829
- const { bindings, events, showBindings, attrBindings, modelBindings, slots, childComponents: forChildComponents, forBlocks: nestedForBlocks, ifBlocks: nestedIfBlocks, processedHtml } = walkBranch(templateHtml, signalNames, computedNames, propNames);
830
-
831
- // Replace the original element with a comment node <!-- each -->
832
- const doc = node.ownerDocument;
833
- const comment = doc.createComment(' each ');
834
- node.replaceChild(comment, el);
835
-
836
- // Calculate anchorPath
837
- const updatedChildren = Array.from(node.childNodes);
838
- const commentIndex = updatedChildren.indexOf(comment);
839
- const anchorPath = [...currentPath, `childNodes[${commentIndex}]`];
840
-
841
- // Create ForBlock
842
- forBlocks.push({
843
- varName: `__for${forIdx++}`,
844
- itemVar,
845
- indexVar,
846
- source,
847
- keyExpr,
848
- templateHtml: processedHtml,
849
- anchorPath,
850
- _anchorNode: comment,
851
- bindings,
852
- events,
853
- showBindings,
854
- attrBindings,
855
- modelBindings,
856
- slots,
857
- childComponents: forChildComponents,
858
- forBlocks: nestedForBlocks,
859
- ifBlocks: nestedIfBlocks,
860
- });
861
- } else {
862
- // Recurse into non-each elements to find nested each
863
- const childPath = [...currentPath, `childNodes[${i}]`];
864
- findForElements(el, childPath);
865
- }
866
- }
867
- }
868
-
869
- findForElements(parent, parentPath);
870
- return forBlocks;
871
- }
872
-
873
-
874
- // ── Dynamic component processing ────────────────────────────────────
875
-
876
- /**
877
- * Process dynamic component elements (`<component :is="expr">`) in descendants of a parent element.
878
- * Recursively detects `<component>` elements, validates the `:is` attribute,
879
- * extracts prop/event bindings, and replaces them with comment anchors.
880
- *
881
- * @param {Element} parent - Root element to search
882
- * @param {string[]} parentPath - DOM path to parent from __root
883
- * @returns {DynamicComponentBinding[]}
884
- */
885
- export function processDynamicComponents(parent, parentPath) {
886
- /** @type {DynamicComponentBinding[]} */
887
- const dynamicComponents = [];
888
- let dynIdx = 0;
889
-
890
- /**
891
- * Recursively search for <component> elements in the subtree.
892
- * @param {Element} node
893
- * @param {string[]} currentPath
894
- */
895
- function findDynamicComponents(node, currentPath) {
896
- const children = Array.from(node.childNodes);
897
- for (let i = 0; i < children.length; i++) {
898
- const child = children[i];
899
- if (child.nodeType !== 1) continue;
900
- const el = /** @type {Element} */ (child);
901
-
902
- if (el.tagName === 'COMPONENT') {
903
- // Validate :is attribute is present
904
- const isExpr = el.getAttribute(':is');
905
- if (!isExpr) {
906
- const error = new Error(':is attribute is required on <component> elements');
907
- /** @ts-expect-error — custom error code */
908
- error.code = 'MISSING_IS_ATTRIBUTE';
909
- throw error;
910
- }
911
-
912
- // Collect prop bindings (:attr="expr", excluding :is)
913
- /** @type {DynPropBinding[]} */
914
- const props = [];
915
- // Collect event bindings (@event="handler")
916
- /** @type {DynEventBinding[]} */
917
- const events = [];
918
-
919
- for (const attr of Array.from(el.attributes)) {
920
- if (attr.name.startsWith(':') && attr.name !== ':is') {
921
- props.push({
922
- attr: attr.name.slice(1),
923
- expression: attr.value,
924
- });
925
- } else if (attr.name.startsWith('@')) {
926
- events.push({
927
- event: attr.name.slice(1),
928
- handler: attr.value,
929
- });
930
- }
931
- }
932
-
933
- // Replace <component> with a comment node <!-- dynamic -->
934
- const doc = node.ownerDocument;
935
- const comment = doc.createComment(' dynamic ');
936
- node.replaceChild(comment, el);
937
-
938
- // Calculate anchorPath
939
- const updatedChildren = Array.from(node.childNodes);
940
- const commentIndex = updatedChildren.indexOf(comment);
941
- const anchorPath = [...currentPath, `childNodes[${commentIndex}]`];
942
-
943
- // Create DynamicComponentBinding
944
- dynamicComponents.push({
945
- varName: `__dyn${dynIdx++}`,
946
- isExpression: isExpr,
947
- props,
948
- events,
949
- anchorPath,
950
- _anchorNode: comment,
951
- });
952
- } else {
953
- // Recurse into non-component elements to find nested dynamic components
954
- const childPath = [...currentPath, `childNodes[${i}]`];
955
- findDynamicComponents(el, childPath);
956
- }
957
- }
958
- }
959
-
960
- findDynamicComponents(parent, parentPath);
961
- return dynamicComponents;
962
- }
963
-
964
- // ── Ref detection ───────────────────────────────────────────────────
965
-
966
- /**
967
- * Detect ref="name" attributes on elements in the DOM tree.
968
- * Removes the ref attribute from each element after recording.
969
- *
970
- * @param {Element} rootEl — jsdom DOM element (parsed template root)
971
- * @returns {RefBinding[]}
972
- * @throws {Error} with code DUPLICATE_REF if same ref name appears on multiple elements
973
- */
974
- export function detectRefs(rootEl) {
975
- /** @type {RefBinding[]} */
976
- const refBindings = [];
977
- /** @type {Set<string>} */
978
- const seen = new Set();
979
-
980
- const elements = rootEl.querySelectorAll('[ref]');
981
-
982
- for (const el of elements) {
983
- const refName = el.getAttribute('ref');
984
-
985
- // Check for duplicate ref names
986
- if (seen.has(refName)) {
987
- const error = new Error(`Duplicate ref name '${refName}' — each ref must be unique`);
988
- /** @ts-expect-error — custom error code */
989
- error.code = 'DUPLICATE_REF';
990
- throw error;
991
- }
992
- seen.add(refName);
993
-
994
- // Compute DOM path from rootEl to el
995
- const path = [];
996
- let current = el;
997
- while (current && current !== rootEl) {
998
- const parent = current.parentNode;
999
- if (!parent) break;
1000
- const children = Array.from(parent.childNodes);
1001
- const idx = children.indexOf(current);
1002
- path.unshift(`childNodes[${idx}]`);
1003
- current = parent;
1004
- }
1005
-
1006
- // Remove the ref attribute
1007
- el.removeAttribute('ref');
1008
-
1009
- refBindings.push({ refName, path });
1010
- }
1011
-
1012
- return refBindings;
1013
- }
1
+ /**
2
+ * Tree Walker for wcCompiler v2.
3
+ *
4
+ * Walks a jsdom DOM tree to discover:
5
+ * - Text bindings {{var}} with childNodes[n] paths
6
+ * - Event bindings @event="handler"
7
+ * - Show bindings show="expression"
8
+ * - Conditional chains (if / else-if / else)
9
+ *
10
+ * Produces { bindings, events, showBindings } arrays with path metadata.
11
+ * processIfChains() detects conditional chains, validates them,
12
+ * extracts branch templates, and replaces chains with comment anchors.
13
+ */
14
+
15
+ import { parseHTML } from 'linkedom';
16
+ import { BOOLEAN_ATTRIBUTES } from './types.js';
17
+
18
+ /** @import { Binding, EventBinding, IfBlock, IfBranch, ShowBinding, AttrBinding, ForBlock, ModelBinding, ModelPropBinding, SlotBinding, SlotProp, RefBinding, ChildComponentBinding, ChildPropBinding, DynamicComponentBinding, DynPropBinding, DynEventBinding } from './types.js' */
19
+
20
+ /**
21
+ * Walk a DOM tree rooted at rootEl, discovering bindings and events.
22
+ *
23
+ * @param {Element} rootEl — jsdom DOM element (parsed template root)
24
+ * @param {Set<string>} signalNames — Set of signal variable names
25
+ * @param {Set<string>} computedNames — Set of computed variable names
26
+ * @param {Set<string>} [propNames] — Set of prop names from defineProps
27
+ * @returns {{ bindings: Binding[], events: EventBinding[], showBindings: ShowBinding[], modelBindings: ModelBinding[], modelPropBindings: ModelPropBinding[], attrBindings: AttrBinding[], slots: SlotBinding[], childComponents: ChildComponentBinding[] }}
28
+ */
29
+ export function walkTree(rootEl, signalNames, computedNames, propNames = new Set()) {
30
+ /** @type {Binding[]} */
31
+ const bindings = [];
32
+ /** @type {EventBinding[]} */
33
+ const events = [];
34
+ /** @type {ShowBinding[]} */
35
+ const showBindings = [];
36
+ /** @type {ModelBinding[]} */
37
+ const modelBindings = [];
38
+ /** @type {ModelPropBinding[]} */
39
+ const modelPropBindings = [];
40
+ /** @type {AttrBinding[]} */
41
+ const attrBindings = [];
42
+ /** @type {SlotBinding[]} */
43
+ const slots = [];
44
+ /** @type {ChildComponentBinding[]} */
45
+ const childComponents = [];
46
+ let bindIdx = 0;
47
+ let eventIdx = 0;
48
+ let showIdx = 0;
49
+ let modelIdx = 0;
50
+ let modelPropIdx = 0;
51
+ let attrIdx = 0;
52
+ let slotIdx = 0;
53
+ let childIdx = 0;
54
+
55
+ /**
56
+ * Determine the binding type for a variable name.
57
+ * Priority: prop → signal → computed → method
58
+ *
59
+ * @param {string} name
60
+ * @returns {'prop' | 'signal' | 'computed' | 'method'}
61
+ */
62
+ function bindingType(name) {
63
+ if (propNames.has(name)) return 'prop';
64
+ if (signalNames.has(name)) return 'signal';
65
+ if (computedNames.has(name)) return 'computed';
66
+ return 'method';
67
+ }
68
+
69
+ /**
70
+ * Recursively walk a DOM node, collecting bindings and events.
71
+ *
72
+ * @param {Node} node — DOM node to walk
73
+ * @param {string[]} pathParts — Current path segments from root
74
+ */
75
+ function walk(node, pathParts) {
76
+ // --- Element node ---
77
+ if (node.nodeType === 1) {
78
+ const el = /** @type {Element} */ (node);
79
+
80
+ // Skip <template #name> elements — they are slot content passed to child components
81
+ // Their interpolations are resolved by the provider, not the consumer
82
+ if (el.tagName === 'TEMPLATE') {
83
+ for (const attr of Array.from(el.attributes)) {
84
+ if (attr.name.startsWith('#')) return;
85
+ }
86
+ }
87
+
88
+ // Detect <slot> elements — replace with <span data-slot="..."> placeholder
89
+ if (el.tagName === 'SLOT') {
90
+ const slotName = el.getAttribute('name') || '';
91
+ const safeName = slotName ? slotName.replace(/[^a-zA-Z0-9_]/g, '_') : 'default';
92
+ const varName = `__slot_${safeName}_${slotIdx}`;
93
+ slotIdx++;
94
+ const defaultContent = el.innerHTML.trim();
95
+
96
+ // Collect :prop="expr" attributes (slot props for scoped slots)
97
+ /** @type {SlotProp[]} */
98
+ const slotProps = [];
99
+ for (const attr of Array.from(el.attributes)) {
100
+ if (attr.name.startsWith(':')) {
101
+ slotProps.push({ prop: attr.name.slice(1), source: attr.value });
102
+ }
103
+ }
104
+
105
+ slots.push({
106
+ varName,
107
+ name: slotName,
108
+ path: [...pathParts],
109
+ defaultContent,
110
+ slotProps,
111
+ });
112
+
113
+ // Replace <slot> with <span data-slot="name">
114
+ const doc = el.ownerDocument;
115
+ const placeholder = doc.createElement('span');
116
+ placeholder.setAttribute('data-slot', slotName || 'default');
117
+ if (defaultContent) placeholder.innerHTML = defaultContent;
118
+ el.parentNode.replaceChild(placeholder, el);
119
+ return; // Don't recurse into the replaced element
120
+ }
121
+
122
+ // Detect child custom elements (tag name contains a hyphen)
123
+ const tagLower = el.tagName.toLowerCase();
124
+ if (tagLower.includes('-') && tagLower !== rootEl.tagName?.toLowerCase()) {
125
+ /** @type {ChildPropBinding[]} */
126
+ const propBindings = [];
127
+ for (const attr of Array.from(el.attributes)) {
128
+ // Skip directive attributes (@event, :bind, show, model, etc.)
129
+ if (attr.name.startsWith('@') || attr.name.startsWith(':') || attr.name.startsWith('bind:') || attr.name.startsWith('model:')) continue;
130
+ if (['show', 'model', 'if', 'else-if', 'else', 'each', 'ref'].includes(attr.name)) continue;
131
+
132
+ // Check for {{interpolation}} in attribute value
133
+ const interpMatch = attr.value.match(/^\{\{([\w.()]+)\}\}$/);
134
+ if (interpMatch) {
135
+ const rawExpr = interpMatch[1];
136
+ const expr = rawExpr.endsWith('()') ? rawExpr.slice(0, -2) : rawExpr;
137
+ propBindings.push({
138
+ attr: attr.name,
139
+ expr,
140
+ type: propNames.has(expr) ? 'prop' : signalNames.has(expr) ? 'signal' : computedNames.has(expr) ? 'computed' : 'method',
141
+ });
142
+ // Clear the interpolation from the attribute — the effect sets it at runtime
143
+ el.setAttribute(attr.name, '');
144
+ }
145
+ }
146
+
147
+ // Always register child component for auto-import (even without prop bindings)
148
+ childComponents.push({
149
+ tag: tagLower,
150
+ varName: `__child${childIdx++}`,
151
+ path: [...pathParts],
152
+ propBindings,
153
+ });
154
+ }
155
+
156
+ // Check for @event attributes
157
+ const attrsToRemove = [];
158
+ for (const attr of Array.from(el.attributes)) {
159
+ if (attr.name.startsWith('@')) {
160
+ const eventName = attr.name.slice(1);
161
+ const handlerName = attr.value.replace(/[^a-zA-Z0-9_]/g, '_').slice(0, 20);
162
+ const varName = `__evt_${eventName.replace(/-/g, '_')}_${handlerName}_${eventIdx}`;
163
+ eventIdx++;
164
+ events.push({
165
+ varName,
166
+ event: eventName,
167
+ handler: attr.value,
168
+ path: [...pathParts],
169
+ });
170
+ attrsToRemove.push(attr.name);
171
+ } else if (attr.name.startsWith(':') || attr.name.startsWith('bind:')) {
172
+ // Attribute binding: :attr="expr" or bind:attr="expr"
173
+ const attrName = attr.name.startsWith(':') ? attr.name.slice(1) : attr.name.slice(5);
174
+ const expression = attr.value;
175
+
176
+ // Classify binding kind
177
+ let kind;
178
+ if (attrName === 'class') {
179
+ kind = 'class';
180
+ } else if (attrName === 'style') {
181
+ kind = 'style';
182
+ } else if (BOOLEAN_ATTRIBUTES.has(attrName)) {
183
+ kind = 'bool';
184
+ } else {
185
+ kind = 'attr';
186
+ }
187
+
188
+ const varName = `__attr_${attrName.replace(/-/g, '_')}_${attrIdx}`;
189
+ attrIdx++;
190
+ attrBindings.push({
191
+ varName,
192
+ attr: attrName,
193
+ expression,
194
+ kind,
195
+ path: [...pathParts],
196
+ });
197
+ attrsToRemove.push(attr.name);
198
+ }
199
+ }
200
+ attrsToRemove.forEach((a) => el.removeAttribute(a));
201
+
202
+ // Detect show attribute
203
+ if (el.hasAttribute('show')) {
204
+ const varName = `__show_${showIdx}`;
205
+ showIdx++;
206
+ showBindings.push({
207
+ varName,
208
+ expression: el.getAttribute('show'),
209
+ path: [...pathParts],
210
+ });
211
+ el.removeAttribute('show');
212
+ }
213
+
214
+ // Detect model attribute
215
+ if (el.hasAttribute('model')) {
216
+ const signalName = el.getAttribute('model');
217
+ const tag = el.tagName.toLowerCase();
218
+
219
+ // Validate element is a form element
220
+ if (!['input', 'textarea', 'select'].includes(tag)) {
221
+ const error = new Error(`model is only valid on <input>, <textarea>, or <select>, not on <${tag}>`);
222
+ /** @ts-expect-error — custom error code */
223
+ error.code = 'INVALID_MODEL_ELEMENT';
224
+ throw error;
225
+ }
226
+
227
+ // Validate model value is a valid identifier
228
+ if (!signalName || !/^[a-zA-Z_$][\w$]*$/.test(signalName)) {
229
+ const error = new Error(`model requires a valid signal name, received: '${signalName || ''}'`);
230
+ /** @ts-expect-error — custom error code */
231
+ error.code = 'INVALID_MODEL_TARGET';
232
+ throw error;
233
+ }
234
+
235
+ // Determine prop, event, coerce, radioValue based on tag and type
236
+ const type = el.getAttribute('type') || 'text';
237
+ let prop, event, coerce = false, radioValue = null;
238
+
239
+ if (tag === 'select') {
240
+ prop = 'value'; event = 'change';
241
+ } else if (tag === 'textarea') {
242
+ prop = 'value'; event = 'input';
243
+ } else if (type === 'checkbox') {
244
+ prop = 'checked'; event = 'change';
245
+ } else if (type === 'radio') {
246
+ prop = 'checked'; event = 'change';
247
+ radioValue = el.getAttribute('value');
248
+ } else if (type === 'number') {
249
+ prop = 'value'; event = 'input'; coerce = true;
250
+ } else {
251
+ prop = 'value'; event = 'input';
252
+ }
253
+
254
+ const varName = `__model_${signalName}_${modelIdx}`;
255
+ modelIdx++;
256
+ modelBindings.push({ varName, signal: signalName, prop, event, coerce, radioValue, path: [...pathParts] });
257
+ el.removeAttribute('model');
258
+ }
259
+
260
+ // Detect model:propName="signalName" attributes (for custom element binding)
261
+ const modelPropAttrsToRemove = [];
262
+ for (const attr of Array.from(el.attributes)) {
263
+ if (attr.name.startsWith('model:')) {
264
+ const propName = attr.name.slice(6); // after 'model:'
265
+ const signal = attr.value;
266
+ const tag = el.tagName.toLowerCase();
267
+
268
+ // Validate the element is a custom element (tag contains a hyphen)
269
+ if (!tag.includes('-')) {
270
+ const error = new Error(`model:propName is only valid on custom elements (tag must contain a hyphen)`);
271
+ /** @ts-expect-error — custom error code */
272
+ error.code = 'MODEL_PROP_INVALID_TARGET';
273
+ throw error;
274
+ }
275
+
276
+ const varName = `__modelProp_${propName}`;
277
+ modelPropIdx++;
278
+ modelPropBindings.push({ varName, propName, signal, path: [...pathParts] });
279
+ modelPropAttrsToRemove.push(attr.name);
280
+ }
281
+ }
282
+ modelPropAttrsToRemove.forEach((a) => el.removeAttribute(a));
283
+ }
284
+
285
+ // --- Text node with interpolations ---
286
+ if (node.nodeType === 3 && /\{\{(?:[^}]|\}(?!\}))+\}\}/.test(node.textContent)) {
287
+ const text = node.textContent;
288
+ const trimmed = text.trim();
289
+ const soleMatch = trimmed.match(/^\{\{((?:[^}]|\}(?!\}))+)\}\}$/);
290
+ const parent = node.parentNode;
291
+
292
+ // Strip trailing () from expression to get the base name for type lookup
293
+ function baseName(expr) {
294
+ return expr.endsWith('()') ? expr.slice(0, -2) : expr;
295
+ }
296
+
297
+ // Case 1: {{var}} is the sole content of the parent element and parent has only one child text node
298
+ if (soleMatch && parent.childNodes.length === 1) {
299
+ const name = baseName(soleMatch[1]);
300
+ const safeName = name.replace(/[^a-zA-Z0-9_]/g, '_').slice(0, 30);
301
+ const varName = `__text_${safeName}_${bindIdx}`;
302
+ bindIdx++;
303
+ bindings.push({
304
+ varName,
305
+ name,
306
+ type: bindingType(name),
307
+ path: pathParts.slice(0, -1), // path to parent, not text node
308
+ });
309
+ parent.textContent = '';
310
+ return;
311
+ }
312
+
313
+ // Case 2: Mixed text and interpolations — split into spans
314
+ const doc = node.ownerDocument;
315
+ const fragment = doc.createDocumentFragment();
316
+ const parts = text.split(/(\{\{(?:[^}]|\}(?!\}))+\}\})/);
317
+ const parentPath = pathParts.slice(0, -1);
318
+
319
+ // Find the index of this text node among its siblings
320
+ let baseIndex = 0;
321
+ for (const child of parent.childNodes) {
322
+ if (child === node) break;
323
+ baseIndex++;
324
+ }
325
+
326
+ let offset = 0;
327
+ for (const part of parts) {
328
+ const bm = part.match(/^\{\{((?:[^}]|\}(?!\}))+)\}\}$/);
329
+ if (bm) {
330
+ fragment.appendChild(doc.createElement('span'));
331
+ const name = baseName(bm[1]);
332
+ const safeName = name.replace(/[^a-zA-Z0-9_]/g, '_').slice(0, 30);
333
+ const varName = `__text_${safeName}_${bindIdx}`;
334
+ bindIdx++;
335
+ bindings.push({
336
+ varName,
337
+ name,
338
+ type: bindingType(name),
339
+ path: [...parentPath, `childNodes[${baseIndex + offset}]`],
340
+ });
341
+ offset++;
342
+ } else if (part) {
343
+ fragment.appendChild(doc.createTextNode(part));
344
+ offset++;
345
+ }
346
+ }
347
+ parent.replaceChild(fragment, node);
348
+ return;
349
+ }
350
+
351
+ // --- Recurse into children ---
352
+ const children = Array.from(node.childNodes);
353
+ for (let i = 0; i < children.length; i++) {
354
+ walk(children[i], [...pathParts, `childNodes[${i}]`]);
355
+ }
356
+ }
357
+
358
+ walk(rootEl, []);
359
+ return { bindings, events, showBindings, modelBindings, modelPropBindings, attrBindings, slots, childComponents };
360
+ }
361
+
362
+ // ── Conditional chain processing (if / else-if / else) ──────────────
363
+
364
+ /**
365
+ * Recompute the path from rootEl to a specific node after DOM normalization.
366
+ * Walks up from the node to rootEl, building the path segments.
367
+ *
368
+ * @param {Element} rootEl - The root element
369
+ * @param {Node} targetNode - The node to find the path to
370
+ * @returns {string[]} Path segments from rootEl to targetNode
371
+ */
372
+ export function recomputeAnchorPath(rootEl, targetNode) {
373
+ const segments = [];
374
+ let current = targetNode;
375
+ while (current && current !== rootEl) {
376
+ const parent = current.parentNode;
377
+ if (!parent) break;
378
+ const children = Array.from(parent.childNodes);
379
+ const idx = children.indexOf(current);
380
+ segments.unshift(`childNodes[${idx}]`);
381
+ current = parent;
382
+ }
383
+ return segments;
384
+ }
385
+
386
+ /**
387
+ * Check if an element is a valid predecessor in a conditional chain
388
+ * (has `if` or `else-if` attribute).
389
+ *
390
+ * @param {Element} el
391
+ * @returns {boolean}
392
+ */
393
+ function isChainPredecessor(el) {
394
+ return el.hasAttribute('if') || el.hasAttribute('else-if');
395
+ }
396
+
397
+ /**
398
+ * Process a branch's HTML to extract internal bindings and events.
399
+ * Creates a temporary DOM and runs walkTree on it.
400
+ *
401
+ * @param {string} html - The branch HTML (outerHTML of the branch element)
402
+ * @param {Set<string>} signalNames
403
+ * @param {Set<string>} computedNames
404
+ * @param {Set<string>} propNames
405
+ * @returns {{ bindings: Binding[], events: EventBinding[], showBindings: ShowBinding[], attrBindings: AttrBinding[], modelBindings: ModelBinding[], modelPropBindings: ModelPropBinding[], slots: SlotBinding[], processedHtml: string }}
406
+ */
407
+ export function walkBranch(html, signalNames, computedNames, propNames) {
408
+ const { document } = parseHTML(`<div id="__branchRoot">${html}</div>`);
409
+ const branchRoot = document.getElementById('__branchRoot');
410
+
411
+ // Process nested structural directives FIRST (before walkTree modifies the DOM).
412
+ // This is critical because walkTree clears textContent of elements with sole
413
+ // {{interpolation}} children, which would destroy content needed by
414
+ // processForBlocks/processIfChains when they clone nested elements for their
415
+ // own walkBranch calls.
416
+ const forBlocks = processForBlocks(branchRoot, [], signalNames, computedNames, propNames);
417
+ const ifBlocks = processIfChains(branchRoot, [], signalNames, computedNames, propNames);
418
+
419
+ // Now run walkTree on the remaining DOM (nested directive elements have been
420
+ // replaced with comment nodes, so walkTree won't process their contents).
421
+ const result = walkTree(branchRoot, signalNames, computedNames, propNames);
422
+
423
+ // Capture the processed HTML AFTER all processing
424
+ const processedHtml = branchRoot.innerHTML;
425
+
426
+ // Strip the first path segment from all paths since at runtime
427
+ // `node = clone.firstChild` is the element itself, not the wrapper div.
428
+ function stripFirstSegment(items) {
429
+ for (const item of items) {
430
+ if (item.path && item.path.length > 0 && item.path[0].startsWith('childNodes[')) {
431
+ item.path = item.path.slice(1);
432
+ }
433
+ }
434
+ }
435
+ stripFirstSegment(result.bindings);
436
+ stripFirstSegment(result.events);
437
+ stripFirstSegment(result.showBindings);
438
+ stripFirstSegment(result.attrBindings);
439
+ stripFirstSegment(result.modelBindings);
440
+ stripFirstSegment(result.modelPropBindings);
441
+ stripFirstSegment(result.slots);
442
+ stripFirstSegment(result.childComponents);
443
+
444
+ // Strip first path segment from nested forBlock/ifBlock anchor paths
445
+ function stripFirstAnchorSegment(items) {
446
+ for (const item of items) {
447
+ if (item.anchorPath && item.anchorPath.length > 0 && item.anchorPath[0].startsWith('childNodes[')) {
448
+ item.anchorPath = item.anchorPath.slice(1);
449
+ }
450
+ }
451
+ }
452
+ stripFirstAnchorSegment(forBlocks);
453
+ stripFirstAnchorSegment(ifBlocks);
454
+
455
+ return {
456
+ bindings: result.bindings,
457
+ events: result.events,
458
+ showBindings: result.showBindings,
459
+ attrBindings: result.attrBindings,
460
+ modelBindings: result.modelBindings,
461
+ modelPropBindings: result.modelPropBindings,
462
+ slots: result.slots,
463
+ childComponents: result.childComponents,
464
+ forBlocks,
465
+ ifBlocks,
466
+ processedHtml,
467
+ };
468
+ }
469
+
470
+ /**
471
+ * Build an IfBlock from a completed chain, replacing elements with a comment node.
472
+ *
473
+ * @param {{ elements: Element[], branches: { type: 'if' | 'else-if' | 'else', expression: string | null, element: Element }[] }} chain
474
+ * @param {Element} parent
475
+ * @param {string[]} parentPath
476
+ * @param {number} idx
477
+ * @param {Set<string>} signalNames
478
+ * @param {Set<string>} computedNames
479
+ * @param {Set<string>} propNames
480
+ * @returns {IfBlock}
481
+ */
482
+ function buildIfBlock(chain, parent, parentPath, idx, signalNames, computedNames, propNames) {
483
+ const doc = parent.ownerDocument;
484
+
485
+ // Extract HTML for each branch (without the directive attribute)
486
+ /** @type {IfBranch[]} */
487
+ const branches = chain.branches.map((branch) => {
488
+ const el = branch.element;
489
+ // Clone the element to extract HTML without modifying the original yet
490
+ const clone = /** @type {Element} */ (el.cloneNode(true));
491
+ // Remove the directive attribute from the clone
492
+ clone.removeAttribute('if');
493
+ clone.removeAttribute('else-if');
494
+ clone.removeAttribute('else');
495
+ const templateHtml = clone.outerHTML;
496
+
497
+ // Process internal bindings/events via partial walk
498
+ const { bindings, events, showBindings, attrBindings, modelBindings, slots, childComponents, processedHtml } = walkBranch(templateHtml, signalNames, computedNames, propNames);
499
+
500
+ return {
501
+ type: branch.type,
502
+ expression: branch.expression,
503
+ templateHtml: processedHtml,
504
+ bindings,
505
+ events,
506
+ showBindings,
507
+ attrBindings,
508
+ modelBindings,
509
+ slots,
510
+ childComponents,
511
+ };
512
+ });
513
+
514
+ // Replace all chain elements with a single comment node
515
+ const comment = doc.createComment(' if ');
516
+ const firstEl = chain.elements[0];
517
+ parent.insertBefore(comment, firstEl);
518
+
519
+ // Remove all chain elements from the DOM
520
+ for (const el of chain.elements) {
521
+ parent.removeChild(el);
522
+ }
523
+
524
+ // Calculate anchorPath: find the index of the comment node among parent's childNodes
525
+ const childNodes = Array.from(parent.childNodes);
526
+ const commentIndex = childNodes.indexOf(comment);
527
+ const anchorPath = [...parentPath, `childNodes[${commentIndex}]`];
528
+
529
+ return {
530
+ varName: `__if${idx}`,
531
+ anchorPath,
532
+ _anchorNode: comment,
533
+ branches,
534
+ };
535
+ }
536
+
537
+ /**
538
+ * Process conditional chains (if/else-if/else) in a DOM tree.
539
+ * Recursively searches all descendants for chains.
540
+ *
541
+ * @param {Element} parent - Root element to search
542
+ * @param {string[]} parentPath - DOM path to parent from __root
543
+ * @param {Set<string>} signalNames
544
+ * @param {Set<string>} computedNames
545
+ * @param {Set<string>} propNames
546
+ * @returns {IfBlock[]}
547
+ */
548
+ export function processIfChains(parent, parentPath, signalNames, computedNames, propNames) {
549
+ /** @type {IfBlock[]} */
550
+ const ifBlocks = [];
551
+ let ifIdx = 0;
552
+
553
+ /**
554
+ * Recursively search for if chains in the subtree.
555
+ * @param {Element} node
556
+ * @param {string[]} currentPath
557
+ */
558
+ function findIfChains(node, currentPath) {
559
+ const children = Array.from(node.childNodes);
560
+
561
+ // First pass: validate all element children for conflicting directives
562
+ for (const child of children) {
563
+ if (child.nodeType !== 1) continue;
564
+ const el = /** @type {Element} */ (child);
565
+
566
+ const hasIf = el.hasAttribute('if');
567
+ const hasElseIf = el.hasAttribute('else-if');
568
+ const hasElse = el.hasAttribute('else');
569
+ const hasShow = el.hasAttribute('show');
570
+
571
+ // CONFLICTING_DIRECTIVES: if + else or if + else-if on same element
572
+ if (hasIf && (hasElse || hasElseIf)) {
573
+ const error = new Error('Las directivas condicionales son mutuamente excluyentes en un mismo elemento');
574
+ /** @ts-expect-error — custom error code */
575
+ error.code = 'CONFLICTING_DIRECTIVES';
576
+ throw error;
577
+ }
578
+
579
+ // CONFLICTING_DIRECTIVES: show + if on same element
580
+ if (hasShow && hasIf) {
581
+ const error = new Error('show y if no deben usarse en el mismo elemento');
582
+ /** @ts-expect-error — custom error code */
583
+ error.code = 'CONFLICTING_DIRECTIVES';
584
+ throw error;
585
+ }
586
+
587
+ // INVALID_V_ELSE: else with a non-empty value
588
+ if (hasElse && el.getAttribute('else') !== '') {
589
+ const error = new Error('else no acepta expresión');
590
+ /** @ts-expect-error — custom error code */
591
+ error.code = 'INVALID_V_ELSE';
592
+ throw error;
593
+ }
594
+ }
595
+
596
+ // Second pass: detect chains by iterating element nodes in order
597
+ /** @type {{ elements: Element[], branches: { type: 'if' | 'else-if' | 'else', expression: string | null, element: Element }[] } | null} */
598
+ let currentChain = null;
599
+ /** @type {Element | null} */
600
+ let prevElement = null;
601
+
602
+ for (const child of children) {
603
+ if (child.nodeType !== 1) continue;
604
+ const el = /** @type {Element} */ (child);
605
+
606
+ const hasIf = el.hasAttribute('if');
607
+ const hasElseIf = el.hasAttribute('else-if');
608
+ const hasElse = el.hasAttribute('else');
609
+
610
+ if (hasIf) {
611
+ // Close any open chain
612
+ if (currentChain) {
613
+ ifBlocks.push(buildIfBlock(currentChain, node, currentPath, ifIdx++, signalNames, computedNames, propNames));
614
+ currentChain = null;
615
+ }
616
+ // Start new chain
617
+ currentChain = {
618
+ elements: [el],
619
+ branches: [{ type: 'if', expression: el.getAttribute('if'), element: el }],
620
+ };
621
+ } else if (hasElseIf) {
622
+ // Validate: must follow an if or else-if
623
+ if (!currentChain || !prevElement || !isChainPredecessor(prevElement)) {
624
+ const error = new Error('else-if/else requiere un if previo en el mismo nivel');
625
+ /** @ts-expect-error — custom error code */
626
+ error.code = 'ORPHAN_ELSE';
627
+ throw error;
628
+ }
629
+ currentChain.elements.push(el);
630
+ currentChain.branches.push({ type: 'else-if', expression: el.getAttribute('else-if'), element: el });
631
+ } else if (hasElse) {
632
+ // Validate: must follow an if or else-if
633
+ if (!currentChain || !prevElement || !isChainPredecessor(prevElement)) {
634
+ const error = new Error('else-if/else requiere un if previo en el mismo nivel');
635
+ /** @ts-expect-error — custom error code */
636
+ error.code = 'ORPHAN_ELSE';
637
+ throw error;
638
+ }
639
+ currentChain.elements.push(el);
640
+ currentChain.branches.push({ type: 'else', expression: null, element: el });
641
+ // Close chain
642
+ ifBlocks.push(buildIfBlock(currentChain, node, currentPath, ifIdx++, signalNames, computedNames, propNames));
643
+ currentChain = null;
644
+ } else {
645
+ // Non-conditional element: close any open chain
646
+ if (currentChain) {
647
+ ifBlocks.push(buildIfBlock(currentChain, node, currentPath, ifIdx++, signalNames, computedNames, propNames));
648
+ currentChain = null;
649
+ }
650
+ // Recurse into non-conditional elements to find nested if chains
651
+ const childIdx = Array.from(node.childNodes).indexOf(el);
652
+ findIfChains(el, [...currentPath, `childNodes[${childIdx}]`]);
653
+ }
654
+
655
+ prevElement = el;
656
+ }
657
+
658
+ // Close any remaining open chain
659
+ if (currentChain) {
660
+ ifBlocks.push(buildIfBlock(currentChain, node, currentPath, ifIdx++, signalNames, computedNames, propNames));
661
+ currentChain = null;
662
+ }
663
+ }
664
+
665
+ findIfChains(parent, parentPath);
666
+
667
+ // Normalize the DOM to merge adjacent text nodes created by element removal
668
+ parent.normalize();
669
+
670
+ // Recompute anchor paths after normalization since text node merging
671
+ // may have changed childNode indices
672
+ for (const ib of ifBlocks) {
673
+ ib.anchorPath = recomputeAnchorPath(parent, ib._anchorNode);
674
+ }
675
+
676
+ return ifBlocks;
677
+ }
678
+
679
+ // ── each directive processing ───────────────────────────────────────
680
+
681
+ // Forma simple: "item in source"
682
+ const simpleRe = /^\s*(\w+)\s+in\s+(.+)\s*$/;
683
+ // Forma con índice: "(item, index) in source"
684
+ const destructuredRe = /^\s*\(\s*(\w+)\s*,\s*(\w+)\s*\)\s+in\s+(.+)\s*$/;
685
+
686
+ /**
687
+ * Parse an each expression.
688
+ * Supports:
689
+ * "item in source"
690
+ * "(item, index) in source"
691
+ *
692
+ * @param {string} expr - The each attribute value
693
+ * @returns {{ itemVar: string, indexVar: string | null, source: string }}
694
+ * @throws {Error} with code INVALID_V_FOR if syntax is invalid
695
+ */
696
+ export function parseEachExpression(expr) {
697
+ // Check if expression contains "in" keyword
698
+ if (!/\bin\b/.test(expr)) {
699
+ const error = new Error('each requiere la sintaxis \'item in source\' o \'(item, index) in source\'');
700
+ /** @ts-expect-error — custom error code */
701
+ error.code = 'INVALID_V_FOR';
702
+ throw error;
703
+ }
704
+
705
+ // Try destructured form first (more specific)
706
+ const destructuredMatch = destructuredRe.exec(expr);
707
+ if (destructuredMatch) {
708
+ const itemVar = destructuredMatch[1];
709
+ const indexVar = destructuredMatch[2];
710
+ const source = destructuredMatch[3].trim();
711
+
712
+ if (!itemVar) {
713
+ const error = new Error('each requiere una variable de iteración');
714
+ /** @ts-expect-error — custom error code */
715
+ error.code = 'INVALID_V_FOR';
716
+ throw error;
717
+ }
718
+ if (!source) {
719
+ const error = new Error('each requiere una expresión fuente');
720
+ /** @ts-expect-error — custom error code */
721
+ error.code = 'INVALID_V_FOR';
722
+ throw error;
723
+ }
724
+
725
+ return { itemVar, indexVar, source };
726
+ }
727
+
728
+ // Try simple form
729
+ const simpleMatch = simpleRe.exec(expr);
730
+ if (simpleMatch) {
731
+ const itemVar = simpleMatch[1];
732
+ const source = simpleMatch[2].trim();
733
+
734
+ if (!itemVar) {
735
+ const error = new Error('each requiere una variable de iteración');
736
+ /** @ts-expect-error — custom error code */
737
+ error.code = 'INVALID_V_FOR';
738
+ throw error;
739
+ }
740
+ if (!source) {
741
+ const error = new Error('each requiere una expresión fuente');
742
+ /** @ts-expect-error — custom error code */
743
+ error.code = 'INVALID_V_FOR';
744
+ throw error;
745
+ }
746
+
747
+ return { itemVar, indexVar: null, source };
748
+ }
749
+
750
+ // If neither regex matched, check for specific error conditions
751
+ const inIndex = expr.indexOf(' in ');
752
+ if (inIndex !== -1) {
753
+ const left = expr.substring(0, inIndex).trim();
754
+ const right = expr.substring(inIndex + 4).trim();
755
+
756
+ if (!left) {
757
+ const error = new Error('each requiere una variable de iteración');
758
+ /** @ts-expect-error — custom error code */
759
+ error.code = 'INVALID_V_FOR';
760
+ throw error;
761
+ }
762
+ if (!right) {
763
+ const error = new Error('each requiere una expresión fuente');
764
+ /** @ts-expect-error — custom error code */
765
+ error.code = 'INVALID_V_FOR';
766
+ throw error;
767
+ }
768
+ }
769
+
770
+ // Fallback: invalid syntax
771
+ const error = new Error('each requiere la sintaxis \'item in source\' o \'(item, index) in source\'');
772
+ /** @ts-expect-error — custom error code */
773
+ error.code = 'INVALID_V_FOR';
774
+ throw error;
775
+ }
776
+
777
+ /**
778
+ * Process each directives in descendants of a parent element.
779
+ * Recursively detects elements with `each` attribute, validates them,
780
+ * extracts item templates, and replaces them with comment anchors.
781
+ *
782
+ * @param {Element} parent - Root element to search
783
+ * @param {string[]} parentPath - DOM path to parent from __root
784
+ * @param {Set<string>} signalNames
785
+ * @param {Set<string>} computedNames
786
+ * @param {Set<string>} propNames
787
+ * @returns {ForBlock[]}
788
+ */
789
+ export function processForBlocks(parent, parentPath, signalNames, computedNames, propNames) {
790
+ /** @type {ForBlock[]} */
791
+ const forBlocks = [];
792
+ let forIdx = 0;
793
+
794
+ /**
795
+ * Recursively search for elements with each in the subtree.
796
+ * @param {Element} node
797
+ * @param {string[]} currentPath
798
+ */
799
+ function findForElements(node, currentPath) {
800
+ const children = Array.from(node.childNodes);
801
+ for (let i = 0; i < children.length; i++) {
802
+ const child = children[i];
803
+ if (child.nodeType !== 1) continue;
804
+ const el = /** @type {Element} */ (child);
805
+
806
+ if (el.hasAttribute('each')) {
807
+ // Validate no conflicting if directive
808
+ if (el.hasAttribute('if')) {
809
+ const error = new Error('each y if no deben usarse en el mismo elemento');
810
+ /** @ts-expect-error — custom error code */
811
+ error.code = 'CONFLICTING_DIRECTIVES';
812
+ throw error;
813
+ }
814
+
815
+ // Parse the each expression
816
+ const expr = el.getAttribute('each');
817
+ const { itemVar, indexVar, source } = parseEachExpression(expr);
818
+
819
+ // Extract :key if present
820
+ const keyExpr = el.hasAttribute(':key') ? el.getAttribute(':key') : null;
821
+
822
+ // Clone the element and remove each and :key from the clone
823
+ const clone = /** @type {Element} */ (el.cloneNode(true));
824
+ clone.removeAttribute('each');
825
+ clone.removeAttribute(':key');
826
+ const templateHtml = clone.outerHTML;
827
+
828
+ // Process internal bindings/events via partial walk
829
+ const { bindings, events, showBindings, attrBindings, modelBindings, slots, childComponents: forChildComponents, forBlocks: nestedForBlocks, ifBlocks: nestedIfBlocks, processedHtml } = walkBranch(templateHtml, signalNames, computedNames, propNames);
830
+
831
+ // Replace the original element with a comment node <!-- each -->
832
+ const doc = node.ownerDocument;
833
+ const comment = doc.createComment(' each ');
834
+ node.replaceChild(comment, el);
835
+
836
+ // Calculate anchorPath
837
+ const updatedChildren = Array.from(node.childNodes);
838
+ const commentIndex = updatedChildren.indexOf(comment);
839
+ const anchorPath = [...currentPath, `childNodes[${commentIndex}]`];
840
+
841
+ // Create ForBlock
842
+ forBlocks.push({
843
+ varName: `__for${forIdx++}`,
844
+ itemVar,
845
+ indexVar,
846
+ source,
847
+ keyExpr,
848
+ templateHtml: processedHtml,
849
+ anchorPath,
850
+ _anchorNode: comment,
851
+ bindings,
852
+ events,
853
+ showBindings,
854
+ attrBindings,
855
+ modelBindings,
856
+ slots,
857
+ childComponents: forChildComponents,
858
+ forBlocks: nestedForBlocks,
859
+ ifBlocks: nestedIfBlocks,
860
+ });
861
+ } else {
862
+ // Recurse into non-each elements to find nested each
863
+ const childPath = [...currentPath, `childNodes[${i}]`];
864
+ findForElements(el, childPath);
865
+ }
866
+ }
867
+ }
868
+
869
+ findForElements(parent, parentPath);
870
+ return forBlocks;
871
+ }
872
+
873
+
874
+ // ── Dynamic component processing ────────────────────────────────────
875
+
876
+ /**
877
+ * Process dynamic component elements (`<component :is="expr">`) in descendants of a parent element.
878
+ * Recursively detects `<component>` elements, validates the `:is` attribute,
879
+ * extracts prop/event bindings, and replaces them with comment anchors.
880
+ *
881
+ * @param {Element} parent - Root element to search
882
+ * @param {string[]} parentPath - DOM path to parent from __root
883
+ * @returns {DynamicComponentBinding[]}
884
+ */
885
+ export function processDynamicComponents(parent, parentPath) {
886
+ /** @type {DynamicComponentBinding[]} */
887
+ const dynamicComponents = [];
888
+ let dynIdx = 0;
889
+
890
+ /**
891
+ * Recursively search for <component> elements in the subtree.
892
+ * @param {Element} node
893
+ * @param {string[]} currentPath
894
+ */
895
+ function findDynamicComponents(node, currentPath) {
896
+ const children = Array.from(node.childNodes);
897
+ for (let i = 0; i < children.length; i++) {
898
+ const child = children[i];
899
+ if (child.nodeType !== 1) continue;
900
+ const el = /** @type {Element} */ (child);
901
+
902
+ if (el.tagName === 'COMPONENT') {
903
+ // Validate :is attribute is present
904
+ const isExpr = el.getAttribute(':is');
905
+ if (!isExpr) {
906
+ const error = new Error(':is attribute is required on <component> elements');
907
+ /** @ts-expect-error — custom error code */
908
+ error.code = 'MISSING_IS_ATTRIBUTE';
909
+ throw error;
910
+ }
911
+
912
+ // Collect prop bindings (:attr="expr", excluding :is)
913
+ /** @type {DynPropBinding[]} */
914
+ const props = [];
915
+ // Collect event bindings (@event="handler")
916
+ /** @type {DynEventBinding[]} */
917
+ const events = [];
918
+
919
+ for (const attr of Array.from(el.attributes)) {
920
+ if (attr.name.startsWith(':') && attr.name !== ':is') {
921
+ props.push({
922
+ attr: attr.name.slice(1),
923
+ expression: attr.value,
924
+ });
925
+ } else if (attr.name.startsWith('@')) {
926
+ events.push({
927
+ event: attr.name.slice(1),
928
+ handler: attr.value,
929
+ });
930
+ }
931
+ }
932
+
933
+ // Replace <component> with a comment node <!-- dynamic -->
934
+ const doc = node.ownerDocument;
935
+ const comment = doc.createComment(' dynamic ');
936
+ node.replaceChild(comment, el);
937
+
938
+ // Calculate anchorPath
939
+ const updatedChildren = Array.from(node.childNodes);
940
+ const commentIndex = updatedChildren.indexOf(comment);
941
+ const anchorPath = [...currentPath, `childNodes[${commentIndex}]`];
942
+
943
+ // Create DynamicComponentBinding
944
+ dynamicComponents.push({
945
+ varName: `__dyn${dynIdx++}`,
946
+ isExpression: isExpr,
947
+ props,
948
+ events,
949
+ anchorPath,
950
+ _anchorNode: comment,
951
+ });
952
+ } else {
953
+ // Recurse into non-component elements to find nested dynamic components
954
+ const childPath = [...currentPath, `childNodes[${i}]`];
955
+ findDynamicComponents(el, childPath);
956
+ }
957
+ }
958
+ }
959
+
960
+ findDynamicComponents(parent, parentPath);
961
+ return dynamicComponents;
962
+ }
963
+
964
+ // ── Ref detection ───────────────────────────────────────────────────
965
+
966
+ /**
967
+ * Detect ref="name" attributes on elements in the DOM tree.
968
+ * Removes the ref attribute from each element after recording.
969
+ *
970
+ * @param {Element} rootEl — jsdom DOM element (parsed template root)
971
+ * @returns {RefBinding[]}
972
+ * @throws {Error} with code DUPLICATE_REF if same ref name appears on multiple elements
973
+ */
974
+ export function detectRefs(rootEl) {
975
+ /** @type {RefBinding[]} */
976
+ const refBindings = [];
977
+ /** @type {Set<string>} */
978
+ const seen = new Set();
979
+
980
+ const elements = rootEl.querySelectorAll('[ref]');
981
+
982
+ for (const el of elements) {
983
+ const refName = el.getAttribute('ref');
984
+
985
+ // Check for duplicate ref names
986
+ if (seen.has(refName)) {
987
+ const error = new Error(`Duplicate ref name '${refName}' — each ref must be unique`);
988
+ /** @ts-expect-error — custom error code */
989
+ error.code = 'DUPLICATE_REF';
990
+ throw error;
991
+ }
992
+ seen.add(refName);
993
+
994
+ // Compute DOM path from rootEl to el
995
+ const path = [];
996
+ let current = el;
997
+ while (current && current !== rootEl) {
998
+ const parent = current.parentNode;
999
+ if (!parent) break;
1000
+ const children = Array.from(parent.childNodes);
1001
+ const idx = children.indexOf(current);
1002
+ path.unshift(`childNodes[${idx}]`);
1003
+ current = parent;
1004
+ }
1005
+
1006
+ // Remove the ref attribute
1007
+ el.removeAttribute('ref');
1008
+
1009
+ refBindings.push({ refName, path });
1010
+ }
1011
+
1012
+ return refBindings;
1013
+ }