@sprlab/wccompiler 0.0.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,63 +1,86 @@
1
1
  /**
2
- * Tree Walker for parsed templates.
2
+ * Tree Walker for wcCompiler v2.
3
3
  *
4
4
  * Walks a jsdom DOM tree to discover:
5
5
  * - Text bindings {{var}} with childNodes[n] paths
6
6
  * - Event bindings @event="handler"
7
- * - Slots <slot> (named, default, with slotProps)
7
+ * - Show bindings show="expression"
8
+ * - Conditional chains (if / else-if / else)
8
9
  *
9
- * Produces { bindings, events, slots } arrays with path metadata.
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.
10
13
  */
11
14
 
12
15
  import { JSDOM } from 'jsdom';
16
+ import { BOOLEAN_ATTRIBUTES } from './types.js';
13
17
 
14
- /**
15
- * Convert a path array to a JS expression string.
16
- * e.g. pathExpr(['childNodes[0]', 'childNodes[1]'], '__root') => '__root.childNodes[0].childNodes[1]'
17
- *
18
- * @param {string[]} parts - Array of childNodes[n] path segments
19
- * @param {string} rootVar - Root variable name
20
- * @returns {string}
21
- */
22
- export function pathExpr(parts, rootVar) {
23
- return parts.length === 0 ? rootVar : rootVar + '.' + parts.join('.');
24
- }
18
+ /** @import { Binding, EventBinding, IfBlock, IfBranch, ShowBinding, AttrBinding, ForBlock, ModelBinding, SlotBinding, SlotProp, RefBinding } from './types.js' */
25
19
 
26
20
  /**
27
- * Walk a DOM tree rooted at rootEl, discovering bindings, events, and slots.
21
+ * Walk a DOM tree rooted at rootEl, discovering bindings and events.
28
22
  *
29
- * @param {Element} rootEl - jsdom DOM element (the parsed template root)
30
- * @param {Set<string>} propsSet - Set of prop names
31
- * @param {Set<string>} computedNames - Set of computed property names
32
- * @param {Set<string>} rootVarNames - Set of root-level reactive variable names
33
- * @returns {{ bindings: Binding[], events: EventBinding[], slots: SlotDef[] }}
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[], attrBindings: AttrBinding[], slots: SlotBinding[] }}
34
28
  */
