@ape-egg/vibe 1.0.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 +42 -0
- package/README.md +108 -0
- package/_vibe-compiled-iteration-batch.js +161 -0
- package/affected.js +149 -0
- package/conditionals.js +182 -0
- package/constants.js +76 -0
- package/hydrate.js +100 -0
- package/index.js +250 -0
- package/iterate.js +451 -0
- package/iteration-utils.js +261 -0
- package/link.js +13 -0
- package/llms.txt +279 -0
- package/package.json +26 -0
- package/parse.js +225 -0
- package/state.js +26 -0
- package/utils.js +45 -0
- package/vibe.css +11 -0
package/iterate.js
ADDED
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
import parse from './parse.js';
|
|
2
|
+
import affected from './affected.js';
|
|
3
|
+
import hydrate from './hydrate.js';
|
|
4
|
+
import { resolvePath, getItemKey, computeDiff } from './iteration-utils.js';
|
|
5
|
+
|
|
6
|
+
// Fast path for iteration rendering (opt-in via window.__VIBE_FAST_ITERATION__)
|
|
7
|
+
// See: _vibe-compiled-iteration-batch.js for implementation details
|
|
8
|
+
// This is a preview of what Vibe Compiled (Phase 2) will do automatically
|
|
9
|
+
import * as fastPath from './_vibe-compiled-iteration-batch.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Find a comment node with matching text content in the given nodes.
|
|
13
|
+
*/
|
|
14
|
+
const findComment = (nodes, text) => {
|
|
15
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
16
|
+
const node = nodes[i];
|
|
17
|
+
if (node.nodeType === 8 && node.textContent.trim() === text.trim()) {
|
|
18
|
+
return node;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Clone a parsed tree, mapping element references from original to cloned DOM.
|
|
26
|
+
* Walks both trees in lockstep - no map building needed since structure is identical.
|
|
27
|
+
*/
|
|
28
|
+
const cloneTreeWithElements = (originalTree, clonedRoot) => {
|
|
29
|
+
if (!originalTree) return null;
|
|
30
|
+
|
|
31
|
+
const cloned = {
|
|
32
|
+
parsed: originalTree.parsed,
|
|
33
|
+
element: clonedRoot,
|
|
34
|
+
children: {},
|
|
35
|
+
...(originalTree.attributes && { attributes: originalTree.attributes })
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
if (!originalTree.children) return cloned;
|
|
39
|
+
|
|
40
|
+
const clonedChildNodes = clonedRoot?.childNodes;
|
|
41
|
+
|
|
42
|
+
// Clone children recursively - use index from key to find cloned element
|
|
43
|
+
for (const key in originalTree.children) {
|
|
44
|
+
const child = originalTree.children[key];
|
|
45
|
+
if (!child || typeof child !== 'object') continue;
|
|
46
|
+
|
|
47
|
+
// Handle conditional nodes - need to update comment references
|
|
48
|
+
if (child.type === 'conditional' && clonedChildNodes) {
|
|
49
|
+
const { startComment, elseComment, endComment, branches } = child.meta;
|
|
50
|
+
|
|
51
|
+
// Find corresponding comments in cloned DOM
|
|
52
|
+
const clonedStart = findComment(clonedChildNodes, startComment.textContent);
|
|
53
|
+
const clonedElse = elseComment ? findComment(clonedChildNodes, elseComment.textContent) : null;
|
|
54
|
+
const clonedEnd = findComment(clonedChildNodes, endComment.textContent);
|
|
55
|
+
|
|
56
|
+
cloned.children[key] = {
|
|
57
|
+
type: 'conditional',
|
|
58
|
+
meta: {
|
|
59
|
+
expression: child.meta.expression,
|
|
60
|
+
startComment: clonedStart || startComment,
|
|
61
|
+
elseComment: clonedElse,
|
|
62
|
+
endComment: clonedEnd || endComment,
|
|
63
|
+
branches // Branch templates are reused (they're cloned during mount)
|
|
64
|
+
},
|
|
65
|
+
runtime: {
|
|
66
|
+
activeBranch: undefined,
|
|
67
|
+
activeInstance: null,
|
|
68
|
+
templateRemoved: false
|
|
69
|
+
},
|
|
70
|
+
children: {}
|
|
71
|
+
};
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Handle iteration nodes - need to update comment references and fresh runtime
|
|
76
|
+
if (child.type === 'iteration' && clonedChildNodes) {
|
|
77
|
+
const { startComment, endComment, template } = child.meta;
|
|
78
|
+
|
|
79
|
+
// Find corresponding comments in cloned DOM
|
|
80
|
+
const clonedStart = findComment(clonedChildNodes, startComment.textContent);
|
|
81
|
+
const clonedEnd = findComment(clonedChildNodes, endComment.textContent);
|
|
82
|
+
|
|
83
|
+
cloned.children[key] = {
|
|
84
|
+
type: 'iteration',
|
|
85
|
+
meta: {
|
|
86
|
+
arrayPath: child.meta.arrayPath,
|
|
87
|
+
itemAlias: child.meta.itemAlias,
|
|
88
|
+
indexAlias: child.meta.indexAlias,
|
|
89
|
+
startComment: clonedStart || startComment,
|
|
90
|
+
endComment: clonedEnd || endComment,
|
|
91
|
+
template // Template is reused (cloned during render)
|
|
92
|
+
},
|
|
93
|
+
runtime: {
|
|
94
|
+
instances: [],
|
|
95
|
+
templateRemoved: false
|
|
96
|
+
},
|
|
97
|
+
children: {}
|
|
98
|
+
};
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Extract index from key (format: "nodename_index") - avoid regex for speed
|
|
103
|
+
const underscoreIdx = key.lastIndexOf('_');
|
|
104
|
+
const childIndex = underscoreIdx >= 0 ? parseInt(key.slice(underscoreIdx + 1), 10) : -1;
|
|
105
|
+
const clonedChild = childIndex >= 0 && clonedChildNodes ? clonedChildNodes[childIndex] : null;
|
|
106
|
+
|
|
107
|
+
cloned.children[key] = cloneTreeWithElements(child, clonedChild);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return cloned;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Initialize a block instance from template nodes.
|
|
115
|
+
* Uses cached template tree when available for speed.
|
|
116
|
+
*
|
|
117
|
+
* @param {NodeList} templateNodes - Template nodes to clone
|
|
118
|
+
* @param {Object} scopedState - Scoped state (with localVars/parentScope already applied)
|
|
119
|
+
* @param {Object} cachedTree - Optional cached parsed tree from template
|
|
120
|
+
* @returns {Object} { element, tree, clonedNodes }
|
|
121
|
+
*/
|
|
122
|
+
export const initializeBlock = (templateNodes, scopedState, cachedTree = null) => {
|
|
123
|
+
let tree;
|
|
124
|
+
let clonedNodes = [];
|
|
125
|
+
let firstElement = null;
|
|
126
|
+
|
|
127
|
+
// Fast path: clone cached tree with new element references
|
|
128
|
+
if (cachedTree) {
|
|
129
|
+
// Clone nodes directly
|
|
130
|
+
for (let i = 0; i < templateNodes.length; i++) {
|
|
131
|
+
const cloned = templateNodes[i].cloneNode(true);
|
|
132
|
+
clonedNodes.push(cloned);
|
|
133
|
+
if (!firstElement && cloned.nodeType === 1) {
|
|
134
|
+
firstElement = cloned;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
// Use virtual root to match template structure (avoids creating DOM container)
|
|
138
|
+
// cloneTreeWithElements only needs childNodes property
|
|
139
|
+
tree = cloneTreeWithElements(cachedTree, { childNodes: clonedNodes });
|
|
140
|
+
} else {
|
|
141
|
+
// Slow path: clone and parse from scratch
|
|
142
|
+
for (let i = 0; i < templateNodes.length; i++) {
|
|
143
|
+
const cloned = templateNodes[i].cloneNode(true);
|
|
144
|
+
clonedNodes.push(cloned);
|
|
145
|
+
if (!firstElement && cloned.nodeType === 1) {
|
|
146
|
+
firstElement = cloned;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Fallback for text-only templates
|
|
151
|
+
if (!firstElement && clonedNodes.length > 0) {
|
|
152
|
+
const container = document.createElement('span');
|
|
153
|
+
clonedNodes.forEach(node => container.appendChild(node.cloneNode(true)));
|
|
154
|
+
firstElement = container;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
tree = firstElement ? parse(firstElement) : null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (tree) {
|
|
161
|
+
const affectedElements = affected(tree, {}, scopedState);
|
|
162
|
+
hydrate(affectedElements, scopedState);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
element: firstElement,
|
|
167
|
+
tree,
|
|
168
|
+
clonedNodes
|
|
169
|
+
};
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// Create a proxied state with scoped variables (item, index, array)
|
|
173
|
+
export const createScopedState = (globalState, localVars, parentScope = {}) => {
|
|
174
|
+
return new Proxy(globalState, {
|
|
175
|
+
get(target, prop) {
|
|
176
|
+
// 1. Check local scope first (item, index, array)
|
|
177
|
+
if (prop in localVars) {
|
|
178
|
+
return localVars[prop];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// 2. Check parent scope (for nested iterations)
|
|
182
|
+
if (prop in parentScope) {
|
|
183
|
+
return parentScope[prop];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// 3. Fall back to global state
|
|
187
|
+
return Reflect.get(target, prop);
|
|
188
|
+
},
|
|
189
|
+
|
|
190
|
+
set(target, prop, value) {
|
|
191
|
+
// Only allow setting global state, not local vars
|
|
192
|
+
if (prop in localVars) {
|
|
193
|
+
console.warn(`Cannot modify iteration variable '${prop}'`);
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
if (prop in parentScope) {
|
|
197
|
+
console.warn(`Cannot modify parent iteration variable '${prop}'`);
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
return Reflect.set(target, prop, value);
|
|
201
|
+
},
|
|
202
|
+
|
|
203
|
+
ownKeys(target) {
|
|
204
|
+
// Return all keys: local vars, parent scope, and global state
|
|
205
|
+
const localKeys = Object.keys(localVars);
|
|
206
|
+
const parentKeys = Object.keys(parentScope);
|
|
207
|
+
const globalKeys = Reflect.ownKeys(target);
|
|
208
|
+
return [...new Set([...localKeys, ...parentKeys, ...globalKeys])];
|
|
209
|
+
},
|
|
210
|
+
|
|
211
|
+
has(target, prop) {
|
|
212
|
+
// Check if property exists in local scope, parent scope, or global state
|
|
213
|
+
return prop in localVars || prop in parentScope || Reflect.has(target, prop);
|
|
214
|
+
},
|
|
215
|
+
|
|
216
|
+
getOwnPropertyDescriptor(target, prop) {
|
|
217
|
+
// Provide property descriptor for local vars and parent scope
|
|
218
|
+
if (prop in localVars) {
|
|
219
|
+
return { configurable: true, enumerable: true, value: localVars[prop] };
|
|
220
|
+
}
|
|
221
|
+
if (prop in parentScope) {
|
|
222
|
+
return { configurable: true, enumerable: true, value: parentScope[prop] };
|
|
223
|
+
}
|
|
224
|
+
return Reflect.getOwnPropertyDescriptor(target, prop);
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
// Render all iterations in the parsed tree
|
|
230
|
+
export const renderAllIterations = (tree, state, linkList, parentScope = {}) => {
|
|
231
|
+
// If this is an iteration node, render it
|
|
232
|
+
if (tree.type === 'iteration') {
|
|
233
|
+
renderIteration(tree, state, linkList, parentScope);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Recursively render iterations in child nodes
|
|
238
|
+
if (tree.children) {
|
|
239
|
+
for (const key in tree.children) {
|
|
240
|
+
const child = tree.children[key];
|
|
241
|
+
if (child && typeof child === 'object') {
|
|
242
|
+
renderAllIterations(child, state, linkList, parentScope);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
// Callback for rendering conditionals - set by conditionals.js to avoid circular import
|
|
249
|
+
let _renderAllConditionals = () => {};
|
|
250
|
+
export const setRenderAllConditionals = (fn) => { _renderAllConditionals = fn; };
|
|
251
|
+
|
|
252
|
+
// Initial render of an iteration block
|
|
253
|
+
export const renderIteration = (iterationNode, state, linkList, parentScope = {}) => {
|
|
254
|
+
const { arrayPath, itemAlias, indexAlias, template, startComment, endComment } = iterationNode.meta;
|
|
255
|
+
|
|
256
|
+
// Already rendered - updates go through updateIteration
|
|
257
|
+
if (iterationNode.runtime.instances?.length > 0) return;
|
|
258
|
+
|
|
259
|
+
// Remove template nodes from DOM on first render
|
|
260
|
+
if (!iterationNode.runtime.templateRemoved) {
|
|
261
|
+
let node = startComment.nextSibling;
|
|
262
|
+
while (node && node !== endComment) {
|
|
263
|
+
const next = node.nextSibling;
|
|
264
|
+
node.parentNode?.removeChild(node);
|
|
265
|
+
node = next;
|
|
266
|
+
}
|
|
267
|
+
iterationNode.runtime.templateRemoved = true;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const parent = startComment.parentNode;
|
|
271
|
+
|
|
272
|
+
const array = resolvePath(state, arrayPath);
|
|
273
|
+
if (!Array.isArray(array) || array.length === 0) {
|
|
274
|
+
iterationNode.runtime.instances = [];
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Fast path: opt-in via window.__VIBE_FAST_ITERATION__ (preview of Vibe Compiled)
|
|
279
|
+
if (window.__VIBE_FAST_ITERATION__ && fastPath.canUseFastPath(template)) {
|
|
280
|
+
fastPath.renderFast(iterationNode, array, state, parent, endComment);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Standard path: clone and hydrate each item (handles nested iterations/conditionals)
|
|
285
|
+
const instances = [];
|
|
286
|
+
const templateNodes = [...template.element.childNodes];
|
|
287
|
+
|
|
288
|
+
for (let i = 0; i < array.length; i++) {
|
|
289
|
+
const item = array[i];
|
|
290
|
+
const localVars = { [itemAlias]: item, [indexAlias]: i };
|
|
291
|
+
const scopedState = createScopedState(state, localVars, parentScope);
|
|
292
|
+
|
|
293
|
+
// Clone, parse, hydrate
|
|
294
|
+
const { element, tree, clonedNodes } = initializeBlock(templateNodes, scopedState, template);
|
|
295
|
+
|
|
296
|
+
// Insert cloned nodes
|
|
297
|
+
clonedNodes.forEach(node => parent.insertBefore(node, endComment));
|
|
298
|
+
|
|
299
|
+
// Recursively render nested iterations and conditionals
|
|
300
|
+
if (tree) {
|
|
301
|
+
const nestedScope = { ...parentScope, ...localVars };
|
|
302
|
+
renderAllIterations(tree, scopedState, linkList, nestedScope);
|
|
303
|
+
_renderAllConditionals(tree, scopedState, linkList, nestedScope);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
instances.push({ element, tree, item, index: i, clonedNodes });
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
iterationNode.runtime.instances = instances;
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
// Update an iteration block when array changes
|
|
313
|
+
export const updateIteration = (iterationNode, newState, oldState, linkList, parentScope = {}) => {
|
|
314
|
+
if (!iterationNode.runtime.instances || !iterationNode.runtime.templateRemoved) return;
|
|
315
|
+
|
|
316
|
+
const { arrayPath, template, startComment, endComment } = iterationNode.meta;
|
|
317
|
+
const oldArray = resolvePath(oldState, arrayPath) || [];
|
|
318
|
+
const newArray = resolvePath(newState, arrayPath) || [];
|
|
319
|
+
|
|
320
|
+
// Fast path: opt-in via window.__VIBE_FAST_ITERATION__ (preview of Vibe Compiled)
|
|
321
|
+
// Use for bulk operations (large arrays or empty→full transitions)
|
|
322
|
+
const isEmptyToFull = oldArray.length === 0 && newArray.length > 0;
|
|
323
|
+
const isFullToEmpty = oldArray.length > 0 && newArray.length === 0;
|
|
324
|
+
const isLargeArray = newArray.length > 100 || oldArray.length > 100;
|
|
325
|
+
|
|
326
|
+
if (window.__VIBE_FAST_ITERATION__ && fastPath.canUseFastPath(template) && (isEmptyToFull || isFullToEmpty || isLargeArray)) {
|
|
327
|
+
fastPath.updateFast(iterationNode, newArray, newState, startComment, endComment);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Standard diff-based updates
|
|
332
|
+
const oldKeys = oldArray.map((item, i) => getItemKey(item, i));
|
|
333
|
+
const newKeys = newArray.map((item, i) => getItemKey(item, i));
|
|
334
|
+
const operations = computeDiff(oldKeys, newKeys, oldArray, newArray);
|
|
335
|
+
|
|
336
|
+
operations.forEach(op => {
|
|
337
|
+
switch (op.type) {
|
|
338
|
+
case 'REMOVE': removeInstance(iterationNode, op.index); break;
|
|
339
|
+
case 'ADD': addInstance(iterationNode, op.item, op.index, newState, linkList, parentScope); break;
|
|
340
|
+
case 'MOVE': moveInstance(iterationNode, op.from, op.to); break;
|
|
341
|
+
case 'UPDATE': updateInstance(iterationNode, op.index, op.item, newState, linkList, parentScope); break;
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
iterationNode.runtime.instances.forEach((inst, i) => { inst.index = i; });
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
// Add a new instance at the specified index
|
|
349
|
+
const addInstance = (iterationNode, item, index, state, linkList, parentScope) => {
|
|
350
|
+
const { itemAlias, indexAlias, template, startComment, endComment } = iterationNode.meta;
|
|
351
|
+
|
|
352
|
+
const localVars = { [itemAlias]: item, [indexAlias]: index };
|
|
353
|
+
const scopedState = createScopedState(state, localVars, parentScope);
|
|
354
|
+
const templateNodes = [...template.element.childNodes];
|
|
355
|
+
|
|
356
|
+
const { element, tree, clonedNodes } = initializeBlock(templateNodes, scopedState, template);
|
|
357
|
+
|
|
358
|
+
// Find insertion point
|
|
359
|
+
const insertBefore = index < iterationNode.runtime.instances.length
|
|
360
|
+
? iterationNode.runtime.instances[index].clonedNodes?.[0] || iterationNode.runtime.instances[index].element
|
|
361
|
+
: endComment;
|
|
362
|
+
|
|
363
|
+
// Insert cloned nodes
|
|
364
|
+
clonedNodes.forEach(node => startComment.parentNode.insertBefore(node, insertBefore));
|
|
365
|
+
|
|
366
|
+
// Recursively render nested iterations and conditionals
|
|
367
|
+
if (tree) {
|
|
368
|
+
const nestedScope = { ...parentScope, ...localVars };
|
|
369
|
+
renderAllIterations(tree, scopedState, linkList, nestedScope);
|
|
370
|
+
_renderAllConditionals(tree, scopedState, linkList, nestedScope);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
iterationNode.runtime.instances.splice(index, 0, { element, tree, item, index, clonedNodes });
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
// Remove an instance at the specified index
|
|
377
|
+
const removeInstance = (iterationNode, index) => {
|
|
378
|
+
if (index < 0 || index >= iterationNode.runtime.instances.length) return;
|
|
379
|
+
|
|
380
|
+
const instance = iterationNode.runtime.instances[index];
|
|
381
|
+
|
|
382
|
+
// Remove all cloned nodes from DOM
|
|
383
|
+
(instance.clonedNodes || [instance.element]).forEach(node => node?.parentNode?.removeChild(node));
|
|
384
|
+
|
|
385
|
+
iterationNode.runtime.instances.splice(index, 1);
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
// Move an instance from one position to another
|
|
389
|
+
const moveInstance = (iterationNode, fromIndex, toIndex) => {
|
|
390
|
+
if (fromIndex === toIndex) return;
|
|
391
|
+
if (fromIndex < 0 || fromIndex >= iterationNode.runtime.instances.length) return;
|
|
392
|
+
if (toIndex < 0 || toIndex >= iterationNode.runtime.instances.length) return;
|
|
393
|
+
|
|
394
|
+
const instance = iterationNode.runtime.instances[fromIndex];
|
|
395
|
+
const nodes = instance.clonedNodes || [instance.element];
|
|
396
|
+
const parent = nodes[0]?.parentNode;
|
|
397
|
+
|
|
398
|
+
iterationNode.runtime.instances.splice(fromIndex, 1);
|
|
399
|
+
iterationNode.runtime.instances.splice(toIndex, 0, instance);
|
|
400
|
+
|
|
401
|
+
// Find new insertion point
|
|
402
|
+
const nextInstance = iterationNode.runtime.instances[toIndex + 1];
|
|
403
|
+
const insertBefore = nextInstance
|
|
404
|
+
? (nextInstance.clonedNodes?.[0] || nextInstance.element)
|
|
405
|
+
: iterationNode.meta.endComment;
|
|
406
|
+
|
|
407
|
+
// Move all nodes
|
|
408
|
+
nodes.forEach(node => parent.insertBefore(node, insertBefore));
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
// Update an instance with new item data
|
|
412
|
+
const updateInstance = (iterationNode, index, newItem, state, linkList, parentScope = {}) => {
|
|
413
|
+
if (index < 0 || index >= iterationNode.runtime.instances.length) return;
|
|
414
|
+
|
|
415
|
+
const { itemAlias, indexAlias, template } = iterationNode.meta;
|
|
416
|
+
const instance = iterationNode.runtime.instances[index];
|
|
417
|
+
|
|
418
|
+
const localVars = { [itemAlias]: newItem, [indexAlias]: index };
|
|
419
|
+
const scopedState = createScopedState(state, localVars, parentScope);
|
|
420
|
+
const templateNodes = [...template.element.childNodes];
|
|
421
|
+
|
|
422
|
+
const { element, tree, clonedNodes } = initializeBlock(templateNodes, scopedState, template);
|
|
423
|
+
|
|
424
|
+
// Remove old nodes, insert new ones in same position
|
|
425
|
+
const oldNodes = instance.clonedNodes || [instance.element];
|
|
426
|
+
const insertBefore = oldNodes[oldNodes.length - 1]?.nextSibling;
|
|
427
|
+
const parent = oldNodes[0]?.parentNode;
|
|
428
|
+
|
|
429
|
+
oldNodes.forEach(node => node?.parentNode?.removeChild(node));
|
|
430
|
+
clonedNodes.forEach(node => parent.insertBefore(node, insertBefore));
|
|
431
|
+
|
|
432
|
+
// Recursively render nested iterations and conditionals
|
|
433
|
+
if (tree) {
|
|
434
|
+
const nestedScope = { ...parentScope, ...localVars };
|
|
435
|
+
renderAllIterations(tree, scopedState, linkList, nestedScope);
|
|
436
|
+
_renderAllConditionals(tree, scopedState, linkList, nestedScope);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Update instance
|
|
440
|
+
instance.element = element;
|
|
441
|
+
instance.tree = tree;
|
|
442
|
+
instance.item = newItem;
|
|
443
|
+
instance.clonedNodes = clonedNodes;
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
export default {
|
|
447
|
+
renderIteration,
|
|
448
|
+
updateIteration,
|
|
449
|
+
renderAllIterations,
|
|
450
|
+
createScopedState
|
|
451
|
+
};
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// Utility functions for array iteration
|
|
2
|
+
import { ITERATION_START_REGEX, CONDITIONAL_START_REGEX } from './constants.js';
|
|
3
|
+
|
|
4
|
+
// Resolve nested paths in state (e.g., "user.items" -> state.user.items)
|
|
5
|
+
export const resolvePath = (obj, path) => {
|
|
6
|
+
if (!path || !obj) return undefined;
|
|
7
|
+
return path.split(".").reduce((acc, part) => acc?.[part], obj);
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
// Clone template element preserving structure
|
|
11
|
+
export const cloneTemplate = (templateElement) => {
|
|
12
|
+
return templateElement.cloneNode(true);
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// Find matching <!-- /each --> comment with depth tracking
|
|
16
|
+
export const findEndComment = (nodes, startIndex) => {
|
|
17
|
+
let depth = 1;
|
|
18
|
+
for (let i = startIndex; i < nodes.length; i++) {
|
|
19
|
+
if (nodes[i].nodeName === "#comment") {
|
|
20
|
+
const text = nodes[i].textContent.trim();
|
|
21
|
+
if (ITERATION_START_REGEX.test(text)) {
|
|
22
|
+
depth++;
|
|
23
|
+
} else if (text === "/each") {
|
|
24
|
+
depth--;
|
|
25
|
+
if (depth === 0) {
|
|
26
|
+
return i;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
throw new Error("Unmatched <!-- each --> comment: missing <!-- /each -->");
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// Find matching <!-- /if --> and optional <!-- else --> with depth tracking
|
|
35
|
+
// Returns { elseIndex: number | null, endIndex: number }
|
|
36
|
+
export const findConditionalEnd = (nodes, startIndex) => {
|
|
37
|
+
let depth = 1;
|
|
38
|
+
let elseIndex = null;
|
|
39
|
+
|
|
40
|
+
for (let i = startIndex; i < nodes.length; i++) {
|
|
41
|
+
if (nodes[i].nodeName === "#comment") {
|
|
42
|
+
const text = nodes[i].textContent.trim();
|
|
43
|
+
|
|
44
|
+
// Check for nested if
|
|
45
|
+
if (CONDITIONAL_START_REGEX.test(text)) {
|
|
46
|
+
depth++;
|
|
47
|
+
}
|
|
48
|
+
// Check for else at current depth
|
|
49
|
+
else if (text === "else" && depth === 1 && elseIndex === null) {
|
|
50
|
+
elseIndex = i;
|
|
51
|
+
}
|
|
52
|
+
// Check for /if
|
|
53
|
+
else if (text === "/if") {
|
|
54
|
+
depth--;
|
|
55
|
+
if (depth === 0) {
|
|
56
|
+
return { elseIndex, endIndex: i };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
throw new Error("Unmatched <!-- if --> comment: missing <!-- /if -->");
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// Generate stable hash for objects
|
|
66
|
+
export const stableHash = (obj) => {
|
|
67
|
+
if (obj === null || obj === undefined) return "null";
|
|
68
|
+
if (typeof obj !== "object") return String(obj);
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
// Sort keys for stable hashing
|
|
72
|
+
const str = JSON.stringify(obj, Object.keys(obj).sort());
|
|
73
|
+
return simpleHash(str);
|
|
74
|
+
} catch (e) {
|
|
75
|
+
// Fallback for circular references
|
|
76
|
+
return simpleHash(String(obj));
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// Simple hash function
|
|
81
|
+
const simpleHash = (str) => {
|
|
82
|
+
let hash = 0;
|
|
83
|
+
for (let i = 0; i < str.length; i++) {
|
|
84
|
+
const char = str.charCodeAt(i);
|
|
85
|
+
hash = (hash << 5) - hash + char;
|
|
86
|
+
hash = hash & hash; // Convert to 32-bit integer
|
|
87
|
+
}
|
|
88
|
+
return Math.abs(hash).toString(36);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// Deep equality check
|
|
92
|
+
export const deepEqual = (a, b) => {
|
|
93
|
+
if (a === b) return true;
|
|
94
|
+
|
|
95
|
+
if (a === null || b === null || a === undefined || b === undefined) {
|
|
96
|
+
return a === b;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (typeof a !== "object" || typeof b !== "object") {
|
|
100
|
+
return a === b;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (Array.isArray(a) !== Array.isArray(b)) {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (Array.isArray(a)) {
|
|
108
|
+
if (a.length !== b.length) return false;
|
|
109
|
+
for (let i = 0; i < a.length; i++) {
|
|
110
|
+
if (!deepEqual(a[i], b[i])) return false;
|
|
111
|
+
}
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const keysA = Object.keys(a);
|
|
116
|
+
const keysB = Object.keys(b);
|
|
117
|
+
|
|
118
|
+
if (keysA.length !== keysB.length) return false;
|
|
119
|
+
|
|
120
|
+
for (const key of keysA) {
|
|
121
|
+
if (!keysB.includes(key)) return false;
|
|
122
|
+
if (!deepEqual(a[key], b[key])) return false;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return true;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// Longest Common Subsequence algorithm
|
|
129
|
+
export const longestCommonSubsequence = (arr1, arr2) => {
|
|
130
|
+
const m = arr1.length;
|
|
131
|
+
const n = arr2.length;
|
|
132
|
+
const dp = Array(m + 1)
|
|
133
|
+
.fill(null)
|
|
134
|
+
.map(() => Array(n + 1).fill(0));
|
|
135
|
+
|
|
136
|
+
// Build LCS table
|
|
137
|
+
for (let i = 1; i <= m; i++) {
|
|
138
|
+
for (let j = 1; j <= n; j++) {
|
|
139
|
+
if (arr1[i - 1] === arr2[j - 1]) {
|
|
140
|
+
dp[i][j] = dp[i - 1][j - 1] + 1;
|
|
141
|
+
} else {
|
|
142
|
+
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Backtrack to find LCS
|
|
148
|
+
const lcs = [];
|
|
149
|
+
let i = m,
|
|
150
|
+
j = n;
|
|
151
|
+
while (i > 0 && j > 0) {
|
|
152
|
+
if (arr1[i - 1] === arr2[j - 1]) {
|
|
153
|
+
lcs.unshift(arr1[i - 1]);
|
|
154
|
+
i--;
|
|
155
|
+
j--;
|
|
156
|
+
} else if (dp[i - 1][j] > dp[i][j - 1]) {
|
|
157
|
+
i--;
|
|
158
|
+
} else {
|
|
159
|
+
j--;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return lcs;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
// Generate unique key for array items
|
|
167
|
+
export const getItemKey = (item, index) => {
|
|
168
|
+
// 1. If item has 'id' property, use it
|
|
169
|
+
if (item && typeof item === "object" && "id" in item) {
|
|
170
|
+
return `id_${item.id}`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// 2. If item is primitive, use value + index
|
|
174
|
+
if (typeof item !== "object" || item === null) {
|
|
175
|
+
return `val_${item}_${index}`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// 3. For objects without id, use stable hash
|
|
179
|
+
return `hash_${stableHash(item)}_${index}`;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
// Compute diff operations between old and new arrays
|
|
183
|
+
export const computeDiff = (oldKeys, newKeys, oldArray, newArray) => {
|
|
184
|
+
const operations = [];
|
|
185
|
+
|
|
186
|
+
// Use LCS to find common elements
|
|
187
|
+
const common = longestCommonSubsequence(oldKeys, newKeys);
|
|
188
|
+
|
|
189
|
+
// Build index maps for quick lookup
|
|
190
|
+
const newKeyMap = new Map(newKeys.map((key, idx) => [key, idx]));
|
|
191
|
+
const oldKeyMap = new Map(oldKeys.map((key, idx) => [key, idx]));
|
|
192
|
+
|
|
193
|
+
// Track which keys we've processed
|
|
194
|
+
const processedOld = new Set();
|
|
195
|
+
const processedNew = new Set();
|
|
196
|
+
|
|
197
|
+
// First pass: identify items to keep and check for updates
|
|
198
|
+
common.forEach((key) => {
|
|
199
|
+
const oldIdx = oldKeyMap.get(key);
|
|
200
|
+
const newIdx = newKeyMap.get(key);
|
|
201
|
+
processedOld.add(oldIdx);
|
|
202
|
+
processedNew.add(newIdx);
|
|
203
|
+
|
|
204
|
+
// Check if item content changed
|
|
205
|
+
if (!deepEqual(oldArray[oldIdx], newArray[newIdx])) {
|
|
206
|
+
operations.push({
|
|
207
|
+
type: "UPDATE",
|
|
208
|
+
index: newIdx,
|
|
209
|
+
oldIndex: oldIdx,
|
|
210
|
+
item: newArray[newIdx],
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Check if position changed
|
|
215
|
+
if (oldIdx !== newIdx) {
|
|
216
|
+
operations.push({
|
|
217
|
+
type: "MOVE",
|
|
218
|
+
from: oldIdx,
|
|
219
|
+
to: newIdx,
|
|
220
|
+
key,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// Second pass: identify removals
|
|
226
|
+
oldKeys.forEach((key, idx) => {
|
|
227
|
+
if (!processedOld.has(idx)) {
|
|
228
|
+
operations.push({
|
|
229
|
+
type: "REMOVE",
|
|
230
|
+
index: idx,
|
|
231
|
+
key,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// Third pass: identify additions
|
|
237
|
+
newKeys.forEach((key, idx) => {
|
|
238
|
+
if (!processedNew.has(idx)) {
|
|
239
|
+
operations.push({
|
|
240
|
+
type: "ADD",
|
|
241
|
+
index: idx,
|
|
242
|
+
item: newArray[idx],
|
|
243
|
+
key,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// Sort operations: REMOVE first (from end), then MOVE, then ADD, then UPDATE
|
|
249
|
+
return operations.sort((a, b) => {
|
|
250
|
+
const priority = { REMOVE: 0, MOVE: 1, ADD: 2, UPDATE: 3 };
|
|
251
|
+
if (priority[a.type] !== priority[b.type]) {
|
|
252
|
+
return priority[a.type] - priority[b.type];
|
|
253
|
+
}
|
|
254
|
+
// For REMOVE, process from end to beginning
|
|
255
|
+
if (a.type === "REMOVE") {
|
|
256
|
+
return b.index - a.index;
|
|
257
|
+
}
|
|
258
|
+
// For others, process in order
|
|
259
|
+
return a.index - b.index;
|
|
260
|
+
});
|
|
261
|
+
};
|