@ape-egg/vibe 1.8.0 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +88 -0
- package/README.md +28 -0
- package/boot.js +5 -1
- package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
- package/compiler/native/vibe-compiler-linux-x64 +0 -0
- package/compiler/src/parser/html.rs +15 -15
- package/index.js +15 -0
- package/package.json +2 -2
- package/runtime/_vibe-compiled-iteration-batch.js +18 -9
- package/runtime/affected.js +36 -16
- package/runtime/component.js +154 -45
- package/runtime/conditionals.js +12 -1
- package/runtime/constants.js +5 -3
- package/runtime/index.js +77 -33
- package/runtime/iterate.js +270 -108
- package/runtime/manifest.js +2 -1
- package/runtime/parse.js +66 -80
- package/runtime/pre-compiled-manifest.js +38 -13
- package/runtime/reconcile.js +621 -0
- package/runtime/state.js +5 -2
- package/runtime/utils.js +74 -1
- package/vibe.css +3 -1
package/runtime/iterate.js
CHANGED
|
@@ -2,17 +2,125 @@ import parse from './parse.js';
|
|
|
2
2
|
import affected from './affected.js';
|
|
3
3
|
import hydrate from './hydrate.js';
|
|
4
4
|
import { resolvePath, getItemKey, computeDiff } from './iteration-utils.js';
|
|
5
|
-
import { resolveThisPath } from './utils.js';
|
|
5
|
+
import { resolveThisPath, evalInScope } from './utils.js';
|
|
6
6
|
import { managedNodes } from './conditionals.js';
|
|
7
|
-
|
|
8
|
-
// Fast path for iteration rendering (opt-in via window.__VIBE_FAST_ITERATION__)
|
|
9
|
-
// See: _vibe-compiled-iteration-batch.js for implementation details
|
|
10
|
-
// This is a preview of what Vibe Compiled (Phase 2) will do automatically
|
|
11
|
-
import * as fastPath from './_vibe-compiled-iteration-batch.js';
|
|
7
|
+
import { BINDING_REGEX } from './constants.js';
|
|
12
8
|
|
|
13
9
|
// Pre-compiled iteration optimization (production)
|
|
14
10
|
import * as compiled from './pre-compiled-iterations.js';
|
|
15
11
|
|
|
12
|
+
// Runtime batch-render helpers for full-replacement of simple templates.
|
|
13
|
+
// Build an HTML string via template-literal compilation, then parse once —
|
|
14
|
+
// avoids per-item clone/parse/hydrate in the hot path.
|
|
15
|
+
// Only used for templates without nested <!-- each --> / <!-- if -->.
|
|
16
|
+
const batchParseTemplate = document.createElement('template');
|
|
17
|
+
|
|
18
|
+
// innerHTML serialization encodes <, >, &, ", ' inside attribute values.
|
|
19
|
+
// Decode them back before wrapping @[expr] in ${...} for the template literal.
|
|
20
|
+
const decodeEntities = (s) => s
|
|
21
|
+
.replace(/</g, '<')
|
|
22
|
+
.replace(/>/g, '>')
|
|
23
|
+
.replace(/"/g, '"')
|
|
24
|
+
.replace(/'/g, "'")
|
|
25
|
+
.replace(/&/g, '&');
|
|
26
|
+
|
|
27
|
+
const hasNestedStructures = (tree) => {
|
|
28
|
+
if (!tree || !tree.children) return false;
|
|
29
|
+
for (const key in tree.children) {
|
|
30
|
+
const child = tree.children[key];
|
|
31
|
+
if (!child) continue;
|
|
32
|
+
if (child.type === 'iteration' || child.type === 'conditional') return true;
|
|
33
|
+
if (hasNestedStructures(child)) return true;
|
|
34
|
+
}
|
|
35
|
+
return false;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const canUseBatchRender = (template) =>
|
|
39
|
+
!hasNestedStructures(template) && template.element.children.length <= 1;
|
|
40
|
+
|
|
41
|
+
const compileBatchFn = (template, itemAlias, indexAlias, stateKeys) => {
|
|
42
|
+
const templateHtml = template.element.innerHTML.trim();
|
|
43
|
+
const escaped = templateHtml.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
|
|
44
|
+
const code = escaped.replace(BINDING_REGEX, (_, expr) => '${' + decodeEntities(expr) + '}');
|
|
45
|
+
|
|
46
|
+
return new Function(
|
|
47
|
+
'arr',
|
|
48
|
+
...stateKeys,
|
|
49
|
+
`
|
|
50
|
+
let html = '';
|
|
51
|
+
const len = arr.length;
|
|
52
|
+
for (let ${indexAlias} = 0; ${indexAlias} < len; ${indexAlias}++) {
|
|
53
|
+
const ${itemAlias} = arr[${indexAlias}];
|
|
54
|
+
html += \`${code}\`;
|
|
55
|
+
}
|
|
56
|
+
return html;
|
|
57
|
+
`,
|
|
58
|
+
);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// For <component src> elements inside an iteration instance, evaluate any
|
|
62
|
+
// `@[expr]` attribute bindings against the iteration's scoped state and replace
|
|
63
|
+
// them with the resolved literal value. Component[src] attributes intentionally
|
|
64
|
+
// bypass hydrate (parse.js) so they reach processComponent as bindings — but
|
|
65
|
+
// bindings that depend on iteration-local vars (item, index) can't resolve later
|
|
66
|
+
// when processComponent inlines the component, since by then iteration scope is gone.
|
|
67
|
+
// Only called from iteration code paths; conditionals don't need this because their
|
|
68
|
+
// branch content is registered in the global manifest and reacts to state updates.
|
|
69
|
+
const resolveIterationComponentProps = (nodes, scopedState) => {
|
|
70
|
+
for (let n = 0; n < nodes.length; n++) {
|
|
71
|
+
const node = nodes[n];
|
|
72
|
+
if (node.nodeType !== 1) continue;
|
|
73
|
+
const components = node.matches?.('component[src], div.component[src]')
|
|
74
|
+
? [node, ...node.querySelectorAll('component[src], div.component[src]')]
|
|
75
|
+
: [...node.querySelectorAll('component[src], div.component[src]')];
|
|
76
|
+
for (let i = 0; i < components.length; i++) {
|
|
77
|
+
const el = components[i];
|
|
78
|
+
const attrs = el.attributes;
|
|
79
|
+
for (let j = 0; j < attrs.length; j++) {
|
|
80
|
+
const attr = attrs[j];
|
|
81
|
+
if (attr.name === 'src') continue;
|
|
82
|
+
const match = attr.value.match(/^@\[(.+)\]$/);
|
|
83
|
+
if (!match) continue;
|
|
84
|
+
try {
|
|
85
|
+
const value = evalInScope(match[1], scopedState, el);
|
|
86
|
+
if (value !== undefined) {
|
|
87
|
+
el.setAttribute(attr.name, String(value));
|
|
88
|
+
}
|
|
89
|
+
} catch {
|
|
90
|
+
// Leave binding raw — processComponent will handle it as a binding
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const renderBatch = (iterationNode, array, state, parent, endComment) => {
|
|
98
|
+
const { itemAlias, indexAlias, template } = iterationNode.meta;
|
|
99
|
+
|
|
100
|
+
if (!iterationNode.runtime.batchFn) {
|
|
101
|
+
const stateKeys = Object.keys(state);
|
|
102
|
+
iterationNode.runtime.batchFn = compileBatchFn(template, itemAlias, indexAlias, stateKeys);
|
|
103
|
+
iterationNode.runtime.stateKeys = stateKeys;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const { batchFn, stateKeys } = iterationNode.runtime;
|
|
107
|
+
const stateValues = stateKeys.map((k) => state[k]);
|
|
108
|
+
const html = batchFn(array, ...stateValues);
|
|
109
|
+
|
|
110
|
+
batchParseTemplate.innerHTML = html;
|
|
111
|
+
const frag = batchParseTemplate.content;
|
|
112
|
+
const kids = frag.children;
|
|
113
|
+
|
|
114
|
+
const arrayLen = array.length;
|
|
115
|
+
const instances = new Array(arrayLen);
|
|
116
|
+
for (let i = 0; i < arrayLen; i++) {
|
|
117
|
+
instances[i] = { element: kids[i], item: array[i], index: i };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
parent.insertBefore(frag, endComment);
|
|
121
|
+
iterationNode.runtime.instances = instances;
|
|
122
|
+
};
|
|
123
|
+
|
|
16
124
|
/**
|
|
17
125
|
* Find a comment node with matching text content in the given nodes.
|
|
18
126
|
*/
|
|
@@ -159,6 +267,18 @@ export const initializeBlock = (templateNodes, scopedState, cachedTree = null) =
|
|
|
159
267
|
}
|
|
160
268
|
}
|
|
161
269
|
|
|
270
|
+
// Preserve raw slot content of nested <component src> elements before renderAllConditionals /
|
|
271
|
+
// renderAllIterations runs on this branch/iteration instance — those paths strip <!-- if -->
|
|
272
|
+
// and <!-- each --> templates from the live DOM, so by the time processComponent reads
|
|
273
|
+
// el.innerHTML (next microtask, when MutationObserver fires) the inactive branch templates
|
|
274
|
+
// would be gone. cloneNode(true) does not copy expando JS properties, so we must (re)capture
|
|
275
|
+
// _vibeSlotContent on every clone.
|
|
276
|
+
const components = parseContainer.querySelectorAll('component[src], div.component[src]');
|
|
277
|
+
for (let i = 0; i < components.length; i++) {
|
|
278
|
+
const el = components[i];
|
|
279
|
+
if (el._vibeSlotContent === undefined) el._vibeSlotContent = el.innerHTML;
|
|
280
|
+
}
|
|
281
|
+
|
|
162
282
|
if (useCachedTree) {
|
|
163
283
|
// Fast path: map cached tree structure onto cloned DOM (no regex, no DOM walking)
|
|
164
284
|
tree = cloneTreeWithElements(cachedTree, parseContainer);
|
|
@@ -313,10 +433,12 @@ export const renderIteration = (iterationNode, state, manifest, parentScope = {}
|
|
|
313
433
|
|
|
314
434
|
const parent = startComment.parentNode;
|
|
315
435
|
|
|
316
|
-
//
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
436
|
+
// Evaluate the array expression — supports state paths (items),
|
|
437
|
+
// window globals (window.fights), method calls (items.filter(...)),
|
|
438
|
+
// and inline literals (['a', 'b']). Falls back to resolvePath for
|
|
439
|
+
// simple paths that evalInScope might miss in scoped contexts.
|
|
440
|
+
const resolvedExpr = resolveThisPath(arrayPath, startComment.parentElement);
|
|
441
|
+
const array = evalInScope(resolvedExpr, state, startComment.parentElement) ?? resolvePath(state, resolvedExpr);
|
|
320
442
|
if (!Array.isArray(array) || array.length === 0) {
|
|
321
443
|
iterationNode.runtime.instances = [];
|
|
322
444
|
return;
|
|
@@ -347,6 +469,9 @@ export const renderIteration = (iterationNode, state, manifest, parentScope = {}
|
|
|
347
469
|
// Clone, parse, hydrate
|
|
348
470
|
const { element, tree, clonedNodes } = initializeBlock(templateNodes, scopedState, template);
|
|
349
471
|
|
|
472
|
+
// Pre-resolve <component src> binding props against iteration scope (see helper comment)
|
|
473
|
+
resolveIterationComponentProps(clonedNodes, scopedState);
|
|
474
|
+
|
|
350
475
|
// Collect nodes in DocumentFragment (single DOM insertion at end)
|
|
351
476
|
for (let j = 0; j < clonedNodes.length; j++) {
|
|
352
477
|
frag.appendChild(clonedNodes[j]);
|
|
@@ -381,11 +506,10 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
|
|
|
381
506
|
|
|
382
507
|
const { arrayPath, template, startComment, endComment } = iterationNode.meta;
|
|
383
508
|
|
|
384
|
-
|
|
385
|
-
const resolvedArrayPath = resolveThisPath(arrayPath, startComment.parentElement);
|
|
509
|
+
const resolvedExpr = resolveThisPath(arrayPath, startComment.parentElement);
|
|
386
510
|
|
|
387
|
-
const stateOldArray = resolvePath(oldState,
|
|
388
|
-
const newArray = resolvePath(newState,
|
|
511
|
+
const stateOldArray = evalInScope(resolvedExpr, oldState, startComment.parentElement) ?? resolvePath(oldState, resolvedExpr) ?? [];
|
|
512
|
+
const newArray = evalInScope(resolvedExpr, newState, startComment.parentElement) ?? resolvePath(newState, resolvedExpr) ?? [];
|
|
389
513
|
|
|
390
514
|
// Use instances (what's actually rendered) as ground truth for old array
|
|
391
515
|
// when oldState disagrees with the rendered count.
|
|
@@ -457,7 +581,7 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
|
|
|
457
581
|
// Bulk replacement: clear all DOM and re-render from scratch
|
|
458
582
|
// Used when arrays share no common keys (avoids O(n²) LCS)
|
|
459
583
|
const bulkReplace = (iterationNode, newArray, state, manifest, parentScope) => {
|
|
460
|
-
const {
|
|
584
|
+
const { template, startComment, endComment } = iterationNode.meta;
|
|
461
585
|
const parent = startComment.parentNode;
|
|
462
586
|
|
|
463
587
|
// Clear all existing DOM between comments using Range (single operation)
|
|
@@ -475,8 +599,8 @@ const bulkReplace = (iterationNode, newArray, state, manifest, parentScope) => {
|
|
|
475
599
|
|
|
476
600
|
// For simple templates (no nested iterations/conditionals, single root element),
|
|
477
601
|
// use batch string rendering: one string concatenation loop + one innerHTML parse
|
|
478
|
-
if (
|
|
479
|
-
|
|
602
|
+
if (canUseBatchRender(template)) {
|
|
603
|
+
renderBatch(iterationNode, newArray, state, parent, endComment);
|
|
480
604
|
const instances = iterationNode.runtime.instances;
|
|
481
605
|
for (let i = 0; i < instances.length; i++) {
|
|
482
606
|
if (instances[i].element?.nodeType === 1) managedNodes.add(instances[i].element);
|
|
@@ -484,135 +608,173 @@ const bulkReplace = (iterationNode, newArray, state, manifest, parentScope) => {
|
|
|
484
608
|
return;
|
|
485
609
|
}
|
|
486
610
|
|
|
487
|
-
// Complex templates:
|
|
611
|
+
// Complex templates: build each instance, batch-append into a DocumentFragment,
|
|
612
|
+
// finalize (mark managed + render nested), then commit to the DOM in one
|
|
613
|
+
// parent.insertBefore call.
|
|
488
614
|
const instances = [];
|
|
489
|
-
const templateNodes = template.element.childNodes;
|
|
490
615
|
const frag = document.createDocumentFragment();
|
|
491
|
-
|
|
492
616
|
for (let i = 0; i < newArray.length; i++) {
|
|
493
|
-
const
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
if (clonedNodes[j].nodeType === 1) managedNodes.add(clonedNodes[j]);
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
if (tree) {
|
|
504
|
-
const nestedScope = { ...parentScope, ...localVars };
|
|
505
|
-
renderAllIterations(tree, scopedState, manifest, nestedScope);
|
|
506
|
-
_renderAllConditionals(tree, scopedState, manifest, nestedScope);
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
instances.push({ element, tree, item, index: i, clonedNodes, scopedState });
|
|
617
|
+
const built = buildInstance(iterationNode, newArray[i], i, state, parentScope);
|
|
618
|
+
for (let j = 0; j < built.clonedNodes.length; j++) frag.appendChild(built.clonedNodes[j]);
|
|
619
|
+
finalizeInstance(built, manifest, parentScope);
|
|
620
|
+
instances.push({
|
|
621
|
+
element: built.element, tree: built.tree, item: newArray[i], index: i,
|
|
622
|
+
clonedNodes: built.clonedNodes, scopedState: built.scopedState,
|
|
623
|
+
});
|
|
510
624
|
}
|
|
511
|
-
|
|
512
625
|
parent.insertBefore(frag, endComment);
|
|
513
626
|
iterationNode.runtime.instances = instances;
|
|
514
627
|
};
|
|
515
628
|
|
|
516
|
-
//
|
|
517
|
-
|
|
518
|
-
|
|
629
|
+
// Find an instance's canonical in-DOM anchor (the first of its cloned nodes
|
|
630
|
+
// that still lives directly under the iteration's parent). Nested primitives
|
|
631
|
+
// inside the iteration template — <!-- if -->, <!-- each -->, <component> —
|
|
632
|
+
// can move/replace cloned nodes between iteration renders (inactive branches
|
|
633
|
+
// get hoisted into template containers; component[src] wrappers get swapped
|
|
634
|
+
// for processed wrappers). Any of those mutations make `clonedNodes[0]` a
|
|
635
|
+
// stale reference to a node no longer under the iteration parent. Callers use
|
|
636
|
+
// this anchor instead of trusting `clonedNodes[0]` directly, so that
|
|
637
|
+
// insert-before / move operations always resolve against the iteration's real
|
|
638
|
+
// DOM slot.
|
|
639
|
+
const findInstanceAnchor = (instance, iterationParent) => {
|
|
640
|
+
const nodes = instance.clonedNodes || (instance.element ? [instance.element] : []);
|
|
641
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
642
|
+
if (nodes[i]?.parentNode === iterationParent) return nodes[i];
|
|
643
|
+
}
|
|
644
|
+
return null;
|
|
645
|
+
};
|
|
519
646
|
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
647
|
+
// Resolve the reference node for `parent.insertBefore(..., ref)` at a given
|
|
648
|
+
// logical iteration index. Walks later instances until it finds one with a
|
|
649
|
+
// live anchor under the iteration parent; falls back to `endComment` when no
|
|
650
|
+
// later instance has any node currently mounted in the iteration.
|
|
651
|
+
const resolveInsertBefore = (iterationNode, index, parent) => {
|
|
652
|
+
const { instances } = iterationNode.runtime;
|
|
653
|
+
for (let i = index; i < instances.length; i++) {
|
|
654
|
+
const anchor = findInstanceAnchor(instances[i], parent);
|
|
655
|
+
if (anchor) return anchor;
|
|
656
|
+
}
|
|
657
|
+
return iterationNode.meta.endComment;
|
|
658
|
+
};
|
|
523
659
|
|
|
524
|
-
|
|
660
|
+
// Detach every DOM node belonging to a logical instance, including content
|
|
661
|
+
// mounted by nested primitives (conditional branches, nested each rows,
|
|
662
|
+
// fetched component wrappers) that isn't tracked in `instance.clonedNodes`.
|
|
663
|
+
// Walks iteration-parent siblings from this instance's anchor up to the next
|
|
664
|
+
// instance's anchor / endComment, so anything in between — clones, mounted
|
|
665
|
+
// branches, swapped-in component wrappers — all gets detached. Also sweeps
|
|
666
|
+
// any clonedNodes that were hoisted out of the iteration parent (e.g. into a
|
|
667
|
+
// sibling conditional's template container).
|
|
668
|
+
const detachInstanceDom = (iterationNode, index, parent) => {
|
|
669
|
+
const instance = iterationNode.runtime.instances[index];
|
|
670
|
+
const { endComment } = iterationNode.meta;
|
|
671
|
+
const anchor = findInstanceAnchor(instance, parent);
|
|
672
|
+
const nextAnchor = resolveInsertBefore(iterationNode, index + 1, parent);
|
|
673
|
+
|
|
674
|
+
if (anchor) {
|
|
675
|
+
let cur = anchor;
|
|
676
|
+
// endComment caps the walk even if nextAnchor ordering is ever
|
|
677
|
+
// corrupted — iteration DOM is bounded by startComment / endComment.
|
|
678
|
+
while (cur && cur !== nextAnchor && cur !== endComment) {
|
|
679
|
+
const nextSibling = cur.nextSibling;
|
|
680
|
+
parent.removeChild(cur);
|
|
681
|
+
cur = nextSibling;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
525
684
|
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
685
|
+
const nodes = instance.clonedNodes || (instance.element ? [instance.element] : []);
|
|
686
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
687
|
+
const n = nodes[i];
|
|
688
|
+
if (n && n.parentNode && n.parentNode !== parent) n.parentNode.removeChild(n);
|
|
689
|
+
}
|
|
690
|
+
};
|
|
532
691
|
|
|
533
|
-
|
|
534
|
-
|
|
692
|
+
// Build a fresh instance's DOM + tree + scope from the iteration template.
|
|
693
|
+
// Pure function — no DOM insertion, no side effects on iteration state.
|
|
694
|
+
// Callers decide where the clones go (iteration parent, DocumentFragment).
|
|
695
|
+
const buildInstance = (iterationNode, item, index, state, parentScope) => {
|
|
696
|
+
const { itemAlias, indexAlias, template } = iterationNode.meta;
|
|
697
|
+
const localVars = { [itemAlias]: item, [indexAlias]: index };
|
|
698
|
+
const scopedState = createScopedState(state, localVars, parentScope);
|
|
699
|
+
const built = initializeBlock([...template.element.childNodes], scopedState, template);
|
|
700
|
+
resolveIterationComponentProps(built.clonedNodes, scopedState);
|
|
701
|
+
return { ...built, scopedState, localVars };
|
|
702
|
+
};
|
|
535
703
|
|
|
536
|
-
|
|
704
|
+
// After a built instance's clones are placed in the DOM (directly or via a
|
|
705
|
+
// fragment), mark element clones as managed so the page-level MutationObserver
|
|
706
|
+
// skips them in processMutations, then fire nested iteration/conditional
|
|
707
|
+
// renders. Without the managed mark those clones would be re-parsed + hydrated
|
|
708
|
+
// on top of the internal render, duplicating every nested branch.
|
|
709
|
+
const finalizeInstance = (built, manifest, parentScope) => {
|
|
710
|
+
const { clonedNodes, tree, scopedState, localVars } = built;
|
|
711
|
+
for (let i = 0; i < clonedNodes.length; i++) {
|
|
712
|
+
if (clonedNodes[i].nodeType === 1) managedNodes.add(clonedNodes[i]);
|
|
713
|
+
}
|
|
537
714
|
if (tree) {
|
|
538
715
|
const nestedScope = { ...parentScope, ...localVars };
|
|
539
716
|
renderAllIterations(tree, scopedState, manifest, nestedScope);
|
|
540
717
|
_renderAllConditionals(tree, scopedState, manifest, nestedScope);
|
|
541
718
|
}
|
|
719
|
+
};
|
|
542
720
|
|
|
543
|
-
|
|
721
|
+
const addInstance = (iterationNode, item, index, state, manifest, parentScope) => {
|
|
722
|
+
const parent = iterationNode.meta.startComment.parentNode;
|
|
723
|
+
const built = buildInstance(iterationNode, item, index, state, parentScope);
|
|
724
|
+
const insertBefore = resolveInsertBefore(iterationNode, index, parent);
|
|
725
|
+
for (let i = 0; i < built.clonedNodes.length; i++) parent.insertBefore(built.clonedNodes[i], insertBefore);
|
|
726
|
+
finalizeInstance(built, manifest, parentScope);
|
|
727
|
+
iterationNode.runtime.instances.splice(index, 0, {
|
|
728
|
+
element: built.element, tree: built.tree, item, index, clonedNodes: built.clonedNodes,
|
|
729
|
+
});
|
|
544
730
|
};
|
|
545
731
|
|
|
546
|
-
// Remove an instance at the specified index
|
|
547
732
|
const removeInstance = (iterationNode, index) => {
|
|
548
733
|
if (index < 0 || index >= iterationNode.runtime.instances.length) return;
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
// Remove all cloned nodes from DOM
|
|
553
|
-
(instance.clonedNodes || [instance.element]).forEach((node) =>
|
|
554
|
-
node?.parentNode?.removeChild(node),
|
|
555
|
-
);
|
|
556
|
-
|
|
734
|
+
const parent = iterationNode.meta.startComment.parentNode;
|
|
735
|
+
if (parent) detachInstanceDom(iterationNode, index, parent);
|
|
557
736
|
iterationNode.runtime.instances.splice(index, 1);
|
|
558
737
|
};
|
|
559
738
|
|
|
560
|
-
// Move an instance from one position to another
|
|
561
739
|
const moveInstance = (iterationNode, fromIndex, toIndex) => {
|
|
562
740
|
if (fromIndex === toIndex) return;
|
|
563
|
-
|
|
564
|
-
if (
|
|
565
|
-
|
|
566
|
-
const
|
|
567
|
-
const nodes = instance.clonedNodes || [instance.element];
|
|
568
|
-
const parent = nodes[0]?.parentNode;
|
|
569
|
-
|
|
570
|
-
iterationNode.runtime.instances.splice(fromIndex, 1);
|
|
571
|
-
iterationNode.runtime.instances.splice(toIndex, 0, instance);
|
|
741
|
+
const { instances } = iterationNode.runtime;
|
|
742
|
+
if (fromIndex < 0 || fromIndex >= instances.length) return;
|
|
743
|
+
if (toIndex < 0 || toIndex >= instances.length) return;
|
|
744
|
+
const parent = iterationNode.meta.startComment.parentNode;
|
|
572
745
|
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
? nextInstance.clonedNodes?.[0] || nextInstance.element
|
|
577
|
-
: iterationNode.meta.endComment;
|
|
746
|
+
const instance = instances[fromIndex];
|
|
747
|
+
instances.splice(fromIndex, 1);
|
|
748
|
+
instances.splice(toIndex, 0, instance);
|
|
578
749
|
|
|
579
|
-
|
|
580
|
-
nodes.
|
|
750
|
+
const insertBefore = resolveInsertBefore(iterationNode, toIndex + 1, parent);
|
|
751
|
+
const nodes = instance.clonedNodes || [instance.element];
|
|
752
|
+
// Re-insert only nodes currently under the iteration parent — those hoisted
|
|
753
|
+
// into nested-conditional template containers stay there so we don't
|
|
754
|
+
// double-count branch content.
|
|
755
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
756
|
+
const n = nodes[i];
|
|
757
|
+
if (n?.parentNode === parent) parent.insertBefore(n, insertBefore);
|
|
758
|
+
}
|
|
581
759
|
};
|
|
582
760
|
|
|
583
|
-
// Update an instance with new item data
|
|
584
761
|
const updateInstance = (iterationNode, index, newItem, state, manifest, parentScope = {}) => {
|
|
585
762
|
if (index < 0 || index >= iterationNode.runtime.instances.length) return;
|
|
586
|
-
|
|
587
|
-
const { itemAlias, indexAlias, template } = iterationNode.meta;
|
|
763
|
+
const parent = iterationNode.meta.startComment.parentNode;
|
|
588
764
|
const instance = iterationNode.runtime.instances[index];
|
|
589
765
|
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
const
|
|
593
|
-
|
|
594
|
-
const
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
const oldNodes = instance.clonedNodes || [instance.element];
|
|
598
|
-
const insertBefore = oldNodes[oldNodes.length - 1]?.nextSibling;
|
|
599
|
-
const parent = oldNodes[0]?.parentNode;
|
|
600
|
-
|
|
601
|
-
oldNodes.forEach((node) => node?.parentNode?.removeChild(node));
|
|
602
|
-
clonedNodes.forEach((node) => parent.insertBefore(node, insertBefore));
|
|
603
|
-
|
|
604
|
-
// Recursively render nested iterations and conditionals
|
|
605
|
-
if (tree) {
|
|
606
|
-
const nestedScope = { ...parentScope, ...localVars };
|
|
607
|
-
renderAllIterations(tree, scopedState, manifest, nestedScope);
|
|
608
|
-
_renderAllConditionals(tree, scopedState, manifest, nestedScope);
|
|
609
|
-
}
|
|
766
|
+
// Build fresh first, then detach old — keeps the old DOM as a stable
|
|
767
|
+
// anchor reference until we know how the new nodes are shaped.
|
|
768
|
+
const built = buildInstance(iterationNode, newItem, index, state, parentScope);
|
|
769
|
+
detachInstanceDom(iterationNode, index, parent);
|
|
770
|
+
const insertBefore = resolveInsertBefore(iterationNode, index + 1, parent);
|
|
771
|
+
for (let i = 0; i < built.clonedNodes.length; i++) parent.insertBefore(built.clonedNodes[i], insertBefore);
|
|
772
|
+
finalizeInstance(built, manifest, parentScope);
|
|
610
773
|
|
|
611
|
-
|
|
612
|
-
instance.
|
|
613
|
-
instance.tree = tree;
|
|
774
|
+
instance.element = built.element;
|
|
775
|
+
instance.tree = built.tree;
|
|
614
776
|
instance.item = newItem;
|
|
615
|
-
instance.clonedNodes = clonedNodes;
|
|
777
|
+
instance.clonedNodes = built.clonedNodes;
|
|
616
778
|
};
|
|
617
779
|
|
|
618
780
|
export default {
|
package/runtime/manifest.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const recursive = (tree, results, tagChain) => {
|
|
2
|
-
|
|
2
|
+
const path = tagChain.join('.');
|
|
3
|
+
results[path] = tree.element;
|
|
3
4
|
|
|
4
5
|
if (tree.children && Object.keys(tree.children).length > 0) {
|
|
5
6
|
Object.keys(tree.children).forEach((tag) => {
|
package/runtime/parse.js
CHANGED
|
@@ -8,6 +8,66 @@ import {
|
|
|
8
8
|
DEHYDRATE_CLASS_OR_ATTR,
|
|
9
9
|
} from './constants.js';
|
|
10
10
|
|
|
11
|
+
// Walks up the DOM for the nearest component wrapper tagged by component.js.
|
|
12
|
+
// Used to rewrite `this.property` in event handlers to the component's state path.
|
|
13
|
+
const findComponentIdForElement = (element) => {
|
|
14
|
+
if (!element?.closest) return null;
|
|
15
|
+
const wrapper = element.closest('[data-vibe-component-id]');
|
|
16
|
+
return wrapper ? wrapper.getAttribute('data-vibe-component-id') : null;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// Single source of truth for reading attribute/name bindings off an element.
|
|
20
|
+
// Called from both the root handler and recursive() so they can't drift. Any
|
|
21
|
+
// element classified as a fetched component (`<component src>` or
|
|
22
|
+
// `<div class="component" src>`) returns nulls — its attributes are props
|
|
23
|
+
// owned by processComponent and must stay raw; hydrating them would coerce
|
|
24
|
+
// objects to "[object Object]" or strip boolean-like attrs to empty.
|
|
25
|
+
const captureAttributeBindings = (element) => {
|
|
26
|
+
const nodeName = element.nodeName;
|
|
27
|
+
const isFetchedComponent =
|
|
28
|
+
(nodeName === 'COMPONENT' || (nodeName === 'DIV' && element.classList?.contains('component'))) &&
|
|
29
|
+
element.hasAttribute?.('src');
|
|
30
|
+
|
|
31
|
+
if (isFetchedComponent || !element.attributes || element.attributes.length === 0) {
|
|
32
|
+
return { attributes: null, nameBindings: null };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const attributes = {};
|
|
36
|
+
const nameBindings = [];
|
|
37
|
+
|
|
38
|
+
for (let j = 0; j < element.attributes.length; j++) {
|
|
39
|
+
const attr = element.attributes[j];
|
|
40
|
+
BINDING_REGEX.lastIndex = 0;
|
|
41
|
+
|
|
42
|
+
// Attribute name itself contains a binding (e.g. <icon @[section.icon]>).
|
|
43
|
+
if (BINDING_REGEX.test(attr.name)) {
|
|
44
|
+
nameBindings.push(attr.name);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Rewrite `this.property` inside event handlers to the component's state path.
|
|
49
|
+
if (attr.name.startsWith('on') && attr.value.includes('this.')) {
|
|
50
|
+
const componentId = findComponentIdForElement(element);
|
|
51
|
+
if (componentId) {
|
|
52
|
+
const rewritten = attr.value.replace(/\bthis\.(\w+)/g, (match, prop) => {
|
|
53
|
+
return DOM_ELEMENT_PROPERTIES.has(prop) ? match : `$['${componentId}'].${prop}`;
|
|
54
|
+
});
|
|
55
|
+
element.setAttribute(attr.name, rewritten);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
BINDING_REGEX.lastIndex = 0;
|
|
60
|
+
if (BINDING_REGEX.test(attr.value)) {
|
|
61
|
+
attributes[attr.name] = attr.value;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
attributes: Object.keys(attributes).length > 0 ? attributes : null,
|
|
67
|
+
nameBindings: nameBindings.length > 0 ? nameBindings : null,
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
|
|
11
71
|
const parseHTML = (children, rootKey = undefined) =>
|
|
12
72
|
children.reduce((s, element, i) => {
|
|
13
73
|
const { nodeName, textContent } = element;
|
|
@@ -196,59 +256,7 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
|
|
|
196
256
|
const elementForBindings = isTextNode ? element.parentElement : element;
|
|
197
257
|
const textNodeRef = isTextNode ? element : null; // Store reference to actual text node
|
|
198
258
|
|
|
199
|
-
|
|
200
|
-
const attributes = {};
|
|
201
|
-
const nameBindings = [];
|
|
202
|
-
|
|
203
|
-
// Skip hydrating attributes on fetched components - they need to be passed raw
|
|
204
|
-
const isFetchedComponent =
|
|
205
|
-
(nodeName === 'COMPONENT' || (nodeName === 'DIV' && element.classList?.contains('component'))) &&
|
|
206
|
-
element.hasAttribute('src');
|
|
207
|
-
|
|
208
|
-
if (element.attributes && !isFetchedComponent) {
|
|
209
|
-
for (let j = 0; j < element.attributes.length; j++) {
|
|
210
|
-
const attr = element.attributes[j];
|
|
211
|
-
// Reset lastIndex before test - BINDING_REGEX has 'g' flag which persists state
|
|
212
|
-
BINDING_REGEX.lastIndex = 0;
|
|
213
|
-
|
|
214
|
-
// Check if attribute name contains binding (e.g., @[section.icon])
|
|
215
|
-
if (BINDING_REGEX.test(attr.name)) {
|
|
216
|
-
nameBindings.push(attr.name);
|
|
217
|
-
continue; // Don't process as regular attribute
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
// Rewrite event handlers with this. to use component state
|
|
221
|
-
if (attr.name.startsWith('on') && attr.value.includes('this.')) {
|
|
222
|
-
const componentId = findComponentIdForElement(element);
|
|
223
|
-
if (componentId) {
|
|
224
|
-
// Rewrite this.property to $['componentId'].property, but skip DOM properties
|
|
225
|
-
const rewritten = attr.value.replace(/\bthis\.(\w+)/g, (match, prop) => {
|
|
226
|
-
return DOM_ELEMENT_PROPERTIES.has(prop) ? match : `$['${componentId}'].${prop}`;
|
|
227
|
-
});
|
|
228
|
-
element.setAttribute(attr.name, rewritten);
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
// Check if attribute value contains binding
|
|
233
|
-
BINDING_REGEX.lastIndex = 0;
|
|
234
|
-
if (BINDING_REGEX.test(attr.value)) {
|
|
235
|
-
attributes[attr.name] = attr.value;
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
// Helper to find component ID for an element
|
|
241
|
-
// Looks for nearest ancestor with data-vibe-component-id
|
|
242
|
-
function findComponentIdForElement(element) {
|
|
243
|
-
if (!element) return null;
|
|
244
|
-
|
|
245
|
-
// Find nearest component wrapper (tagged by component.js)
|
|
246
|
-
const wrapper = element.closest('[data-vibe-component-id]');
|
|
247
|
-
return wrapper ? wrapper.getAttribute('data-vibe-component-id') : null;
|
|
248
|
-
}
|
|
249
|
-
const hasAttributeBindings = Object.keys(attributes).length > 0;
|
|
250
|
-
const hasNameBindings = nameBindings.length > 0;
|
|
251
|
-
|
|
259
|
+
const { attributes, nameBindings } = captureAttributeBindings(element);
|
|
252
260
|
const hasChildren = childNodes.length;
|
|
253
261
|
|
|
254
262
|
if (hasChildren) {
|
|
@@ -259,8 +267,8 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
|
|
|
259
267
|
parsed,
|
|
260
268
|
element: elementForBindings,
|
|
261
269
|
children: recursive(iteratableChildren, undefined, new Set(), stats),
|
|
262
|
-
...(
|
|
263
|
-
...(
|
|
270
|
+
...(attributes && { attributes }),
|
|
271
|
+
...(nameBindings && { nameBindings }),
|
|
264
272
|
...(textNodeRef && { textNode: textNodeRef }),
|
|
265
273
|
};
|
|
266
274
|
} else {
|
|
@@ -268,8 +276,8 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
|
|
|
268
276
|
parsed: innerHTML || textContent,
|
|
269
277
|
element: elementForBindings,
|
|
270
278
|
children: {},
|
|
271
|
-
...(
|
|
272
|
-
...(
|
|
279
|
+
...(attributes && { attributes }),
|
|
280
|
+
...(nameBindings && { nameBindings }),
|
|
273
281
|
...(textNodeRef && { textNode: textNodeRef }),
|
|
274
282
|
};
|
|
275
283
|
}
|
|
@@ -282,29 +290,7 @@ export default (root, rootKey = undefined) => {
|
|
|
282
290
|
const { childNodes } = root;
|
|
283
291
|
const stats = { skipped: 0 };
|
|
284
292
|
|
|
285
|
-
|
|
286
|
-
let attributes = null;
|
|
287
|
-
let nameBindings = null;
|
|
288
|
-
if (root.attributes && root.attributes.length > 0) {
|
|
289
|
-
for (let j = 0; j < root.attributes.length; j++) {
|
|
290
|
-
const attr = root.attributes[j];
|
|
291
|
-
BINDING_REGEX.lastIndex = 0;
|
|
292
|
-
|
|
293
|
-
// Check if attribute name contains binding
|
|
294
|
-
if (BINDING_REGEX.test(attr.name)) {
|
|
295
|
-
if (!nameBindings) nameBindings = [];
|
|
296
|
-
nameBindings.push(attr.name);
|
|
297
|
-
continue;
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
// Check if attribute value contains binding
|
|
301
|
-
BINDING_REGEX.lastIndex = 0;
|
|
302
|
-
if (BINDING_REGEX.test(attr.value)) {
|
|
303
|
-
if (!attributes) attributes = {};
|
|
304
|
-
attributes[attr.name] = attr.value;
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
}
|
|
293
|
+
const { attributes, nameBindings } = captureAttributeBindings(root);
|
|
308
294
|
|
|
309
295
|
return {
|
|
310
296
|
// html: root.outerHTML,
|