35
- export function walkTree(rootEl, propsSet, computedNames, rootVarNames) {
29
+ export function walkTree(rootEl, signalNames, computedNames, propNames = new Set()) {
30
+ /** @type {Binding[]} */
36
31
  const bindings = [];
32
+ /** @type {EventBinding[]} */
37
33
  const events = [];
34
+ /** @type {ShowBinding[]} */
35
+ const showBindings = [];
36
+ /** @type {ModelBinding[]} */
37
+ const modelBindings = [];
38
+ /** @type {AttrBinding[]} */
39
+ const attrBindings = [];
40
+ /** @type {SlotBinding[]} */
38
41
  const slots = [];
39
42
  let bindIdx = 0;
40
43
  let eventIdx = 0;
44
+ let showIdx = 0;
45
+ let modelIdx = 0;
46
+ let attrIdx = 0;
41
47
  let slotIdx = 0;
42
48
 
49
+ /**
50
+ * Determine the binding type for a variable name.
51
+ * Priority: prop → signal → computed → method
52
+ *
53
+ * @param {string} name
54
+ * @returns {'prop' | 'signal' | 'computed' | 'method'}
55
+ */
43
56
  function bindingType(name) {
44
- if (propsSet.has(name)) return 'prop';
57
+ if (propNames.has(name)) return 'prop';
58
+ if (signalNames.has(name)) return 'signal';
45
59
  if (computedNames.has(name)) return 'computed';
46
- return 'internal';
60
+ return 'method';
47
61
  }
48
62
 
63
+ /**
64
+ * Recursively walk a DOM node, collecting bindings and events.
65
+ *
66
+ * @param {Node} node — DOM node to walk
67
+ * @param {string[]} pathParts — Current path segments from root
68
+ */
49
69
  function walk(node, pathParts) {
50
70
  // --- Element node ---
51
71
  if (node.nodeType === 1) {
52
- // Detect <slot> elements
53
- if (node.tagName === 'SLOT') {
54
- const slotName = node.getAttribute('name') || '';
72
+ const el = /** @type {Element} */ (node);
73
+
74
+ // Detect <slot> elements replace with <span data-slot="..."> placeholder
75
+ if (el.tagName === 'SLOT') {
76
+ const slotName = el.getAttribute('name') || '';
55
77
  const varName = `__s${slotIdx++}`;
56
- const defaultContent = node.innerHTML.trim();
78
+ const defaultContent = el.innerHTML.trim();
57
79
 
58
- // Collect slotProps (attributes starting with :)
80
+ // Collect :prop="expr" attributes (slot props for scoped slots)
81
+ /** @type {SlotProp[]} */
59
82
  const slotProps = [];
60
- for (const attr of Array.from(node.attributes)) {
83
+ for (const attr of Array.from(el.attributes)) {
61
84
  if (attr.name.startsWith(':')) {
62
85
  slotProps.push({ prop: attr.name.slice(1), source: attr.value });
63
86
  }
@@ -72,17 +95,17 @@ export function walkTree(rootEl, propsSet, computedNames, rootVarNames) {
72
95
  });
73
96
 
74
97
  // Replace <slot> with <span data-slot="name">
75
- const doc = node.ownerDocument;
98
+ const doc = el.ownerDocument;
76
99
  const placeholder = doc.createElement('span');
77
100
  placeholder.setAttribute('data-slot', slotName || 'default');
78
101
  if (defaultContent) placeholder.innerHTML = defaultContent;
79
- node.parentNode.replaceChild(placeholder, node);
80
- return;
102
+ el.parentNode.replaceChild(placeholder, el);
103
+ return; // Don't recurse into the replaced element
81
104
  }
82
105
 
83
106
  // Check for @event attributes
84
107
  const attrsToRemove = [];
85
- for (const attr of Array.from(node.attributes)) {
108
+ for (const attr of Array.from(el.attributes)) {
86
109
  if (attr.name.startsWith('@')) {
87
110
  const varName = `__e${eventIdx++}`;
88
111
  events.push({
@@ -92,35 +115,117 @@ export function walkTree(rootEl, propsSet, computedNames, rootVarNames) {
92
115
  path: [...pathParts],
93
116
  });
94
117
  attrsToRemove.push(attr.name);
118
+ } else if (attr.name.startsWith(':') || attr.name.startsWith('bind:')) {
119
+ // Attribute binding: :attr="expr" or bind:attr="expr"
120
+ const attrName = attr.name.startsWith(':') ? attr.name.slice(1) : attr.name.slice(5);
121
+ const expression = attr.value;
122
+
123
+ // Classify binding kind
124
+ let kind;
125
+ if (attrName === 'class') {
126
+ kind = 'class';
127
+ } else if (attrName === 'style') {
128
+ kind = 'style';
129
+ } else if (BOOLEAN_ATTRIBUTES.has(attrName)) {
130
+ kind = 'bool';
131
+ } else {
132
+ kind = 'attr';
133
+ }
134
+
135
+ const varName = `__attr${attrIdx++}`;
136
+ attrBindings.push({
137
+ varName,
138
+ attr: attrName,
139
+ expression,
140
+ kind,
141
+ path: [...pathParts],
142
+ });
143
+ attrsToRemove.push(attr.name);
144
+ }
145
+ }
146
+ attrsToRemove.forEach((a) => el.removeAttribute(a));
147
+
148
+ // Detect show attribute
149
+ if (el.hasAttribute('show')) {
150
+ const varName = `__show${showIdx++}`;
151
+ showBindings.push({
152
+ varName,
153
+ expression: el.getAttribute('show'),
154
+ path: [...pathParts],
155
+ });
156
+ el.removeAttribute('show');
157
+ }
158
+
159
+ // Detect model attribute
160
+ if (el.hasAttribute('model')) {
161
+ const signalName = el.getAttribute('model');
162
+ const tag = el.tagName.toLowerCase();
163
+
164
+ // Validate element is a form element
165
+ if (!['input', 'textarea', 'select'].includes(tag)) {
166
+ const error = new Error(`model is only valid on <input>, <textarea>, or <select>, not on <${tag}>`);
167
+ /** @ts-expect-error — custom error code */
168
+ error.code = 'INVALID_MODEL_ELEMENT';
169
+ throw error;
170
+ }
171
+
172
+ // Validate model value is a valid identifier
173
+ if (!signalName || !/^[a-zA-Z_$][\w$]*$/.test(signalName)) {
174
+ const error = new Error(`model requires a valid signal name, received: '${signalName || ''}'`);
175
+ /** @ts-expect-error — custom error code */
176
+ error.code = 'INVALID_MODEL_TARGET';
177
+ throw error;
95
178
  }
179
+
180
+ // Determine prop, event, coerce, radioValue based on tag and type
181
+ const type = el.getAttribute('type') || 'text';
182
+ let prop, event, coerce = false, radioValue = null;
183
+
184
+ if (tag === 'select') {
185
+ prop = 'value'; event = 'change';
186
+ } else if (tag === 'textarea') {
187
+ prop = 'value'; event = 'input';
188
+ } else if (type === 'checkbox') {
189
+ prop = 'checked'; event = 'change';
190
+ } else if (type === 'radio') {
191
+ prop = 'checked'; event = 'change';
192
+ radioValue = el.getAttribute('value');
193
+ } else if (type === 'number') {
194
+ prop = 'value'; event = 'input'; coerce = true;
195
+ } else {
196
+ prop = 'value'; event = 'input';
197
+ }
198
+
199
+ const varName = `__model${modelIdx++}`;
200
+ modelBindings.push({ varName, signal: signalName, prop, event, coerce, radioValue, path: [...pathParts] });
201
+ el.removeAttribute('model');
96
202
  }
97
- attrsToRemove.forEach((a) => node.removeAttribute(a));
98
203
  }
99
204
 
100
205
  // --- Text node with interpolations ---
101
- if (node.nodeType === 3 && /\{\{\w+\}\}/.test(node.textContent)) {
206
+ if (node.nodeType === 3 && /\{\{[\w.]+\}\}/.test(node.textContent)) {
102
207
  const text = node.textContent;
103
208
  const trimmed = text.trim();
104
- const soleMatch = trimmed.match(/^\{\{(\w+)\}\}$/);
209
+ const soleMatch = trimmed.match(/^\{\{([\w.]+)\}\}$/);
105
210
  const parent = node.parentNode;
106
211
 
107
- // Case 1: {{var}} is the sole content of the parent element
212
+ // Case 1: {{var}} is the sole content of the parent element and parent has only one child text node
108
213
  if (soleMatch && parent.childNodes.length === 1) {
109
214
  const varName = `__b${bindIdx++}`;
110
215
  bindings.push({
111
216
  varName,
112
217
  name: soleMatch[1],
113
218
  type: bindingType(soleMatch[1]),
114
- path: pathParts.slice(0, -1),
219
+ path: pathParts.slice(0, -1), // path to parent, not text node
115
220
  });
116
- node.textContent = '';
221
+ parent.textContent = '';
117
222
  return;
118
223
  }
119
224
 
120
225
  // Case 2: Mixed text and interpolations — split into spans
121
226
  const doc = node.ownerDocument;
122
227
  const fragment = doc.createDocumentFragment();
123
- const parts = text.split(/(\{\{\w+\}\})/);
228
+ const parts = text.split(/(\{\{[\w.]+\}\})/);
124
229
  const parentPath = pathParts.slice(0, -1);
125
230
 
126
231
  // Find the index of this text node among its siblings
@@ -132,7 +237,7 @@ export function walkTree(rootEl, propsSet, computedNames, rootVarNames) {
132
237
 
133
238
  let offset = 0;
134
239
  for (const part of parts) {
135
- const bm = part.match(/^\{\{(\w+)\}\}$/);
240
+ const bm = part.match(/^\{\{([\w.]+)\}\}$/);
136
241
  if (bm) {
137
242
  fragment.appendChild(doc.createElement('span'));
138
243
  const varName = `__b${bindIdx++}`;
@@ -142,10 +247,11 @@ export function walkTree(rootEl, propsSet, computedNames, rootVarNames) {
142
247
  type: bindingType(bm[1]),
143
248
  path: [...parentPath, `childNodes[${baseIndex + offset}]`],
144
249
  });
250
+ offset++;
145
251
  } else if (part) {
146
252
  fragment.appendChild(doc.createTextNode(part));
253
+ offset++;
147
254
  }
148
- offset++;
149
255
  }
150
256
  parent.replaceChild(fragment, node);
151
257
  return;
@@ -159,5 +265,538 @@ export function walkTree(rootEl, propsSet, computedNames, rootVarNames) {
159
265
  }
160
266
 
161
267
  walk(rootEl, []);
162
- return { bindings, events, slots };
268
+ return { bindings, events, showBindings, modelBindings, attrBindings, slots };
269
+ }
270
+
271
+ // ── Conditional chain processing (if / else-if / else) ──────────────
272
+
273
+ /**
274
+ * Recompute the path from rootEl to a specific node after DOM normalization.
275
+ * Walks up from the node to rootEl, building the path segments.
276
+ *
277
+ * @param {Element} rootEl - The root element
278
+ * @param {Node} targetNode - The node to find the path to
279
+ * @returns {string[]} Path segments from rootEl to targetNode
280
+ */
281
+ export function recomputeAnchorPath(rootEl, targetNode) {
282
+ const segments = [];
283
+ let current = targetNode;
284
+ while (current && current !== rootEl) {
285
+ const parent = current.parentNode;
286
+ if (!parent) break;
287
+ const children = Array.from(parent.childNodes);
288
+ const idx = children.indexOf(current);
289
+ segments.unshift(`childNodes[${idx}]`);
290
+ current = parent;
291
+ }
292
+ return segments;
293
+ }
294
+
295
+ /**
296
+ * Check if an element is a valid predecessor in a conditional chain
297
+ * (has `if` or `else-if` attribute).
298
+ *
299
+ * @param {Element} el
300
+ * @returns {boolean}
301
+ */
302
+ function isChainPredecessor(el) {
303
+ return el.hasAttribute('if') || el.hasAttribute('else-if');
304
+ }
305
+
306
+ /**
307
+ * Process a branch's HTML to extract internal bindings and events.
308
+ * Creates a temporary DOM and runs walkTree on it.
309
+ *
310
+ * @param {string} html - The branch HTML (outerHTML of the branch element)
311
+ * @param {Set<string>} signalNames
312
+ * @param {Set<string>} computedNames
313
+ * @param {Set<string>} propNames
314
+ * @returns {{ bindings: Binding[], events: EventBinding[], showBindings: ShowBinding[], attrBindings: AttrBinding[], modelBindings: ModelBinding[], slots: SlotBinding[], processedHtml: string }}
315
+ */
316
+ export function walkBranch(html, signalNames, computedNames, propNames) {
317
+ const dom = new JSDOM(`<div id="__branchRoot">${html}</div>`);
318
+ const branchRoot = dom.window.document.getElementById('__branchRoot');
319
+
320
+ // Use walkTree on the branch root to discover bindings/events
321
+ const result = walkTree(branchRoot, signalNames, computedNames, propNames);
322
+
323
+ // Capture the processed HTML AFTER walkTree has modified the DOM
324
+ const processedHtml = branchRoot.innerHTML;
325
+
326
+ // Strip the first path segment from all paths since at runtime
327
+ // `node = clone.firstChild` is the element itself, not the wrapper div.
328
+ function stripFirstSegment(items) {
329
+ for (const item of items) {
330
+ if (item.path && item.path.length > 0 && item.path[0].startsWith('childNodes[')) {
331
+ item.path = item.path.slice(1);
332
+ }
333
+ }
334
+ }
335
+ stripFirstSegment(result.bindings);
336
+ stripFirstSegment(result.events);
337
+ stripFirstSegment(result.showBindings);
338
+ stripFirstSegment(result.attrBindings);
339
+ stripFirstSegment(result.modelBindings);
340
+ stripFirstSegment(result.slots);
341
+
342
+ return {
343
+ bindings: result.bindings,
344
+ events: result.events,
345
+ showBindings: result.showBindings,
346
+ attrBindings: result.attrBindings,
347
+ modelBindings: result.modelBindings,
348
+ slots: result.slots,
349
+ processedHtml,
350
+ };
351
+ }
352
+
353
+ /**
354
+ * Build an IfBlock from a completed chain, replacing elements with a comment node.
355
+ *
356
+ * @param {{ elements: Element[], branches: { type: 'if' | 'else-if' | 'else', expression: string | null, element: Element }[] }} chain
357
+ * @param {Element} parent
358
+ * @param {string[]} parentPath
359
+ * @param {number} idx
360
+ * @param {Set<string>} signalNames
361
+ * @param {Set<string>} computedNames
362
+ * @param {Set<string>} propNames
363
+ * @returns {IfBlock}
364
+ */
365
+ function buildIfBlock(chain, parent, parentPath, idx, signalNames, computedNames, propNames) {
366
+ const doc = parent.ownerDocument;
367
+
368
+ // Extract HTML for each branch (without the directive attribute)
369
+ /** @type {IfBranch[]} */
370
+ const branches = chain.branches.map((branch) => {
371
+ const el = branch.element;
372
+ // Clone the element to extract HTML without modifying the original yet
373
+ const clone = /** @type {Element} */ (el.cloneNode(true));
374
+ // Remove the directive attribute from the clone
375
+ clone.removeAttribute('if');
376
+ clone.removeAttribute('else-if');
377
+ clone.removeAttribute('else');
378
+ const templateHtml = clone.outerHTML;
379
+
380
+ // Process internal bindings/events via partial walk
381
+ const { bindings, events, showBindings, attrBindings, modelBindings, slots, processedHtml } = walkBranch(templateHtml, signalNames, computedNames, propNames);
382
+
383
+ return {
384
+ type: branch.type,
385
+ expression: branch.expression,
386
+ templateHtml: processedHtml,
387
+ bindings,
388
+ events,
389
+ showBindings,
390
+ attrBindings,
391
+ modelBindings,
392
+ slots,
393
+ };
394
+ });
395
+
396
+ // Replace all chain elements with a single comment node
397
+ const comment = doc.createComment(' if ');
398
+ const firstEl = chain.elements[0];
399
+ parent.insertBefore(comment, firstEl);
400
+
401
+ // Remove all chain elements from the DOM
402
+ for (const el of chain.elements) {
403
+ parent.removeChild(el);
404
+ }
405
+
406
+ // Calculate anchorPath: find the index of the comment node among parent's childNodes
407
+ const childNodes = Array.from(parent.childNodes);
408
+ const commentIndex = childNodes.indexOf(comment);
409
+ const anchorPath = [...parentPath, `childNodes[${commentIndex}]`];
410
+
411
+ return {
412
+ varName: `__if${idx}`,
413
+ anchorPath,
414
+ _anchorNode: comment,
415
+ branches,
416
+ };
417
+ }
418
+
419
+ /**
420
+ * Process conditional chains (if/else-if/else) in a DOM tree.
421
+ * Recursively searches all descendants for chains.
422
+ *
423
+ * @param {Element} parent - Root element to search
424
+ * @param {string[]} parentPath - DOM path to parent from __root
425
+ * @param {Set<string>} signalNames
426
+ * @param {Set<string>} computedNames
427
+ * @param {Set<string>} propNames
428
+ * @returns {IfBlock[]}
429
+ */
430
+ export function processIfChains(parent, parentPath, signalNames, computedNames, propNames) {
431
+ /** @type {IfBlock[]} */
432
+ const ifBlocks = [];
433
+ let ifIdx = 0;
434
+
435
+ /**
436
+ * Recursively search for if chains in the subtree.
437
+ * @param {Element} node
438
+ * @param {string[]} currentPath
439
+ */
440
+ function findIfChains(node, currentPath) {
441
+ const children = Array.from(node.childNodes);
442
+
443
+ // First pass: validate all element children for conflicting directives
444
+ for (const child of children) {
445
+ if (child.nodeType !== 1) continue;
446
+ const el = /** @type {Element} */ (child);
447
+
448
+ const hasIf = el.hasAttribute('if');
449
+ const hasElseIf = el.hasAttribute('else-if');
450
+ const hasElse = el.hasAttribute('else');
451
+ const hasShow = el.hasAttribute('show');
452
+
453
+ // CONFLICTING_DIRECTIVES: if + else or if + else-if on same element
454
+ if (hasIf && (hasElse || hasElseIf)) {
455
+ const error = new Error('Las directivas condicionales son mutuamente excluyentes en un mismo elemento');
456
+ /** @ts-expect-error — custom error code */
457
+ error.code = 'CONFLICTING_DIRECTIVES';
458
+ throw error;
459
+ }
460
+
461
+ // CONFLICTING_DIRECTIVES: show + if on same element
462
+ if (hasShow && hasIf) {
463
+ const error = new Error('show y if no deben usarse en el mismo elemento');
464
+ /** @ts-expect-error — custom error code */
465
+ error.code = 'CONFLICTING_DIRECTIVES';
466
+ throw error;
467
+ }
468
+
469
+ // INVALID_V_ELSE: else with a non-empty value
470
+ if (hasElse && el.getAttribute('else') !== '') {
471
+ const error = new Error('else no acepta expresión');
472
+ /** @ts-expect-error — custom error code */
473
+ error.code = 'INVALID_V_ELSE';
474
+ throw error;
475
+ }
476
+ }
477
+
478
+ // Second pass: detect chains by iterating element nodes in order
479
+ /** @type {{ elements: Element[], branches: { type: 'if' | 'else-if' | 'else', expression: string | null, element: Element }[] } | null} */
480
+ let currentChain = null;
481
+ /** @type {Element | null} */
482
+ let prevElement = null;
483
+
484
+ for (const child of children) {
485
+ if (child.nodeType !== 1) continue;
486
+ const el = /** @type {Element} */ (child);
487
+
488
+ const hasIf = el.hasAttribute('if');
489
+ const hasElseIf = el.hasAttribute('else-if');
490
+ const hasElse = el.hasAttribute('else');
491
+
492
+ if (hasIf) {
493
+ // Close any open chain
494
+ if (currentChain) {
495
+ ifBlocks.push(buildIfBlock(currentChain, node, currentPath, ifIdx++, signalNames, computedNames, propNames));
496
+ currentChain = null;
497
+ }
498
+ // Start new chain
499
+ currentChain = {
500
+ elements: [el],
501
+ branches: [{ type: 'if', expression: el.getAttribute('if'), element: el }],
502
+ };
503
+ } else if (hasElseIf) {
504
+ // Validate: must follow an if or else-if
505
+ if (!currentChain || !prevElement || !isChainPredecessor(prevElement)) {
506
+ const error = new Error('else-if/else requiere un if previo en el mismo nivel');
507
+ /** @ts-expect-error — custom error code */
508
+ error.code = 'ORPHAN_ELSE';
509
+ throw error;
510
+ }
511
+ currentChain.elements.push(el);
512
+ currentChain.branches.push({ type: 'else-if', expression: el.getAttribute('else-if'), element: el });
513
+ } else if (hasElse) {
514
+ // Validate: must follow an if or else-if
515
+ if (!currentChain || !prevElement || !isChainPredecessor(prevElement)) {
516
+ const error = new Error('else-if/else requiere un if previo en el mismo nivel');
517
+ /** @ts-expect-error — custom error code */
518
+ error.code = 'ORPHAN_ELSE';
519
+ throw error;
520
+ }
521
+ currentChain.elements.push(el);
522
+ currentChain.branches.push({ type: 'else', expression: null, element: el });
523
+ // Close chain
524
+ ifBlocks.push(buildIfBlock(currentChain, node, currentPath, ifIdx++, signalNames, computedNames, propNames));
525
+ currentChain = null;
526
+ } else {
527
+ // Non-conditional element: close any open chain
528
+ if (currentChain) {
529
+ ifBlocks.push(buildIfBlock(currentChain, node, currentPath, ifIdx++, signalNames, computedNames, propNames));
530
+ currentChain = null;
531
+ }
532
+ // Recurse into non-conditional elements to find nested if chains
533
+ const childIdx = Array.from(node.childNodes).indexOf(el);
534
+ findIfChains(el, [...currentPath, `childNodes[${childIdx}]`]);
535
+ }
536
+
537
+ prevElement = el;
538
+ }
539
+
540
+ // Close any remaining open chain
541
+ if (currentChain) {
542
+ ifBlocks.push(buildIfBlock(currentChain, node, currentPath, ifIdx++, signalNames, computedNames, propNames));
543
+ currentChain = null;
544
+ }
545
+ }
546
+
547
+ findIfChains(parent, parentPath);
548
+
549
+ // Normalize the DOM to merge adjacent text nodes created by element removal
550
+ parent.normalize();
551
+
552
+ // Recompute anchor paths after normalization since text node merging
553
+ // may have changed childNode indices
554
+ for (const ib of ifBlocks) {
555
+ ib.anchorPath = recomputeAnchorPath(parent, ib._anchorNode);
556
+ }
557
+
558
+ return ifBlocks;
559
+ }
560
+
561
+ // ── each directive processing ───────────────────────────────────────
562
+
563
+ // Forma simple: "item in source"
564
+ const simpleRe = /^\s*(\w+)\s+in\s+(.+)\s*$/;
565
+ // Forma con índice: "(item, index) in source"
566
+ const destructuredRe = /^\s*\(\s*(\w+)\s*,\s*(\w+)\s*\)\s+in\s+(.+)\s*$/;
567
+
568
+ /**
569
+ * Parse an each expression.
570
+ * Supports:
571
+ * "item in source"
572
+ * "(item, index) in source"
573
+ *
574
+ * @param {string} expr - The each attribute value
575
+ * @returns {{ itemVar: string, indexVar: string | null, source: string }}
576
+ * @throws {Error} with code INVALID_V_FOR if syntax is invalid
577
+ */
578
+ export function parseEachExpression(expr) {
579
+ // Check if expression contains "in" keyword
580
+ if (!/\bin\b/.test(expr)) {
581
+ const error = new Error('each requiere la sintaxis \'item in source\' o \'(item, index) in source\'');
582
+ /** @ts-expect-error — custom error code */
583
+ error.code = 'INVALID_V_FOR';
584
+ throw error;
585
+ }
586
+
587
+ // Try destructured form first (more specific)
588
+ const destructuredMatch = destructuredRe.exec(expr);
589
+ if (destructuredMatch) {
590
+ const itemVar = destructuredMatch[1];
591
+ const indexVar = destructuredMatch[2];
592
+ const source = destructuredMatch[3].trim();
593
+
594
+ if (!itemVar) {
595
+ const error = new Error('each requiere una variable de iteración');
596
+ /** @ts-expect-error — custom error code */
597
+ error.code = 'INVALID_V_FOR';
598
+ throw error;
599
+ }
600
+ if (!source) {
601
+ const error = new Error('each requiere una expresión fuente');
602
+ /** @ts-expect-error — custom error code */
603
+ error.code = 'INVALID_V_FOR';
604
+ throw error;
605
+ }
606
+
607
+ return { itemVar, indexVar, source };
608
+ }
609
+
610
+ // Try simple form
611
+ const simpleMatch = simpleRe.exec(expr);
612
+ if (simpleMatch) {
613
+ const itemVar = simpleMatch[1];
614
+ const source = simpleMatch[2].trim();
615
+
616
+ if (!itemVar) {
617
+ const error = new Error('each requiere una variable de iteración');
618
+ /** @ts-expect-error — custom error code */
619
+ error.code = 'INVALID_V_FOR';
620
+ throw error;
621
+ }
622
+ if (!source) {
623
+ const error = new Error('each requiere una expresión fuente');
624
+ /** @ts-expect-error — custom error code */
625
+ error.code = 'INVALID_V_FOR';
626
+ throw error;
627
+ }
628
+
629
+ return { itemVar, indexVar: null, source };
630
+ }
631
+
632
+ // If neither regex matched, check for specific error conditions
633
+ const inIndex = expr.indexOf(' in ');
634
+ if (inIndex !== -1) {
635
+ const left = expr.substring(0, inIndex).trim();
636
+ const right = expr.substring(inIndex + 4).trim();
637
+
638
+ if (!left) {
639
+ const error = new Error('each requiere una variable de iteración');
640
+ /** @ts-expect-error — custom error code */
641
+ error.code = 'INVALID_V_FOR';
642
+ throw error;
643
+ }
644
+ if (!right) {
645
+ const error = new Error('each requiere una expresión fuente');
646
+ /** @ts-expect-error — custom error code */
647
+ error.code = 'INVALID_V_FOR';
648
+ throw error;
649
+ }
650
+ }
651
+
652
+ // Fallback: invalid syntax
653
+ const error = new Error('each requiere la sintaxis \'item in source\' o \'(item, index) in source\'');
654
+ /** @ts-expect-error — custom error code */
655
+ error.code = 'INVALID_V_FOR';
656
+ throw error;
657
+ }
658
+
659
+ /**
660
+ * Process each directives in descendants of a parent element.
661
+ * Recursively detects elements with `each` attribute, validates them,
662
+ * extracts item templates, and replaces them with comment anchors.
663
+ *
664
+ * @param {Element} parent - Root element to search
665
+ * @param {string[]} parentPath - DOM path to parent from __root
666
+ * @param {Set<string>} signalNames
667
+ * @param {Set<string>} computedNames
668
+ * @param {Set<string>} propNames
669
+ * @returns {ForBlock[]}
670
+ */
671
+ export function processForBlocks(parent, parentPath, signalNames, computedNames, propNames) {
672
+ /** @type {ForBlock[]} */
673
+ const forBlocks = [];
674
+ let forIdx = 0;
675
+
676
+ /**
677
+ * Recursively search for elements with each in the subtree.
678
+ * @param {Element} node
679
+ * @param {string[]} currentPath
680
+ */
681
+ function findForElements(node, currentPath) {
682
+ const children = Array.from(node.childNodes);
683
+ for (let i = 0; i < children.length; i++) {
684
+ const child = children[i];
685
+ if (child.nodeType !== 1) continue;
686
+ const el = /** @type {Element} */ (child);
687
+
688
+ if (el.hasAttribute('each')) {
689
+ // Validate no conflicting if directive
690
+ if (el.hasAttribute('if')) {
691
+ const error = new Error('each y if no deben usarse en el mismo elemento');
692
+ /** @ts-expect-error — custom error code */
693
+ error.code = 'CONFLICTING_DIRECTIVES';
694
+ throw error;
695
+ }
696
+
697
+ // Parse the each expression
698
+ const expr = el.getAttribute('each');
699
+ const { itemVar, indexVar, source } = parseEachExpression(expr);
700
+
701
+ // Extract :key if present
702
+ const keyExpr = el.hasAttribute(':key') ? el.getAttribute(':key') : null;
703
+
704
+ // Clone the element and remove each and :key from the clone
705
+ const clone = /** @type {Element} */ (el.cloneNode(true));
706
+ clone.removeAttribute('each');
707
+ clone.removeAttribute(':key');
708
+ const templateHtml = clone.outerHTML;
709
+
710
+ // Process internal bindings/events via partial walk
711
+ const { bindings, events, showBindings, attrBindings, modelBindings, slots, processedHtml } = walkBranch(templateHtml, signalNames, computedNames, propNames);
712
+
713
+ // Replace the original element with a comment node <!-- each -->
714
+ const doc = node.ownerDocument;
715
+ const comment = doc.createComment(' each ');
716
+ node.replaceChild(comment, el);
717
+
718
+ // Calculate anchorPath
719
+ const updatedChildren = Array.from(node.childNodes);
720
+ const commentIndex = updatedChildren.indexOf(comment);
721
+ const anchorPath = [...currentPath, `childNodes[${commentIndex}]`];
722
+
723
+ // Create ForBlock
724
+ forBlocks.push({
725
+ varName: `__for${forIdx++}`,
726
+ itemVar,
727
+ indexVar,
728
+ source,
729
+ keyExpr,
730
+ templateHtml: processedHtml,
731
+ anchorPath,
732
+ _anchorNode: comment,
733
+ bindings,
734
+ events,
735
+ showBindings,
736
+ attrBindings,
737
+ modelBindings,
738
+ slots,
739
+ });
740
+ } else {
741
+ // Recurse into non-each elements to find nested each
742
+ const childPath = [...currentPath, `childNodes[${i}]`];
743
+ findForElements(el, childPath);
744
+ }
745
+ }
746
+ }
747
+
748
+ findForElements(parent, parentPath);
749
+ return forBlocks;
750
+ }
751
+
752
+
753
+ // ── Ref detection ───────────────────────────────────────────────────
754
+
755
+ /**
756
+ * Detect ref="name" attributes on elements in the DOM tree.
757
+ * Removes the ref attribute from each element after recording.
758
+ *
759
+ * @param {Element} rootEl — jsdom DOM element (parsed template root)
760
+ * @returns {RefBinding[]}
761
+ * @throws {Error} with code DUPLICATE_REF if same ref name appears on multiple elements
762
+ */
763
+ export function detectRefs(rootEl) {
764
+ /** @type {RefBinding[]} */
765
+ const refBindings = [];
766
+ /** @type {Set<string>} */
767
+ const seen = new Set();
768
+
769
+ const elements = rootEl.querySelectorAll('[ref]');
770
+
771
+ for (const el of elements) {
772
+ const refName = el.getAttribute('ref');
773
+
774
+ // Check for duplicate ref names
775
+ if (seen.has(refName)) {
776
+ const error = new Error(`Duplicate ref name '${refName}' — each ref must be unique`);
777
+ /** @ts-expect-error — custom error code */
778
+ error.code = 'DUPLICATE_REF';
779
+ throw error;
780
+ }
781
+ seen.add(refName);
782
+
783
+ // Compute DOM path from rootEl to el
784
+ const path = [];
785
+ let current = el;
786
+ while (current && current !== rootEl) {
787
+ const parent = current.parentNode;
788
+ if (!parent) break;
789
+ const children = Array.from(parent.childNodes);
790
+ const idx = children.indexOf(current);
791
+ path.unshift(`childNodes[${idx}]`);
792
+ current = parent;
793
+ }
794
+
795
+ // Remove the ref attribute
796
+ el.removeAttribute('ref');
797
+
798
+ refBindings.push({ refName, path });
799
+ }
800
+
801
+ return refBindings;
163
802
  }