@sprlab/wccompiler 0.0.3 → 0.2.1

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