@ape-egg/vibe 1.6.1 → 1.7.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.
@@ -16,9 +16,10 @@ import * as compiled from './pre-compiled-iterations.js';
16
16
  * Find a comment node with matching text content in the given nodes.
17
17
  */
18
18
  const findComment = (nodes, text) => {
19
+ const trimmedText = text.trim();
19
20
  for (let i = 0; i < nodes.length; i++) {
20
21
  const node = nodes[i];
21
- if (node.nodeType === 8 && node.textContent.trim() === text.trim()) {
22
+ if (node.nodeType === 8 && node.textContent.trim() === trimmedText) {
22
23
  return node;
23
24
  }
24
25
  }
@@ -36,9 +37,16 @@ const cloneTreeWithElements = (originalTree, clonedRoot) => {
36
37
  parsed: originalTree.parsed,
37
38
  element: clonedRoot,
38
39
  children: {},
39
- ...(originalTree.attributes && { attributes: originalTree.attributes }),
40
40
  };
41
41
 
42
+ // Avoid spread operator for performance
43
+ if (originalTree.attributes) {
44
+ cloned.attributes = originalTree.attributes;
45
+ }
46
+ if (originalTree.nameBindings) {
47
+ cloned.nameBindings = originalTree.nameBindings;
48
+ }
49
+
42
50
  if (!originalTree.children) return cloned;
43
51
 
44
52
  const clonedChildNodes = clonedRoot?.childNodes;
@@ -130,26 +138,42 @@ export const initializeBlock = (templateNodes, scopedState, cachedTree = null) =
130
138
  let clonedNodes = [];
131
139
  let firstElement = null;
132
140
 
133
- // Always use slow path: clone and parse from scratch
134
- // TODO: Re-enable fast path once cloneTreeWithElements properly handles nested conditionals
141
+ // TEMPORARY: Disable fast path to test if it's causing duplication
142
+ let canUseFastPath = false;
135
143
 
136
144
  // Create a container for parsing (to capture all nodes including comment nodes like <!-- if -->)
137
145
  const parseContainer = document.createElement('div');
138
146
 
139
- // Clone all template nodes into the container
140
- for (let i = 0; i < templateNodes.length; i++) {
141
- const cloned = templateNodes[i].cloneNode(true);
142
- parseContainer.appendChild(cloned);
143
- if (!firstElement && cloned.nodeType === 1) {
144
- firstElement = cloned;
147
+ if (canUseFastPath) {
148
+ // Fast path: clone template nodes and map to cached tree structure
149
+ for (let i = 0; i < templateNodes.length; i++) {
150
+ const cloned = templateNodes[i].cloneNode(true);
151
+ parseContainer.appendChild(cloned);
152
+ if (!firstElement && cloned.nodeType === 1) {
153
+ firstElement = cloned;
154
+ }
155
+ }
156
+ tree = cloneTreeWithElements(cachedTree, parseContainer);
157
+ } else {
158
+ // Slow path: clone and parse from scratch
159
+ for (let i = 0; i < templateNodes.length; i++) {
160
+ const cloned = templateNodes[i].cloneNode(true);
161
+ parseContainer.appendChild(cloned);
162
+ if (!firstElement && cloned.nodeType === 1) {
163
+ firstElement = cloned;
164
+ }
145
165
  }
166
+ // Parse the entire container (includes all nodes + conditionals)
167
+ tree = parse(parseContainer);
146
168
  }
147
169
 
148
- // Parse the entire container (includes all nodes + conditionals)
149
- tree = parse(parseContainer);
150
-
151
170
  // Extract the cloned nodes from the container (these are the same nodes the tree references)
152
- clonedNodes = Array.from(parseContainer.childNodes);
171
+ // Avoid Array.from for performance
172
+ const childNodes = parseContainer.childNodes;
173
+ clonedNodes = [];
174
+ for (let i = 0; i < childNodes.length; i++) {
175
+ clonedNodes.push(childNodes[i]);
176
+ }
153
177
 
154
178
  // If no firstElement found, use parseContainer as fallback
155
179
  if (!firstElement) {
@@ -271,6 +295,21 @@ export const renderIteration = (iterationNode, state, manifest, parentScope = {}
271
295
  return;
272
296
  }
273
297
 
298
+ // Fallback check: If markers are lost (e.g., comment nodes replaced by component loading),
299
+ // check actual DOM state between comments for hydrated nodes
300
+ let currentNode = startComment.nextSibling;
301
+ while (currentNode && currentNode !== endComment) {
302
+ if (currentNode.nodeType === 1) {
303
+ // Element node
304
+ const html = currentNode.outerHTML || '';
305
+ // If node doesn't have any @[...] syntax, it's been hydrated
306
+ if (!html.includes('@[')) {
307
+ return; // Already rendered
308
+ }
309
+ }
310
+ currentNode = currentNode.nextSibling;
311
+ }
312
+
274
313
  // Remove template nodes from DOM on first render
275
314
  if (!iterationNode.runtime.templateRemoved) {
276
315
  let node = startComment.nextSibling;
@@ -361,7 +400,16 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
361
400
  // Compiled path: Use pre-compiled batch function when available
362
401
  if (compiled.canUseCompiled(iterationNode)) {
363
402
  const compiledMeta = compiled.getCompiledMeta(iterationNode);
364
- if (compiled.updateCompiled(iterationNode, newArray, newState, compiledMeta, startComment, endComment)) {
403
+ if (
404
+ compiled.updateCompiled(
405
+ iterationNode,
406
+ newArray,
407
+ newState,
408
+ compiledMeta,
409
+ startComment,
410
+ endComment,
411
+ )
412
+ ) {
365
413
  return;
366
414
  }
367
415
  // Fall through to runtime path if compiled failed
package/runtime/parse.js CHANGED
@@ -12,7 +12,7 @@ const parseHTML = (children, rootKey = undefined) =>
12
12
  children.reduce((s, element, i) => {
13
13
  const { nodeName, textContent } = element;
14
14
  if (['#comment'].includes(nodeName)) {
15
- return `${s}${nodeName === '#comment' ? `asd` : textContent}`;
15
+ return s;
16
16
  }
17
17
  const name = nodeName.startsWith('#') ? nodeName.slice(1) : nodeName;
18
18
  const innerNodeIdentifier = `${name}_${i}`.toLowerCase();
@@ -77,8 +77,9 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
77
77
  },
78
78
  },
79
79
  // Preserve runtime data from previous parse if it exists (stored on startComment by iterate.js)
80
+ // Only restore if the comment node is still connected to the DOM (not replaced by component loading)
80
81
  // @ts-ignore - custom property added by iterate.js
81
- runtime: element.__vibeIterationRuntime || {
82
+ runtime: (element.isConnected && element.__vibeIterationRuntime) || {
82
83
  instances: [],
83
84
  templateRemoved: false,
84
85
  },
@@ -43,7 +43,7 @@ export const buildHyperspeedManifest = (parsedTree) => {
43
43
  }
44
44
 
45
45
  // Check if parsed string contains bindings - split into array
46
- if (typeof node.parsed === 'string' && node.parsed.includes('@[')) {
46
+ if (typeof node.parsed === "string" && node.parsed.includes("@[")) {
47
47
  const parsedArray = splitByMarkers(node.parsed);
48
48
  if (parsedArray) {
49
49
  result.hyperspeedRestoration = {
@@ -53,10 +53,10 @@ export const buildHyperspeedManifest = (parsedTree) => {
53
53
  }
54
54
 
55
55
  // Check for attribute bindings
56
- if (node.attributes && typeof node.attributes === 'object') {
56
+ if (node.attributes && typeof node.attributes === "object") {
57
57
  const attrBindings = {};
58
58
  for (const [key, value] of Object.entries(node.attributes)) {
59
- if (typeof value === 'string' && value.includes('@[')) {
59
+ if (typeof value === "string" && value.includes("@[")) {
60
60
  attrBindings[key] = value;
61
61
  }
62
62
  }
@@ -69,7 +69,7 @@ export const buildHyperspeedManifest = (parsedTree) => {
69
69
  }
70
70
 
71
71
  // For iterations, extract template as HTML string
72
- if (node.type === 'iteration' && node.meta?.template?.element) {
72
+ if (node.type === "iteration" && node.meta?.template?.element) {
73
73
  const templateElement = node.meta.template.element;
74
74
  if (templateElement && templateElement.innerHTML) {
75
75
  if (!result.hyperspeedRestoration) {
@@ -80,7 +80,7 @@ export const buildHyperspeedManifest = (parsedTree) => {
80
80
  }
81
81
 
82
82
  // For conditionals, extract branch templates as HTML strings
83
- if (node.type === 'conditional' && node.meta?.branches) {
83
+ if (node.type === "conditional" && node.meta?.branches) {
84
84
  const trueBranch = node.meta.branches.true?.element;
85
85
  const falseBranch = node.meta.branches.false?.element;
86
86
 
@@ -98,7 +98,7 @@ export const buildHyperspeedManifest = (parsedTree) => {
98
98
  }
99
99
 
100
100
  // Recursively process children (skip runtime-only nodes)
101
- if (node.children && typeof node.children === 'object') {
101
+ if (node.children && typeof node.children === "object") {
102
102
  for (const [key, childNode] of Object.entries(node.children)) {
103
103
  result.children[key] = walkNode(childNode);
104
104
  }
@@ -116,8 +116,8 @@ export const buildHyperspeedManifest = (parsedTree) => {
116
116
  element: null,
117
117
  parsed: [],
118
118
  children: walkNode(parsedTree).children, // Use children directly
119
- }
120
- }
119
+ },
120
+ },
121
121
  };
122
122
  };
123
123
 
@@ -138,19 +138,19 @@ const detectHyperspeed = async () => {
138
138
  let pagePath = window.location.pathname;
139
139
 
140
140
  // Normalize path: handle directory URLs and missing extensions
141
- if (pagePath.endsWith('/')) {
141
+ if (pagePath.endsWith("/")) {
142
142
  // /compiled/ -> /compiled/index.html
143
- pagePath = pagePath + 'index.html';
144
- } else if (!pagePath.includes('.')) {
143
+ pagePath = pagePath + "index.html";
144
+ } else if (!pagePath.includes(".")) {
145
145
  // /compiled/mypage -> /compiled/mypage.html
146
- const lastSlash = pagePath.lastIndexOf('/');
146
+ const lastSlash = pagePath.lastIndexOf("/");
147
147
  const lastSegment = pagePath.substring(lastSlash + 1);
148
- if (lastSegment && !lastSegment.includes('.')) {
149
- pagePath = pagePath + '.html';
148
+ if (lastSegment && !lastSegment.includes(".")) {
149
+ pagePath = pagePath + ".html";
150
150
  }
151
151
  }
152
152
 
153
- const pathSegments = pagePath.split('/').filter(s => s);
153
+ const pathSegments = pagePath.split("/").filter((s) => s);
154
154
 
155
155
  if (pathSegments.length === 0) return null;
156
156
 
@@ -165,9 +165,11 @@ const detectHyperspeed = async () => {
165
165
  // Strategy 1: vibe-hyperspeed at the same level as parent directory
166
166
  // /compiled/playground/test.html -> /compiled/vibe-hyperspeed/playground/test.html.manifest.js
167
167
  if (dirSegments.length >= 1) {
168
- const subPath = dirSegments.slice(1).join('/'); // Everything after first dir
169
- const baseDir = '/' + dirSegments[0]; // First directory segment
170
- possiblePaths.push(`${baseDir}/vibe-hyperspeed/${subPath ? subPath + '/' : ''}${fileName}.manifest.js`);
168
+ const subPath = dirSegments.slice(1).join("/"); // Everything after first dir
169
+ const baseDir = "/" + dirSegments[0]; // First directory segment
170
+ possiblePaths.push(
171
+ `${baseDir}/vibe-hyperspeed/${subPath ? subPath + "/" : ""}${fileName}.manifest.js`,
172
+ );
171
173
  }
172
174
 
173
175
  // Strategy 2: vibe-hyperspeed at web root (original behavior)
@@ -177,8 +179,10 @@ const detectHyperspeed = async () => {
177
179
  // Strategy 3: vibe-hyperspeed relative to immediate parent
178
180
  // /playground/test.html -> /vibe-hyperspeed/playground/test.html.manifest.js
179
181
  if (dirSegments.length > 0) {
180
- const relativePath = dirSegments.join('/');
181
- possiblePaths.push(`/vibe-hyperspeed/${relativePath}/${fileName}.manifest.js`);
182
+ const relativePath = dirSegments.join("/");
183
+ possiblePaths.push(
184
+ `/vibe-hyperspeed/${relativePath}/${fileName}.manifest.js`,
185
+ );
182
186
  }
183
187
 
184
188
  // Try each possible path
@@ -187,7 +191,7 @@ const detectHyperspeed = async () => {
187
191
  const module = await import(manifestPath);
188
192
  hyperspeedData = {
189
193
  manifest: module.default,
190
- path: manifestPath
194
+ path: manifestPath,
191
195
  };
192
196
  return hyperspeedData;
193
197
  } catch (e) {
@@ -217,7 +221,11 @@ const detectHyperspeed = async () => {
217
221
  * @param {Object} subtree - The matching subtree from manifest (e.g., manifest.children.body)
218
222
  * @param {Object} fullManifest - The full manifest (unused now, kept for compatibility)
219
223
  */
220
- export const restoreMarkersFromManifest = (rootElement, subtree, fullManifest = null) => {
224
+ export const restoreMarkersFromManifest = (
225
+ rootElement,
226
+ subtree,
227
+ fullManifest = null,
228
+ ) => {
221
229
  const walkTree = (tree, element) => {
222
230
  if (!tree || !element) return;
223
231
 
@@ -227,17 +235,25 @@ export const restoreMarkersFromManifest = (rootElement, subtree, fullManifest =
227
235
  if (restoration) {
228
236
  // Restore text content with markers if parsed contains bindings
229
237
  if (restoration.parsed && Array.isArray(restoration.parsed)) {
230
- const hasBindings = restoration.parsed.some(item =>
231
- typeof item === 'string' && item.includes('@[')
238
+ const hasBindings = restoration.parsed.some(
239
+ (item) => typeof item === "string" && item.includes("@["),
232
240
  );
233
241
 
234
242
  if (hasBindings) {
235
243
  // Reconstruct original content with markers
236
- const originalContent = restoration.parsed.join('');
244
+ let originalContent = restoration.parsed.join("");
245
+
246
+ // Transform component-scoped bindings back to this. format
247
+ // Compiler transforms @[this.count] → @[_c0.count] for stamping
248
+ // Runtime expects @[this.count], so transform back
249
+ originalContent = originalContent.replace(/@\[_c\d+\./g, "@[this.");
237
250
 
238
251
  // For text nodes, update parent's innerHTML
239
252
  // For elements with children, update only text nodes
240
- if (element.childNodes.length === 1 && element.childNodes[0].nodeType === 3) {
253
+ if (
254
+ element.childNodes.length === 1 &&
255
+ element.childNodes[0].nodeType === 3
256
+ ) {
241
257
  // Single text node - replace it
242
258
  element.childNodes[0].textContent = originalContent;
243
259
  } else if (element.childNodes.length === 0) {
@@ -253,7 +269,13 @@ export const restoreMarkersFromManifest = (rootElement, subtree, fullManifest =
253
269
  // 1. Boolean-like attributes with falsy values were removed - restore them
254
270
  // 2. Value attributes were stamped - replace stamped values with markers
255
271
  if (restoration.attributes) {
256
- for (const [attrName, attrValue] of Object.entries(restoration.attributes)) {
272
+ for (let [attrName, attrValue] of Object.entries(
273
+ restoration.attributes,
274
+ )) {
275
+ // Transform component-scoped bindings back to this. format
276
+ // Compiler transforms @[this.count] → @[_c0.count], runtime expects @[this.count]
277
+ attrValue = attrValue.replace(/@\[_c\d+\./g, "@[this.");
278
+
257
279
  // Always set the attribute to restore the marker
258
280
  // - If missing (boolean-like, falsy): adds it back
259
281
  // - If present (value attr, stamped): replaces stamped value with marker
@@ -288,17 +310,22 @@ export const restoreMarkersFromManifest = (rootElement, subtree, fullManifest =
288
310
  // Text nodes use childNodes indices which become invalid after DOM modifications
289
311
  if (tree.children) {
290
312
  for (const key in tree.children) {
291
- if (key.startsWith('text_')) {
313
+ if (key.startsWith("text_")) {
292
314
  const childTree = tree.children[key];
293
315
  const restoration = childTree.compiled?.restoration;
294
316
 
295
317
  if (restoration?.parsed && Array.isArray(restoration.parsed)) {
296
- const hasBindings = restoration.parsed.some(item =>
297
- typeof item === 'string' && item.includes('@[')
318
+ const hasBindings = restoration.parsed.some(
319
+ (item) => typeof item === "string" && item.includes("@["),
298
320
  );
299
321
 
300
322
  if (hasBindings) {
301
- const originalContent = restoration.parsed.join('');
323
+ // Transform component-scoped bindings back to this. format
324
+ let originalContent = restoration.parsed.join("");
325
+ originalContent = originalContent.replace(
326
+ /@\[_c\d+\./g,
327
+ "@[this.",
328
+ );
302
329
 
303
330
  // Extract index from key (e.g., text_0 -> 0)
304
331
  const match = key.match(/_(\d+)$/);
@@ -329,7 +356,7 @@ export const restoreMarkersFromManifest = (rootElement, subtree, fullManifest =
329
356
  const childTree = tree.children[key];
330
357
 
331
358
  // Handle iteration restoration
332
- if (childTree.type === 'iteration' && childTree.compiled?.restoration) {
359
+ if (childTree.type === "iteration" && childTree.compiled?.restoration) {
333
360
  const restoration = childTree.compiled.restoration;
334
361
 
335
362
  // Find iteration comment by matching the expression
@@ -337,7 +364,10 @@ export const restoreMarkersFromManifest = (rootElement, subtree, fullManifest =
337
364
  let endComment = null;
338
365
  let depth = 0;
339
366
 
340
- const walker = document.createTreeWalker(element, NodeFilter.SHOW_COMMENT);
367
+ const walker = document.createTreeWalker(
368
+ element,
369
+ NodeFilter.SHOW_COMMENT,
370
+ );
341
371
  while (walker.nextNode()) {
342
372
  const comment = walker.currentNode;
343
373
  const trimmed = comment.textContent.trim();
@@ -351,9 +381,9 @@ export const restoreMarkersFromManifest = (rootElement, subtree, fullManifest =
351
381
  startComment = comment;
352
382
  depth = 1;
353
383
  } else if (startComment) {
354
- if (trimmed.startsWith('each ')) {
384
+ if (trimmed.startsWith("each ")) {
355
385
  depth++;
356
- } else if (trimmed === '/each') {
386
+ } else if (trimmed === "/each") {
357
387
  depth--;
358
388
  if (depth === 0) {
359
389
  endComment = comment;
@@ -363,6 +393,10 @@ export const restoreMarkersFromManifest = (rootElement, subtree, fullManifest =
363
393
  }
364
394
  }
365
395
 
396
+ // Always delete the iteration node from tree - runtime will re-create it
397
+ // Do this even if comments weren't found (they might have been removed by parent restoration)
398
+ delete tree.children[key];
399
+
366
400
  if (startComment && endComment) {
367
401
  // Mark as processed
368
402
  startComment._vibeProcessed = true;
@@ -382,7 +416,7 @@ export const restoreMarkersFromManifest = (rootElement, subtree, fullManifest =
382
416
  }
383
417
 
384
418
  // Insert the template (single item)
385
- const tempContainer = document.createElement('div');
419
+ const tempContainer = document.createElement("div");
386
420
  tempContainer.innerHTML = restoration.template;
387
421
 
388
422
  const fragment = document.createDocumentFragment();
@@ -392,30 +426,35 @@ export const restoreMarkersFromManifest = (rootElement, subtree, fullManifest =
392
426
  parent.insertBefore(fragment, endComment);
393
427
  }
394
428
 
395
- // Delete the iteration node from tree - runtime will re-create it
396
- delete tree.children[key];
397
429
  continue;
398
430
  }
399
431
 
400
- if (childTree.type === 'conditional' && childTree.compiled?.restoration) {
432
+ if (
433
+ childTree.type === "conditional" &&
434
+ childTree.compiled?.restoration
435
+ ) {
401
436
  const restoration = childTree.compiled.restoration;
402
437
 
403
438
  // Find conditional comment markers in the current element
404
439
  // But SKIP conditionals that are inside iteration blocks
405
- const walker = document.createTreeWalker(element, NodeFilter.SHOW_COMMENT);
440
+ const walker = document.createTreeWalker(
441
+ element,
442
+ NodeFilter.SHOW_COMMENT,
443
+ );
406
444
  let startComment = null;
407
445
  let endComment = null;
408
446
  let insideIteration = false;
447
+ let conditionalDepth = 0;
409
448
 
410
449
  while (walker.nextNode()) {
411
450
  const comment = walker.currentNode;
412
451
  const text = comment.textContent.trim();
413
452
 
414
453
  // Track if we're inside an iteration block
415
- if (text.startsWith('each ')) {
454
+ if (text.startsWith("each ")) {
416
455
  insideIteration = true;
417
456
  continue;
418
- } else if (text === '/each') {
457
+ } else if (text === "/each") {
419
458
  insideIteration = false;
420
459
  continue;
421
460
  }
@@ -426,14 +465,31 @@ export const restoreMarkersFromManifest = (rootElement, subtree, fullManifest =
426
465
  // Skip already processed conditionals
427
466
  if (comment._vibeProcessed) continue;
428
467
 
429
- if (text.startsWith('if') && !startComment) {
430
- startComment = comment;
431
- } else if (text === '/if' && startComment && !endComment) {
432
- endComment = comment;
433
- break;
468
+ if (text.startsWith("if")) {
469
+ if (!startComment) {
470
+ const expression = childTree.meta?.expression;
471
+ const expectedText = expression ? 'if ' + expression : null;
472
+ if (expectedText && text === expectedText) {
473
+ startComment = comment;
474
+ conditionalDepth = 1;
475
+ }
476
+ } else {
477
+ // Track nested conditionals
478
+ conditionalDepth++;
479
+ }
480
+ } else if (text === "/if" && startComment) {
481
+ conditionalDepth--;
482
+ if (conditionalDepth === 0) {
483
+ endComment = comment;
484
+ break;
485
+ }
434
486
  }
435
487
  }
436
488
 
489
+ // Always delete the conditional node from tree - runtime will re-create it
490
+ // Do this even if comments weren't found (they might have been removed by parent restoration)
491
+ delete tree.children[key];
492
+
437
493
  if (startComment && endComment) {
438
494
  // Mark as processed
439
495
  startComment._vibeProcessed = true;
@@ -444,12 +500,12 @@ export const restoreMarkersFromManifest = (rootElement, subtree, fullManifest =
444
500
  let current = startComment.nextSibling;
445
501
  while (current && current !== endComment) {
446
502
  const next = current.nextSibling;
447
- current.remove(); // Remove ALL nodes, including comment nodes
503
+ current.remove(); // Remove ALL nodes, including comment nodes
448
504
  current = next;
449
505
  }
450
506
 
451
507
  // Insert the template content BEFORE endComment
452
- const tempContainer = document.createElement('div');
508
+ const tempContainer = document.createElement("div");
453
509
  tempContainer.innerHTML = restoration.template;
454
510
 
455
511
  const fragment = document.createDocumentFragment();
@@ -458,10 +514,6 @@ export const restoreMarkersFromManifest = (rootElement, subtree, fullManifest =
458
514
  }
459
515
  endComment.parentNode.insertBefore(fragment, endComment);
460
516
  }
461
-
462
- // Delete restoration data AND remove the conditional node from tree
463
- // Runtime will re-create it from the restored DOM
464
- delete tree.children[key];
465
517
  }
466
518
  }
467
519
  }
@@ -472,28 +524,33 @@ export const restoreMarkersFromManifest = (rootElement, subtree, fullManifest =
472
524
  const childTree = tree.children[key];
473
525
 
474
526
  // Skip conditionals and iterations (already processed/deleted above)
475
- if (childTree.type === 'conditional' || childTree.type === 'iteration') {
527
+ if (
528
+ childTree.type === "conditional" ||
529
+ childTree.type === "iteration"
530
+ ) {
476
531
  continue;
477
532
  }
478
533
 
479
534
  // Skip text nodes - already handled before conditional/iteration processing
480
- if (key.startsWith('text_')) {
535
+ if (key.startsWith("text_")) {
481
536
  continue;
482
537
  }
483
538
 
484
539
  // Find corresponding child element by tag name and index
485
540
  // Keys like "layout_1" mean the node at childNodes index 1 (includes text nodes)
486
- // NOT the second layout element
487
541
  // Tag names can contain digits (h1, h2, etc.) so use [a-z0-9-]+
488
542
  const match = key.match(/^([a-z0-9-]+)_(\d+)$/);
489
543
  if (match) {
490
544
  const tagName = match[1].toUpperCase();
491
545
  const nodeIndex = parseInt(match[2], 10);
492
546
 
493
- // Get the node at this childNodes index
494
547
  const childNode = element.childNodes[nodeIndex];
495
548
 
496
- if (childNode && childNode.nodeType === Node.ELEMENT_NODE && childNode.nodeName === tagName) {
549
+ if (
550
+ childNode &&
551
+ childNode.nodeType === Node.ELEMENT_NODE &&
552
+ childNode.nodeName === tagName
553
+ ) {
497
554
  walkTree(childTree, childNode);
498
555
  }
499
556
  }