@ape-egg/vibe 1.1.2 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +78 -0
- package/README.md +30 -23
- package/index.js +96 -2
- package/package.json +2 -1
- package/runtime/affected.js +33 -19
- package/runtime/component-state.js +63 -0
- package/runtime/component.js +39 -2
- package/runtime/conditionals.js +4 -4
- package/runtime/constants.js +13 -0
- package/runtime/hydrate.js +17 -20
- package/runtime/index.js +115 -49
- package/runtime/iterate.js +12 -4
- package/runtime/parse.js +48 -2
- package/runtime/scope.js +70 -0
- package/runtime/state.js +10 -5
- package/runtime/utils.js +72 -5
- /package/{runtime/vibe.css → vibe.css} +0 -0
package/runtime/hydrate.js
CHANGED
|
@@ -3,27 +3,20 @@ import { updateConditional } from './conditionals.js';
|
|
|
3
3
|
import { VALUE_ATTRS, DOM_PROPERTIES, BINDING_REGEX, PURE_BINDING_REGEX } from './constants.js';
|
|
4
4
|
import { evalInScope } from './utils.js';
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
let previousState = {};
|
|
8
|
-
|
|
9
|
-
export const setPreviousState = (state) => {
|
|
10
|
-
previousState = { ...state };
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
export default (affected, state, manifest = {}) => {
|
|
6
|
+
export default (affected, state, manifest = {}, oldState = {}) => {
|
|
14
7
|
affected.forEach((aff) => {
|
|
15
8
|
// Use scoped state if provided (from iteration instances)
|
|
16
9
|
const effectiveState = aff.scopedState || state;
|
|
17
10
|
|
|
18
11
|
// Handle iteration updates
|
|
19
12
|
if (aff.type === 'iteration') {
|
|
20
|
-
updateIteration(aff.node, state,
|
|
13
|
+
updateIteration(aff.node, state, oldState, manifest);
|
|
21
14
|
return;
|
|
22
15
|
}
|
|
23
16
|
|
|
24
17
|
// Handle conditional updates
|
|
25
18
|
if (aff.type === 'conditional') {
|
|
26
|
-
updateConditional(aff.node, state,
|
|
19
|
+
updateConditional(aff.node, state, oldState, manifest);
|
|
27
20
|
return;
|
|
28
21
|
}
|
|
29
22
|
|
|
@@ -33,7 +26,7 @@ export default (affected, state, manifest = {}) => {
|
|
|
33
26
|
try {
|
|
34
27
|
// HTML lowercases attribute names, so we need case-insensitive lookup
|
|
35
28
|
// Try exact match first, then try finding a case-insensitive match
|
|
36
|
-
let attrName = evalInScope(matchInner, effectiveState);
|
|
29
|
+
let attrName = evalInScope(matchInner, effectiveState, element);
|
|
37
30
|
|
|
38
31
|
// If exact match failed and expression is a simple property (no dots/brackets)
|
|
39
32
|
if (!attrName && !matchInner.includes('.') && !matchInner.includes('[')) {
|
|
@@ -91,12 +84,12 @@ export default (affected, state, manifest = {}) => {
|
|
|
91
84
|
if (isDomProperty && isPureBinding) {
|
|
92
85
|
// For DOM properties like value, set the property directly
|
|
93
86
|
const expr = isPureBinding[1];
|
|
94
|
-
const value = evalInScope(expr, effectiveState);
|
|
87
|
+
const value = evalInScope(expr, effectiveState, element);
|
|
95
88
|
element[attrName] = value;
|
|
96
89
|
} else if (!isValueAttr && isPureBinding) {
|
|
97
90
|
// Boolean-like attributes: add or remove based on truthiness
|
|
98
91
|
const expr = isPureBinding[1];
|
|
99
|
-
const value = evalInScope(expr, effectiveState);
|
|
92
|
+
const value = evalInScope(expr, effectiveState, element);
|
|
100
93
|
if (value) {
|
|
101
94
|
element.setAttribute(attrName, '');
|
|
102
95
|
} else {
|
|
@@ -105,7 +98,7 @@ export default (affected, state, manifest = {}) => {
|
|
|
105
98
|
} else {
|
|
106
99
|
// Value attribute - replace bindings with values
|
|
107
100
|
const newValue = attrValue.replace(BINDING_REGEX, (_, expr) => {
|
|
108
|
-
return evalInScope(expr, effectiveState);
|
|
101
|
+
return evalInScope(expr, effectiveState, element);
|
|
109
102
|
});
|
|
110
103
|
element.setAttribute(attrName, newValue);
|
|
111
104
|
}
|
|
@@ -114,12 +107,12 @@ export default (affected, state, manifest = {}) => {
|
|
|
114
107
|
}
|
|
115
108
|
|
|
116
109
|
// Handle regular element updates
|
|
117
|
-
const { matches, matchOuter, matchInner, input, element } = aff;
|
|
110
|
+
const { matches, matchOuter, matchInner, input, element, textNode } = aff;
|
|
118
111
|
|
|
119
112
|
// This prevents undefined store properties to throw an error
|
|
120
113
|
try {
|
|
121
114
|
// Evaluate the expression with state as context
|
|
122
|
-
const evaluated = evalInScope(matchInner, effectiveState);
|
|
115
|
+
const evaluated = evalInScope(matchInner, effectiveState, element);
|
|
123
116
|
|
|
124
117
|
const toReplace = input.replaceAll(matchOuter, evaluated).trim();
|
|
125
118
|
|
|
@@ -129,10 +122,14 @@ export default (affected, state, manifest = {}) => {
|
|
|
129
122
|
}
|
|
130
123
|
});
|
|
131
124
|
|
|
132
|
-
|
|
125
|
+
// If we have a direct reference to the text node, update it specifically
|
|
126
|
+
// This prevents wiping child elements when parent has both text and element children
|
|
127
|
+
if (textNode && textNode.nodeType === 3) {
|
|
128
|
+
textNode.textContent = toReplace;
|
|
129
|
+
} else {
|
|
130
|
+
// Fallback: element has no children or is just a text container
|
|
131
|
+
element.textContent = toReplace;
|
|
132
|
+
}
|
|
133
133
|
} catch (e) {}
|
|
134
134
|
});
|
|
135
|
-
|
|
136
|
-
// Update previous state for next diff
|
|
137
|
-
setPreviousState(state);
|
|
138
135
|
};
|
package/runtime/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import state from './state.js';
|
|
2
2
|
import parse from './parse.js';
|
|
3
3
|
import createManifest from './manifest.js';
|
|
4
|
-
import hydrate
|
|
4
|
+
import hydrate from './hydrate.js';
|
|
5
5
|
import affected from './affected.js';
|
|
6
6
|
import { deepMerge, hash } from './utils.js';
|
|
7
7
|
import { renderAllIterations, setRenderAllConditionals } from './iterate.js';
|
|
@@ -22,23 +22,40 @@ import {
|
|
|
22
22
|
import { processComponent, abortComponentFetch } from './component.js';
|
|
23
23
|
import { debugLog } from './debug.js';
|
|
24
24
|
import { shouldCleanup, cleanup } from './cleanup.js';
|
|
25
|
+
import { generateComponentId, executeComponentScript } from './component-state.js';
|
|
25
26
|
|
|
26
27
|
// Wire up cross-module dependency after all modules are loaded
|
|
27
28
|
setRenderAllConditionals(renderAllConditionals);
|
|
28
29
|
|
|
29
|
-
// Check if node
|
|
30
|
-
const
|
|
30
|
+
// Check if node should be processed by Vibe
|
|
31
|
+
const shouldProcessNode = (node) => {
|
|
32
|
+
// Only process element nodes
|
|
33
|
+
if (node.nodeType !== 1) return false;
|
|
34
|
+
|
|
35
|
+
// Fast check first: skip nodes without Vibe syntax (cheapest check)
|
|
36
|
+
const html = node.outerHTML;
|
|
37
|
+
if (
|
|
38
|
+
!html.includes('@[') &&
|
|
39
|
+
!html.includes('<!-- each') &&
|
|
40
|
+
!html.includes('<!-- if') &&
|
|
41
|
+
!html.includes('<component')
|
|
42
|
+
) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Node has Vibe syntax - now check if it's in a non-reactive context
|
|
31
47
|
let current = node;
|
|
32
48
|
while (current && current !== document.body) {
|
|
33
49
|
if (NON_REACTIVE_ELEMENTS.includes(current.nodeName)) {
|
|
34
|
-
return
|
|
50
|
+
return false;
|
|
35
51
|
}
|
|
36
52
|
if (current.hasAttribute?.('dehydrate')) {
|
|
37
|
-
return
|
|
53
|
+
return false;
|
|
38
54
|
}
|
|
39
55
|
current = current.parentElement;
|
|
40
56
|
}
|
|
41
|
-
|
|
57
|
+
|
|
58
|
+
return true;
|
|
42
59
|
};
|
|
43
60
|
|
|
44
61
|
// Navigate tree using dot notation (handles .children at each level)
|
|
@@ -90,10 +107,11 @@ const processCoreLoop = (node, parsedNode, state, manifest, isNewNode, debug) =>
|
|
|
90
107
|
|
|
91
108
|
// 2. Hydrate (replace @[...] bindings)
|
|
92
109
|
// Use empty object as "previous state" for new nodes so all bindings are affected
|
|
93
|
-
const
|
|
110
|
+
const oldStateForAffected = isNewNode ? {} : previousState;
|
|
111
|
+
const affectedElements = affected(parsedNode, oldStateForAffected, state);
|
|
94
112
|
if (affectedElements.length > 0) {
|
|
95
113
|
hydratedCount = affectedElements.length;
|
|
96
|
-
hydrate(affectedElements, state, manifest);
|
|
114
|
+
hydrate(affectedElements, state, manifest, oldStateForAffected);
|
|
97
115
|
}
|
|
98
116
|
|
|
99
117
|
// 3. Conditionals (evaluate <!-- if -->)
|
|
@@ -139,6 +157,45 @@ const main = (s, attrName = 'vibe', config = {}) => {
|
|
|
139
157
|
|
|
140
158
|
debugLog(PHASE_ATTACH, `Vibe attached to`, debug, 0, rootElement);
|
|
141
159
|
|
|
160
|
+
// Process inline component scripts FIRST (before parse)
|
|
161
|
+
// This sets componentIds so parse can rewrite event handlers
|
|
162
|
+
const componentScripts = rootElement.querySelectorAll('script[type="component"]');
|
|
163
|
+
|
|
164
|
+
if (componentScripts.length > 0) {
|
|
165
|
+
componentScripts.forEach((script) => {
|
|
166
|
+
// Skip if already processed (e.g., by auto.js)
|
|
167
|
+
if (script.hasAttribute('data-vibe-component-id')) return;
|
|
168
|
+
|
|
169
|
+
const scriptContent = script.textContent?.trim() || '';
|
|
170
|
+
if (!scriptContent) return;
|
|
171
|
+
|
|
172
|
+
const componentId = generateComponentId();
|
|
173
|
+
|
|
174
|
+
// Store componentId on script element BEFORE parsing
|
|
175
|
+
script.setAttribute('data-vibe-component-id', componentId);
|
|
176
|
+
|
|
177
|
+
// Tag all following sibling elements with this component ID (shallow)
|
|
178
|
+
// Stop at next component script or end of siblings
|
|
179
|
+
let sibling = script.nextElementSibling;
|
|
180
|
+
while (sibling) {
|
|
181
|
+
// Stop if we hit another component script
|
|
182
|
+
if (sibling.tagName === 'SCRIPT' && sibling.getAttribute('type') === 'component') {
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
// Tag only the sibling (descendants will use closest() to find it)
|
|
186
|
+
sibling.setAttribute('data-vibe-component-id', componentId);
|
|
187
|
+
sibling = sibling.nextElementSibling;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Execute component script to get state
|
|
191
|
+
const componentState = executeComponentScript(scriptContent);
|
|
192
|
+
|
|
193
|
+
// Add component state to initial state
|
|
194
|
+
s[componentId] = componentState;
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Now parse (event handlers will be rewritten correctly)
|
|
142
199
|
let parsedTree = parse(rootElement);
|
|
143
200
|
let manifest = createManifest(parsedTree);
|
|
144
201
|
|
|
@@ -171,19 +228,33 @@ const main = (s, attrName = 'vibe', config = {}) => {
|
|
|
171
228
|
afterDomMutation: [],
|
|
172
229
|
};
|
|
173
230
|
|
|
231
|
+
// Extract plain values from proxy (removes proxy wrappers)
|
|
232
|
+
const extractPlainValue = (obj) => {
|
|
233
|
+
if (obj === null || typeof obj !== 'object') return obj;
|
|
234
|
+
if (Array.isArray(obj)) return obj.map(extractPlainValue);
|
|
235
|
+
const plain = {};
|
|
236
|
+
for (const key in obj) {
|
|
237
|
+
if (obj.hasOwnProperty(key)) {
|
|
238
|
+
plain[key] = extractPlainValue(obj[key]);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return plain;
|
|
242
|
+
};
|
|
243
|
+
|
|
174
244
|
// Observer reference and callback - defined here so state handler can access processMutations
|
|
175
245
|
let observer = null;
|
|
176
246
|
let processMutations = null;
|
|
177
247
|
|
|
178
|
-
const $ = state(s, (newState) => {
|
|
179
|
-
|
|
248
|
+
const $ = state(s, (newState, oldState) => {
|
|
249
|
+
// Extract current state (after mutation)
|
|
250
|
+
const currentState = extractPlainValue($);
|
|
251
|
+
const changedProp = Object.keys(newState)[0];
|
|
180
252
|
|
|
181
|
-
// Find what changed
|
|
182
|
-
const affectedElements = affected(parsedTree, previousState,
|
|
253
|
+
// Find what changed (compare previousState vs currentState)
|
|
254
|
+
const affectedElements = affected(parsedTree, previousState, currentState);
|
|
183
255
|
|
|
184
256
|
if (affectedElements.length > 0) {
|
|
185
|
-
|
|
186
|
-
debugLog(PHASE_UPDATE, `state changed (${changedKeys})`, debug);
|
|
257
|
+
debugLog(PHASE_UPDATE, `state changed (${changedProp})`, debug);
|
|
187
258
|
|
|
188
259
|
// Capture pending mutations before disconnecting (takeRecords clears the queue)
|
|
189
260
|
let pendingMutations = [];
|
|
@@ -194,11 +265,11 @@ const main = (s, attrName = 'vibe', config = {}) => {
|
|
|
194
265
|
|
|
195
266
|
// Run core loop on the full tree (hydrate will use affected list)
|
|
196
267
|
// isNewNode = false because this is a state update, not a DOM mutation
|
|
197
|
-
hydrate(affectedElements,
|
|
268
|
+
hydrate(affectedElements, currentState, manifest, previousState);
|
|
198
269
|
|
|
199
270
|
// After hydrate, check for conditional/iteration changes
|
|
200
|
-
const conditionalCount = renderAllConditionals(parsedTree,
|
|
201
|
-
const iterationCount = renderAllIterations(parsedTree,
|
|
271
|
+
const conditionalCount = renderAllConditionals(parsedTree, currentState, manifest);
|
|
272
|
+
const iterationCount = renderAllIterations(parsedTree, currentState, manifest);
|
|
202
273
|
|
|
203
274
|
if (observer) {
|
|
204
275
|
observer.observe(rootElement, {
|
|
@@ -215,22 +286,10 @@ const main = (s, attrName = 'vibe', config = {}) => {
|
|
|
215
286
|
}
|
|
216
287
|
}
|
|
217
288
|
|
|
218
|
-
//
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
const cloned = {};
|
|
223
|
-
for (const key in obj) {
|
|
224
|
-
if (obj.hasOwnProperty(key)) {
|
|
225
|
-
cloned[key] = deepClone(obj[key]);
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
return cloned;
|
|
229
|
-
};
|
|
230
|
-
|
|
231
|
-
const prev = deepClone(previousState);
|
|
232
|
-
previousState = { ...$, ...newState };
|
|
233
|
-
hooks.afterUpdate.forEach((callback) => callback(deepClone({ ...$ }), prev));
|
|
289
|
+
// Store previous state for hooks (currentState is already plain, no need to clone)
|
|
290
|
+
const prev = previousState;
|
|
291
|
+
previousState = currentState;
|
|
292
|
+
hooks.afterUpdate.forEach((callback) => callback(currentState, prev));
|
|
234
293
|
});
|
|
235
294
|
|
|
236
295
|
// Add hook subscription method (non-enumerable so it won't be spread/cloned with state)
|
|
@@ -244,8 +303,9 @@ const main = (s, attrName = 'vibe', config = {}) => {
|
|
|
244
303
|
enumerable: false,
|
|
245
304
|
});
|
|
246
305
|
|
|
247
|
-
// Initial hydration
|
|
248
|
-
const
|
|
306
|
+
// Initial hydration - pass plain values so iteration can do reference comparison
|
|
307
|
+
const initialState = extractPlainValue($);
|
|
308
|
+
const affectedElements = affected(parsedTree, initialState, initialState);
|
|
249
309
|
|
|
250
310
|
debugLog(
|
|
251
311
|
PHASE_HYDRATE,
|
|
@@ -257,14 +317,13 @@ const main = (s, attrName = 'vibe', config = {}) => {
|
|
|
257
317
|
debug,
|
|
258
318
|
);
|
|
259
319
|
|
|
260
|
-
|
|
261
|
-
|
|
320
|
+
// Hydrate with proxy $ so DOM bindings work
|
|
321
|
+
// Pass initialState as oldState for iterations (won't actually update, just initial render)
|
|
322
|
+
hydrate(affectedElements, $, manifest, initialState);
|
|
262
323
|
|
|
263
324
|
// Render all iterations and conditionals after initial hydration
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
const iterationCount = renderAllIterations(parsedTree, $, manifest);
|
|
325
|
+
// Use initialState (plain values) for iteration rendering so reference comparison works
|
|
326
|
+
const iterationCount = renderAllIterations(parsedTree, initialState, manifest);
|
|
268
327
|
if (iterationCount > 0)
|
|
269
328
|
debugLog(
|
|
270
329
|
PHASE_ITERATE,
|
|
@@ -288,6 +347,9 @@ const main = (s, attrName = 'vibe', config = {}) => {
|
|
|
288
347
|
debug,
|
|
289
348
|
);
|
|
290
349
|
|
|
350
|
+
// After all initial rendering, capture a clean snapshot for comparison
|
|
351
|
+
previousState = extractPlainValue($);
|
|
352
|
+
|
|
291
353
|
// Define observer callback as named function so we can call it manually for pending mutations
|
|
292
354
|
processMutations = (mutations) => {
|
|
293
355
|
// Early exit if no mutations to process (common case)
|
|
@@ -353,8 +415,8 @@ const main = (s, attrName = 'vibe', config = {}) => {
|
|
|
353
415
|
});
|
|
354
416
|
|
|
355
417
|
addedNodesList.forEach((node) => {
|
|
356
|
-
// Skip
|
|
357
|
-
if (
|
|
418
|
+
// Skip nodes that aren't element nodes, are non-reactive, or lack Vibe syntax
|
|
419
|
+
if (!shouldProcessNode(node)) {
|
|
358
420
|
return;
|
|
359
421
|
}
|
|
360
422
|
|
|
@@ -539,12 +601,16 @@ const main = (s, attrName = 'vibe', config = {}) => {
|
|
|
539
601
|
...config,
|
|
540
602
|
_forceSync: true,
|
|
541
603
|
_observer: observer,
|
|
542
|
-
_processMutations: processMutations
|
|
604
|
+
_processMutations: processMutations,
|
|
543
605
|
};
|
|
544
|
-
processComponent(
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
606
|
+
processComponent(
|
|
607
|
+
rootElement,
|
|
608
|
+
() => {
|
|
609
|
+
// When all components are done, run cleanup check
|
|
610
|
+
checkCleanup();
|
|
611
|
+
},
|
|
612
|
+
componentConfig,
|
|
613
|
+
);
|
|
548
614
|
}
|
|
549
615
|
};
|
|
550
616
|
|
|
@@ -596,7 +662,7 @@ const main = (s, attrName = 'vibe', config = {}) => {
|
|
|
596
662
|
...config,
|
|
597
663
|
_forceSync: true,
|
|
598
664
|
_observer: observer,
|
|
599
|
-
_processMutations: processMutations
|
|
665
|
+
_processMutations: processMutations,
|
|
600
666
|
};
|
|
601
667
|
|
|
602
668
|
processComponent(
|
package/runtime/iterate.js
CHANGED
|
@@ -2,6 +2,7 @@ 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
6
|
|
|
6
7
|
// Fast path for iteration rendering (opt-in via window.__VIBE_FAST_ITERATION__)
|
|
7
8
|
// See: _vibe-compiled-iteration-batch.js for implementation details
|
|
@@ -153,7 +154,7 @@ export const initializeBlock = (templateNodes, scopedState, cachedTree = null) =
|
|
|
153
154
|
}
|
|
154
155
|
|
|
155
156
|
if (tree) {
|
|
156
|
-
const affectedElements = affected(tree, {}, scopedState);
|
|
157
|
+
const affectedElements = affected(tree, {}, scopedState, [], scopedState);
|
|
157
158
|
hydrate(affectedElements, scopedState);
|
|
158
159
|
}
|
|
159
160
|
|
|
@@ -278,7 +279,10 @@ export const renderIteration = (iterationNode, state, manifest, parentScope = {}
|
|
|
278
279
|
|
|
279
280
|
const parent = startComment.parentNode;
|
|
280
281
|
|
|
281
|
-
|
|
282
|
+
// Handle this.property for component-scoped arrays
|
|
283
|
+
const resolvedArrayPath = resolveThisPath(arrayPath, startComment.parentElement);
|
|
284
|
+
|
|
285
|
+
const array = resolvePath(state, resolvedArrayPath);
|
|
282
286
|
if (!Array.isArray(array) || array.length === 0) {
|
|
283
287
|
iterationNode.runtime.instances = [];
|
|
284
288
|
return;
|
|
@@ -327,8 +331,12 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
|
|
|
327
331
|
if (!iterationNode.runtime.instances || !iterationNode.runtime.templateRemoved) return;
|
|
328
332
|
|
|
329
333
|
const { arrayPath, template, startComment, endComment } = iterationNode.meta;
|
|
330
|
-
|
|
331
|
-
|
|
334
|
+
|
|
335
|
+
// Handle this.property for component-scoped arrays
|
|
336
|
+
const resolvedArrayPath = resolveThisPath(arrayPath, startComment.parentElement);
|
|
337
|
+
|
|
338
|
+
const oldArray = resolvePath(oldState, resolvedArrayPath) || [];
|
|
339
|
+
const newArray = resolvePath(newState, resolvedArrayPath) || [];
|
|
332
340
|
|
|
333
341
|
// Fast path: opt-in via window.__VIBE_FAST_ITERATION__ (preview of Vibe Compiled)
|
|
334
342
|
// Use for bulk operations (large arrays or empty→full transitions)
|
package/runtime/parse.js
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
BINDING_REGEX,
|
|
6
6
|
ITERATION_REGEX,
|
|
7
7
|
CONDITIONAL_REGEX,
|
|
8
|
+
DOM_ELEMENT_PROPERTIES,
|
|
8
9
|
} from './constants.js';
|
|
9
10
|
|
|
10
11
|
const parseHTML = (children, rootKey = undefined) =>
|
|
@@ -185,6 +186,11 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
|
|
|
185
186
|
const name = nodeName.startsWith('#') ? nodeName.slice(1) : nodeName;
|
|
186
187
|
const nodeIdentifier = `${name}_${i}`.toLowerCase();
|
|
187
188
|
|
|
189
|
+
// For text nodes, use parent element (text nodes can't have attributes)
|
|
190
|
+
const isTextNode = nodeName === '#text';
|
|
191
|
+
const elementForBindings = isTextNode ? element.parentElement : element;
|
|
192
|
+
const textNodeRef = isTextNode ? element : null; // Store reference to actual text node
|
|
193
|
+
|
|
188
194
|
// Check for attribute bindings
|
|
189
195
|
const attributes = {};
|
|
190
196
|
const nameBindings = [];
|
|
@@ -200,6 +206,18 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
|
|
|
200
206
|
continue; // Don't process as regular attribute
|
|
201
207
|
}
|
|
202
208
|
|
|
209
|
+
// Rewrite event handlers with this. to use component state
|
|
210
|
+
if (attr.name.startsWith('on') && attr.value.includes('this.')) {
|
|
211
|
+
const componentId = findComponentIdForElement(element);
|
|
212
|
+
if (componentId) {
|
|
213
|
+
// Rewrite this.property to $['componentId'].property, but skip DOM properties
|
|
214
|
+
const rewritten = attr.value.replace(/\bthis\.(\w+)/g, (match, prop) => {
|
|
215
|
+
return DOM_ELEMENT_PROPERTIES.has(prop) ? match : `$['${componentId}'].${prop}`;
|
|
216
|
+
});
|
|
217
|
+
element.setAttribute(attr.name, rewritten);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
203
221
|
// Check if attribute value contains binding
|
|
204
222
|
BINDING_REGEX.lastIndex = 0;
|
|
205
223
|
if (BINDING_REGEX.test(attr.value)) {
|
|
@@ -207,6 +225,32 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
|
|
|
207
225
|
}
|
|
208
226
|
}
|
|
209
227
|
}
|
|
228
|
+
|
|
229
|
+
// Helper to find component ID for an element
|
|
230
|
+
function findComponentIdForElement(element) {
|
|
231
|
+
if (!element || !element.parentElement) return null;
|
|
232
|
+
|
|
233
|
+
let current = element;
|
|
234
|
+
|
|
235
|
+
while (current && current !== document.body) {
|
|
236
|
+
// Check previous siblings for script[data-vibe-component-id]
|
|
237
|
+
let sibling = current.previousElementSibling;
|
|
238
|
+
while (sibling) {
|
|
239
|
+
if (
|
|
240
|
+
sibling.tagName === 'SCRIPT' &&
|
|
241
|
+
sibling.getAttribute('type') === 'component' &&
|
|
242
|
+
sibling.hasAttribute('data-vibe-component-id')
|
|
243
|
+
) {
|
|
244
|
+
return sibling.getAttribute('data-vibe-component-id');
|
|
245
|
+
}
|
|
246
|
+
sibling = sibling.previousElementSibling;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
current = current.parentElement;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
210
254
|
const hasAttributeBindings = Object.keys(attributes).length > 0;
|
|
211
255
|
const hasNameBindings = nameBindings.length > 0;
|
|
212
256
|
|
|
@@ -218,18 +262,20 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
|
|
|
218
262
|
|
|
219
263
|
result[rootKey || nodeIdentifier] = {
|
|
220
264
|
parsed,
|
|
221
|
-
element,
|
|
265
|
+
element: elementForBindings,
|
|
222
266
|
children: recursive(iteratableChildren, undefined, new Set(), stats),
|
|
223
267
|
...(hasAttributeBindings && { attributes }),
|
|
224
268
|
...(hasNameBindings && { nameBindings }),
|
|
269
|
+
...(textNodeRef && { textNode: textNodeRef }),
|
|
225
270
|
};
|
|
226
271
|
} else {
|
|
227
272
|
result[nodeIdentifier] = {
|
|
228
273
|
parsed: innerHTML || textContent,
|
|
229
|
-
element,
|
|
274
|
+
element: elementForBindings,
|
|
230
275
|
children: {},
|
|
231
276
|
...(hasAttributeBindings && { attributes }),
|
|
232
277
|
...(hasNameBindings && { nameBindings }),
|
|
278
|
+
...(textNodeRef && { textNode: textNodeRef }),
|
|
233
279
|
};
|
|
234
280
|
}
|
|
235
281
|
}
|
package/runtime/scope.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Scope resolution for component state
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Find the component ID that owns this element
|
|
5
|
+
* Walks up DOM to find nearest script[data-vibe-component-id]
|
|
6
|
+
* @param {Element} element - DOM element to find component for
|
|
7
|
+
* @returns {string|null} - Component ID or null if not in component scope
|
|
8
|
+
*/
|
|
9
|
+
export const findComponentId = (element) => {
|
|
10
|
+
let current = element;
|
|
11
|
+
|
|
12
|
+
while (current && current !== document.body) {
|
|
13
|
+
// Check if this element has component ID
|
|
14
|
+
if (current.hasAttribute && current.hasAttribute('data-vibe-component-id')) {
|
|
15
|
+
return current.getAttribute('data-vibe-component-id');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Check previous siblings for script[data-vibe-component-id]
|
|
19
|
+
let sibling = current.previousElementSibling;
|
|
20
|
+
while (sibling) {
|
|
21
|
+
if (
|
|
22
|
+
sibling.tagName === 'SCRIPT' &&
|
|
23
|
+
sibling.getAttribute('type') === 'component' &&
|
|
24
|
+
sibling.hasAttribute('data-vibe-component-id')
|
|
25
|
+
) {
|
|
26
|
+
return sibling.getAttribute('data-vibe-component-id');
|
|
27
|
+
}
|
|
28
|
+
sibling = sibling.previousElementSibling;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
current = current.parentElement;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return null;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Resolve a property path with component scope fallback
|
|
39
|
+
* Checks component state first, then global state
|
|
40
|
+
* @param {string} path - Property path (e.g., "count" or "user.name")
|
|
41
|
+
* @param {Element} element - DOM element for context
|
|
42
|
+
* @param {object} globalState - Global $ object
|
|
43
|
+
* @returns {*} - Resolved value
|
|
44
|
+
*/
|
|
45
|
+
export const resolveWithScope = (path, element, globalState) => {
|
|
46
|
+
const componentId = findComponentId(element);
|
|
47
|
+
|
|
48
|
+
if (componentId && globalState[componentId]) {
|
|
49
|
+
// Check component state first
|
|
50
|
+
const componentState = globalState[componentId];
|
|
51
|
+
const value = resolvePath(path, componentState);
|
|
52
|
+
|
|
53
|
+
if (value !== undefined) {
|
|
54
|
+
return value;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Fallback to global state
|
|
59
|
+
return resolvePath(path, globalState);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Resolve a dot-notation path in an object
|
|
64
|
+
* @param {string} path - Property path
|
|
65
|
+
* @param {object} obj - Object to resolve path in
|
|
66
|
+
* @returns {*} - Resolved value or undefined
|
|
67
|
+
*/
|
|
68
|
+
const resolvePath = (path, obj) => {
|
|
69
|
+
return path.split('.').reduce((current, key) => current?.[key], obj);
|
|
70
|
+
};
|
package/runtime/state.js
CHANGED
|
@@ -16,17 +16,22 @@ const createDeepProxy = (target, rerender, rootState = null, rootProp = null) =>
|
|
|
16
16
|
const proxy = new Proxy(target, {
|
|
17
17
|
set(obj, prop, value) {
|
|
18
18
|
const oldValue = obj[prop];
|
|
19
|
-
const ref = Reflect.set(obj, prop, value);
|
|
20
19
|
|
|
21
20
|
// Only trigger rerender if value actually changed
|
|
22
21
|
if (oldValue !== value) {
|
|
23
|
-
//
|
|
24
|
-
|
|
22
|
+
// Perform the mutation
|
|
23
|
+
const ref = Reflect.set(obj, prop, value);
|
|
24
|
+
|
|
25
|
+
// Trigger rerender with changed state
|
|
26
|
+
// If we're nested, use the root prop; otherwise use the prop itself
|
|
25
27
|
const changedProp = rootProp || prop;
|
|
26
|
-
rerender({ [changedProp]: rootState[changedProp] });
|
|
28
|
+
rerender({ [changedProp]: rootState[changedProp] }, null);
|
|
29
|
+
|
|
30
|
+
return ref;
|
|
27
31
|
}
|
|
28
32
|
|
|
29
|
-
|
|
33
|
+
// No change, just set
|
|
34
|
+
return Reflect.set(obj, prop, value);
|
|
30
35
|
},
|
|
31
36
|
|
|
32
37
|
get(target, prop) {
|