@ape-egg/vibe 1.1.2 → 1.3.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 +140 -0
- package/README.md +30 -23
- package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
- package/compiler/src/compiler/compile.rs +204 -51
- package/compiler/src/compiler/manifest_builder.rs +432 -0
- package/compiler/src/compiler/mod.rs +3 -0
- package/compiler/src/compiler/state_extractor.rs +148 -0
- package/compiler/src/compiler/value_stamper.rs +222 -0
- package/compiler/src/config.rs +0 -5
- package/compiler/src/main.rs +45 -20
- package/compiler/src/parser/html.rs +22 -8
- package/index.js +40 -2
- package/package.json +2 -1
- package/runtime/affected.js +44 -24
- package/runtime/cleanup.js +45 -24
- package/runtime/component-state.js +63 -0
- package/runtime/component.js +59 -15
- package/runtime/conditionals.js +4 -4
- package/runtime/constants.js +46 -2
- package/runtime/debug.js +1 -0
- package/runtime/hydrate.js +23 -21
- package/runtime/hyperspeed.js +425 -0
- package/runtime/index.js +322 -69
- package/runtime/iterate.js +15 -4
- package/runtime/parse.js +48 -10
- package/runtime/scope.js +50 -0
- package/runtime/state.js +10 -5
- package/runtime/utils.js +63 -5
- package/{runtime/vibe.css → vibe.css} +4 -2
package/runtime/affected.js
CHANGED
|
@@ -1,19 +1,25 @@
|
|
|
1
1
|
import { resolvePath, deepEqual } from './iteration-utils.js';
|
|
2
2
|
import { extractDependencies } from './conditionals.js';
|
|
3
3
|
import { BINDING_REGEX } from './constants.js';
|
|
4
|
-
import { evalInScope } from './utils.js';
|
|
4
|
+
import { evalInScope, resolveThisPath } from './utils.js';
|
|
5
5
|
|
|
6
6
|
// Evaluate conditional expression
|
|
7
|
-
const evaluateCondition = (expression, state) => !!evalInScope(expression, state);
|
|
7
|
+
const evaluateCondition = (expression, state, element = null) => !!evalInScope(expression, state, element);
|
|
8
8
|
|
|
9
9
|
// Helper function to check if a match references a specific key
|
|
10
|
-
const matchesKey = (matchStr, key) =>
|
|
10
|
+
const matchesKey = (matchStr, key) =>
|
|
11
|
+
matchStr === key ||
|
|
12
|
+
matchStr.startsWith(key + '.') ||
|
|
13
|
+
matchStr.startsWith(key + '[');
|
|
11
14
|
|
|
12
|
-
const recursive = (tree, state, newState, affected, scopedStateForHydration = null) => {
|
|
15
|
+
const recursive = (tree, state, newState, affected, scopedStateForHydration = null, depth = 0) => {
|
|
13
16
|
// Handle iteration nodes specially
|
|
14
17
|
if (tree.type === 'iteration') {
|
|
15
|
-
|
|
16
|
-
const
|
|
18
|
+
// Handle this.property for component-scoped arrays
|
|
19
|
+
const arrayPath = resolveThisPath(tree.meta.arrayPath, tree.meta.startComment?.parentElement);
|
|
20
|
+
|
|
21
|
+
const oldArray = resolvePath(state, arrayPath);
|
|
22
|
+
const newArray = resolvePath(newState, arrayPath);
|
|
17
23
|
|
|
18
24
|
// Fast path: reference comparison (arrays are typically replaced, not mutated)
|
|
19
25
|
// This avoids expensive O(n) deepEqual for large arrays
|
|
@@ -34,7 +40,8 @@ const recursive = (tree, state, newState, affected, scopedStateForHydration = nu
|
|
|
34
40
|
// Use the instance's scoped state (includes item, index, etc.)
|
|
35
41
|
// Merge newState into scopedState to get updated global values
|
|
36
42
|
const mergedNewState = { ...instance.scopedState, ...newState };
|
|
37
|
-
|
|
43
|
+
// Pass the Proxy as scopedState so evalInScope can access iteration variables
|
|
44
|
+
recursive(instance.tree, instance.scopedState, mergedNewState, affected, instance.scopedState, depth + 1);
|
|
38
45
|
}
|
|
39
46
|
}
|
|
40
47
|
}
|
|
@@ -44,8 +51,8 @@ const recursive = (tree, state, newState, affected, scopedStateForHydration = nu
|
|
|
44
51
|
|
|
45
52
|
// Handle conditional nodes specially
|
|
46
53
|
if (tree.type === 'conditional') {
|
|
47
|
-
const oldValue = evaluateCondition(tree.meta.expression, state);
|
|
48
|
-
const newValue = evaluateCondition(tree.meta.expression, newState);
|
|
54
|
+
const oldValue = evaluateCondition(tree.meta.expression, state, tree.meta.startComment?.parentElement);
|
|
55
|
+
const newValue = evaluateCondition(tree.meta.expression, newState, tree.meta.startComment?.parentElement);
|
|
49
56
|
|
|
50
57
|
// Check if condition result changed
|
|
51
58
|
if (oldValue !== newValue) {
|
|
@@ -59,7 +66,7 @@ const recursive = (tree, state, newState, affected, scopedStateForHydration = nu
|
|
|
59
66
|
|
|
60
67
|
// Condition didn't change, check for affected elements inside active branch
|
|
61
68
|
if (tree.runtime.activeInstance && tree.runtime.activeInstance.parsedTree) {
|
|
62
|
-
return recursive(tree.runtime.activeInstance.parsedTree, state, newState, affected, scopedStateForHydration);
|
|
69
|
+
return recursive(tree.runtime.activeInstance.parsedTree, state, newState, affected, scopedStateForHydration, depth + 1);
|
|
63
70
|
}
|
|
64
71
|
|
|
65
72
|
return affected;
|
|
@@ -83,21 +90,24 @@ const recursive = (tree, state, newState, affected, scopedStateForHydration = nu
|
|
|
83
90
|
const checkedMatches = [];
|
|
84
91
|
|
|
85
92
|
for (const m of matches) {
|
|
86
|
-
|
|
93
|
+
// Resolve this.property to componentId.property
|
|
94
|
+
const resolvedInner = resolveThisPath(m.inner, tree.element);
|
|
95
|
+
|
|
96
|
+
const noMatch = !shallowState.some((key) => matchesKey(resolvedInner, key));
|
|
87
97
|
|
|
88
98
|
let shouldAffect = false;
|
|
89
99
|
let relevantKeys = [];
|
|
90
100
|
|
|
91
101
|
if (isInitialHydration) {
|
|
92
102
|
// Initial hydration: affect all matched keys
|
|
93
|
-
relevantKeys = shallowNewState.filter((key) => matchesKey(
|
|
103
|
+
relevantKeys = shallowNewState.filter((key) => matchesKey(resolvedInner, key));
|
|
94
104
|
shouldAffect = noMatch || relevantKeys.length > 0;
|
|
95
105
|
} else {
|
|
96
106
|
// Update: only affect if value changed
|
|
97
107
|
const changedKeys = shallowNewState.filter((key) =>
|
|
98
|
-
matchesKey(
|
|
108
|
+
matchesKey(resolvedInner, key) && state[key] !== newState[key]
|
|
99
109
|
);
|
|
100
|
-
relevantKeys = changedKeys.length > 0 ? changedKeys : shallowState.filter((key) => matchesKey(
|
|
110
|
+
relevantKeys = changedKeys.length > 0 ? changedKeys : shallowState.filter((key) => matchesKey(resolvedInner, key));
|
|
101
111
|
shouldAffect = noMatch || changedKeys.length > 0;
|
|
102
112
|
}
|
|
103
113
|
|
|
@@ -119,6 +129,7 @@ const recursive = (tree, state, newState, affected, scopedStateForHydration = nu
|
|
|
119
129
|
input: m.input,
|
|
120
130
|
matches: m.matches,
|
|
121
131
|
element: tree.element,
|
|
132
|
+
textNode: tree.textNode, // Reference to specific text node (prevents wiping children)
|
|
122
133
|
scopedState: scopedStateForHydration, // Pass scoped state from iteration context
|
|
123
134
|
});
|
|
124
135
|
}
|
|
@@ -140,18 +151,21 @@ const recursive = (tree, state, newState, affected, scopedStateForHydration = nu
|
|
|
140
151
|
}
|
|
141
152
|
|
|
142
153
|
for (const m of attrMatches) {
|
|
143
|
-
|
|
154
|
+
// Resolve this.property to componentId.property
|
|
155
|
+
const resolvedInner = resolveThisPath(m.inner, tree.element);
|
|
156
|
+
|
|
157
|
+
const noMatch = !shallowState.some((key) => matchesKey(resolvedInner, key));
|
|
144
158
|
|
|
145
159
|
let shouldAffect = false;
|
|
146
160
|
|
|
147
161
|
if (isInitialHydration) {
|
|
148
162
|
// Initial hydration: affect all matched keys
|
|
149
|
-
const newMatches = shallowNewState.filter((key) => matchesKey(
|
|
163
|
+
const newMatches = shallowNewState.filter((key) => matchesKey(resolvedInner, key));
|
|
150
164
|
shouldAffect = noMatch || newMatches.length > 0;
|
|
151
165
|
} else {
|
|
152
166
|
// Update: only affect if value changed
|
|
153
167
|
const changedKeys = shallowNewState.filter((key) =>
|
|
154
|
-
matchesKey(
|
|
168
|
+
matchesKey(resolvedInner, key) && state[key] !== newState[key]
|
|
155
169
|
);
|
|
156
170
|
shouldAffect = noMatch || changedKeys.length > 0;
|
|
157
171
|
}
|
|
@@ -162,7 +176,7 @@ const recursive = (tree, state, newState, affected, scopedStateForHydration = nu
|
|
|
162
176
|
attrName,
|
|
163
177
|
attrValue,
|
|
164
178
|
matchOuter: m.outer,
|
|
165
|
-
matchInner: m.inner,
|
|
179
|
+
matchInner: m.inner, // Keep original, evalInScope will resolve this.
|
|
166
180
|
element: tree.element,
|
|
167
181
|
scopedState: scopedStateForHydration, // Pass scoped state from iteration context
|
|
168
182
|
});
|
|
@@ -186,16 +200,19 @@ const recursive = (tree, state, newState, affected, scopedStateForHydration = nu
|
|
|
186
200
|
}
|
|
187
201
|
|
|
188
202
|
for (const m of nameMatches) {
|
|
189
|
-
|
|
203
|
+
// Resolve this.property to componentId.property
|
|
204
|
+
const resolvedInner = resolveThisPath(m.inner, tree.element);
|
|
205
|
+
|
|
206
|
+
const noMatch = !shallowState.some((key) => matchesKey(resolvedInner, key));
|
|
190
207
|
|
|
191
208
|
let shouldAffect = false;
|
|
192
209
|
|
|
193
210
|
if (isInitialHydration) {
|
|
194
|
-
const newMatches = shallowNewState.filter((key) => matchesKey(
|
|
211
|
+
const newMatches = shallowNewState.filter((key) => matchesKey(resolvedInner, key));
|
|
195
212
|
shouldAffect = noMatch || newMatches.length > 0;
|
|
196
213
|
} else {
|
|
197
214
|
const changedKeys = shallowNewState.filter((key) =>
|
|
198
|
-
matchesKey(
|
|
215
|
+
matchesKey(resolvedInner, key) && state[key] !== newState[key]
|
|
199
216
|
);
|
|
200
217
|
shouldAffect = noMatch || changedKeys.length > 0;
|
|
201
218
|
}
|
|
@@ -205,7 +222,7 @@ const recursive = (tree, state, newState, affected, scopedStateForHydration = nu
|
|
|
205
222
|
type: 'nameBinding',
|
|
206
223
|
nameBinding,
|
|
207
224
|
matchOuter: m.outer,
|
|
208
|
-
matchInner: m.inner,
|
|
225
|
+
matchInner: m.inner, // Keep original, evalInScope will resolve this.
|
|
209
226
|
element: tree.element,
|
|
210
227
|
scopedState: scopedStateForHydration,
|
|
211
228
|
});
|
|
@@ -220,7 +237,7 @@ const recursive = (tree, state, newState, affected, scopedStateForHydration = nu
|
|
|
220
237
|
for (const key in children) {
|
|
221
238
|
const child = children[key];
|
|
222
239
|
if (child && typeof child === 'object') {
|
|
223
|
-
recursive(child, state, newState, affected, scopedStateForHydration);
|
|
240
|
+
recursive(child, state, newState, affected, scopedStateForHydration, depth + 1);
|
|
224
241
|
}
|
|
225
242
|
}
|
|
226
243
|
}
|
|
@@ -228,4 +245,7 @@ const recursive = (tree, state, newState, affected, scopedStateForHydration = nu
|
|
|
228
245
|
return affected;
|
|
229
246
|
};
|
|
230
247
|
|
|
231
|
-
export default (tree, state, newState) =>
|
|
248
|
+
export default (tree, state, newState) => {
|
|
249
|
+
const affected = recursive(tree, state, newState, [], null, 0);
|
|
250
|
+
return affected;
|
|
251
|
+
};
|
package/runtime/cleanup.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { debugLog } from './debug.js';
|
|
2
|
-
import {
|
|
2
|
+
import { PHASE_READY, FOUC_CLASS_OR_ATTR } from './constants.js';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Check if all Vibe processing is complete and cleanup can run
|
|
@@ -7,30 +7,27 @@ import { PHASE_COMPLETE } from './constants.js';
|
|
|
7
7
|
* @returns {Boolean} - true if cleanup should run
|
|
8
8
|
*/
|
|
9
9
|
export const shouldCleanup = (rootElement) => {
|
|
10
|
-
// 1. Check for pending <component> elements
|
|
11
|
-
|
|
10
|
+
// 1. Check for pending <component> elements (only those with src - fetched components)
|
|
11
|
+
// Inline component wrappers (<component> without src) are fine to remain
|
|
12
|
+
const componentElements = rootElement.querySelectorAll('component[src], div.component[src]');
|
|
12
13
|
if (componentElements.length > 0) {
|
|
13
14
|
return false;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
// 2. Check for unhydrated bindings (literal @[...] in DOM, excluding dehydrated elements)
|
|
17
|
-
const walker = document.createTreeWalker(
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
while (parent && parent !== rootElement) {
|
|
25
|
-
if (parent.hasAttribute('dehydrate')) {
|
|
26
|
-
return NodeFilter.FILTER_REJECT; // Skip dehydrated content
|
|
27
|
-
}
|
|
28
|
-
parent = parent.parentElement;
|
|
18
|
+
const walker = document.createTreeWalker(rootElement, NodeFilter.SHOW_TEXT, {
|
|
19
|
+
acceptNode(node) {
|
|
20
|
+
// Check if this text node is inside a dehydrated element
|
|
21
|
+
let parent = node.parentElement;
|
|
22
|
+
while (parent && parent !== rootElement) {
|
|
23
|
+
if (parent.hasAttribute('dehydrate')) {
|
|
24
|
+
return NodeFilter.FILTER_REJECT; // Skip dehydrated content
|
|
29
25
|
}
|
|
30
|
-
|
|
26
|
+
parent = parent.parentElement;
|
|
31
27
|
}
|
|
32
|
-
|
|
33
|
-
|
|
28
|
+
return NodeFilter.FILTER_ACCEPT;
|
|
29
|
+
},
|
|
30
|
+
});
|
|
34
31
|
|
|
35
32
|
let node;
|
|
36
33
|
while ((node = walker.nextNode())) {
|
|
@@ -44,16 +41,40 @@ export const shouldCleanup = (rootElement) => {
|
|
|
44
41
|
};
|
|
45
42
|
|
|
46
43
|
/**
|
|
47
|
-
* Perform cleanup - remove
|
|
44
|
+
* Perform cleanup - remove attribute or class from all matching elements
|
|
48
45
|
* @param {Element} rootElement - Root element
|
|
49
|
-
* @param {String} attrName - Attribute name to remove (default: 'vibe')
|
|
50
46
|
* @param {Boolean} debug - Debug mode
|
|
51
47
|
*/
|
|
52
|
-
export const cleanup = (rootElement,
|
|
48
|
+
export const cleanup = (rootElement, debug = false) => {
|
|
53
49
|
// Force reflow
|
|
54
50
|
rootElement.offsetHeight;
|
|
55
51
|
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
|
|
52
|
+
// Determine if selector is a class (starts with .) or attribute (default)
|
|
53
|
+
const isClass = FOUC_CLASS_OR_ATTR.startsWith('.');
|
|
54
|
+
const cleanName = FOUC_CLASS_OR_ATTR.replace(/^\./, '').replace(/^\[/, '').replace(/\]$/, '');
|
|
55
|
+
|
|
56
|
+
if (isClass) {
|
|
57
|
+
// Remove class from all elements in document that have it
|
|
58
|
+
const elements = document.querySelectorAll(`.${cleanName}`);
|
|
59
|
+
elements.forEach((el) => el.classList.remove(cleanName));
|
|
60
|
+
debugLog(PHASE_READY, `removing .${cleanName} class from ${elements.length} elements`, debug);
|
|
61
|
+
} else {
|
|
62
|
+
// Remove attribute from all elements in document that have it
|
|
63
|
+
const elements = document.querySelectorAll(`[${cleanName}]`);
|
|
64
|
+
elements.forEach((el) => el.removeAttribute(cleanName));
|
|
65
|
+
debugLog(
|
|
66
|
+
PHASE_READY,
|
|
67
|
+
`removing [${cleanName}] attribute from ${elements.length} elements`,
|
|
68
|
+
debug,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Dispatch ready event to signal that Vibe has completed all initial processing
|
|
73
|
+
if (typeof document !== 'undefined') {
|
|
74
|
+
document.dispatchEvent(
|
|
75
|
+
new CustomEvent('vibe:ready', {
|
|
76
|
+
detail: { rootElement, cleanName, isClass },
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
79
|
+
}
|
|
59
80
|
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Shared utility for processing component state
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Generate unique component ID
|
|
5
|
+
*/
|
|
6
|
+
export const generateComponentId = () => {
|
|
7
|
+
return `_c${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Transform variable declarations to property assignments
|
|
12
|
+
* let count = 0; → this.count = 0;
|
|
13
|
+
*/
|
|
14
|
+
const transformDeclarations = (code) => {
|
|
15
|
+
return code
|
|
16
|
+
// Transform: let varName = value;
|
|
17
|
+
.replace(/\b(let|const|var)\s+(\w+)\s*=\s*([^;]+);/g, 'this.$2 = $3;')
|
|
18
|
+
// Transform: let varName;
|
|
19
|
+
.replace(/\b(let|const|var)\s+(\w+)\s*;/g, 'this.$2 = undefined;');
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Execute component script and return state object
|
|
24
|
+
* @param {string} scriptContent - JavaScript code to execute
|
|
25
|
+
* @returns {object} - Component state object
|
|
26
|
+
*/
|
|
27
|
+
export const executeComponentScript = (scriptContent) => {
|
|
28
|
+
const componentState = {};
|
|
29
|
+
|
|
30
|
+
if (!scriptContent || !scriptContent.trim()) {
|
|
31
|
+
return componentState;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
// Transform declarations to property assignments
|
|
36
|
+
const transformed = transformDeclarations(scriptContent);
|
|
37
|
+
|
|
38
|
+
// Execute script with 'this' = componentState
|
|
39
|
+
const fn = new Function(transformed);
|
|
40
|
+
fn.call(componentState);
|
|
41
|
+
|
|
42
|
+
} catch (error) {
|
|
43
|
+
console.error('[vibe] Error executing component script:', error);
|
|
44
|
+
console.error('[vibe] Script content:', scriptContent);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return componentState;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Register component state in global store
|
|
52
|
+
* @param {string} componentId - Unique component identifier
|
|
53
|
+
* @param {object} state - Component state object
|
|
54
|
+
* @param {object} globalState - Global $ object
|
|
55
|
+
*/
|
|
56
|
+
export const registerComponentState = (componentId, state, globalState) => {
|
|
57
|
+
if (!globalState[componentId]) {
|
|
58
|
+
globalState[componentId] = state;
|
|
59
|
+
} else {
|
|
60
|
+
// Component already registered, merge new state
|
|
61
|
+
Object.assign(globalState[componentId], state);
|
|
62
|
+
}
|
|
63
|
+
};
|
package/runtime/component.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { debugLog } from './debug.js';
|
|
2
2
|
import { PHASE_FETCH } from './constants.js';
|
|
3
3
|
import { evalInScope } from './utils.js';
|
|
4
|
+
import { generateComponentId, executeComponentScript } from './component-state.js';
|
|
4
5
|
|
|
5
6
|
// Helper to escape regex special characters
|
|
6
7
|
const escapeRegex = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
@@ -19,7 +20,10 @@ export const abortComponentFetch = (element) => {
|
|
|
19
20
|
|
|
20
21
|
export const processComponent = (rootElement, onComplete, config = {}) => {
|
|
21
22
|
const debug = !!config?.debug;
|
|
22
|
-
|
|
23
|
+
// Only process component elements with src attribute (fetched components)
|
|
24
|
+
// Supports: <component src="..."> and <div class="component" src="...">
|
|
25
|
+
// Ignores: <component> and <div class="component"> (inline component wrappers)
|
|
26
|
+
const componentElements = rootElement.querySelectorAll('component[src], div.component[src]');
|
|
23
27
|
|
|
24
28
|
if (componentElements.length === 0) {
|
|
25
29
|
if (onComplete) onComplete();
|
|
@@ -30,12 +34,6 @@ export const processComponent = (rootElement, onComplete, config = {}) => {
|
|
|
30
34
|
const el = componentElements[0];
|
|
31
35
|
const src = el.getAttribute('src');
|
|
32
36
|
|
|
33
|
-
if (!src) {
|
|
34
|
-
el.remove();
|
|
35
|
-
// Don't recursively call - let MutationObserver handle it
|
|
36
|
-
return;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
37
|
// Capture children and props before fetching
|
|
40
38
|
const children = el.innerHTML.trim();
|
|
41
39
|
const props = {};
|
|
@@ -52,18 +50,54 @@ export const processComponent = (rootElement, onComplete, config = {}) => {
|
|
|
52
50
|
fetch(src, { signal: controller.signal })
|
|
53
51
|
.then((r) => r.text())
|
|
54
52
|
.then((html) => {
|
|
55
|
-
//
|
|
56
|
-
|
|
53
|
+
// Parse HTML in temporary container to process component scripts
|
|
54
|
+
const temp = document.createElement('div');
|
|
55
|
+
temp.innerHTML = html;
|
|
56
|
+
|
|
57
|
+
// Process any <script type="component"> elements
|
|
58
|
+
const componentScripts = temp.querySelectorAll('script[type="component"]');
|
|
59
|
+
componentScripts.forEach((script) => {
|
|
60
|
+
const scriptContent = script.textContent?.trim() || '';
|
|
61
|
+
if (!scriptContent) return;
|
|
62
|
+
|
|
63
|
+
// Generate component ID
|
|
64
|
+
const componentId = generateComponentId();
|
|
65
|
+
|
|
66
|
+
// Tag script element
|
|
67
|
+
script.setAttribute('data-vibe-component-id', componentId);
|
|
68
|
+
|
|
69
|
+
// Tag following siblings with this component ID
|
|
70
|
+
let sibling = script.nextElementSibling;
|
|
71
|
+
while (sibling) {
|
|
72
|
+
// Stop if we hit another component script
|
|
73
|
+
if (sibling.tagName === 'SCRIPT' && sibling.getAttribute('type') === 'component') {
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
sibling.setAttribute('data-vibe-component-id', componentId);
|
|
77
|
+
sibling = sibling.nextElementSibling;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Execute component script to get state
|
|
81
|
+
const componentState = executeComponentScript(scriptContent);
|
|
82
|
+
|
|
83
|
+
// Register in global state
|
|
84
|
+
if (window.$) {
|
|
85
|
+
window.$[componentId] = componentState;
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Get transformed HTML from temp container
|
|
90
|
+
let transformedHtml = temp.innerHTML;
|
|
57
91
|
|
|
58
92
|
// Replace props
|
|
59
93
|
Object.entries(props).forEach(([propName, propValue]) => {
|
|
60
94
|
const bindingMatch = propValue.match(/^@\[(.+)\]$/);
|
|
61
95
|
|
|
62
96
|
if (bindingMatch) {
|
|
63
|
-
// Reactive prop: replace propName
|
|
97
|
+
// Reactive prop: replace @[propName] with @[path]
|
|
64
98
|
const path = bindingMatch[1];
|
|
65
|
-
const propPattern = new RegExp(
|
|
66
|
-
transformedHtml = transformedHtml.replace(propPattern, path);
|
|
99
|
+
const propPattern = new RegExp(`@\\[${escapeRegex(propName)}\\]`, 'g');
|
|
100
|
+
transformedHtml = transformedHtml.replace(propPattern, `@[${path}]`);
|
|
67
101
|
} else {
|
|
68
102
|
// Static prop: replace @[propName] with literal value
|
|
69
103
|
const propPattern = new RegExp(`@\\[${escapeRegex(propName)}\\]`, 'g');
|
|
@@ -80,14 +114,24 @@ export const processComponent = (rootElement, onComplete, config = {}) => {
|
|
|
80
114
|
// Clean up pending fetch tracker
|
|
81
115
|
pendingFetches.delete(el);
|
|
82
116
|
|
|
83
|
-
//
|
|
117
|
+
// Replace with clean component wrapper (no src, no props)
|
|
84
118
|
// Check if element still has a parent (might have been removed during fetch)
|
|
85
119
|
if (el.parentNode) {
|
|
86
|
-
|
|
120
|
+
// Create clean wrapper element (preserve tag type: component or div.component)
|
|
121
|
+
const newWrapper = el.tagName === 'DIV'
|
|
122
|
+
? document.createElement('div')
|
|
123
|
+
: document.createElement('component');
|
|
124
|
+
|
|
125
|
+
if (el.tagName === 'DIV') {
|
|
126
|
+
newWrapper.className = 'component';
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
newWrapper.innerHTML = transformedHtml;
|
|
130
|
+
el.replaceWith(newWrapper);
|
|
87
131
|
debugLog(PHASE_FETCH, src, debug);
|
|
88
132
|
|
|
89
133
|
// Force immediate processing of the mutation (MutationObserver is async, but we need sync)
|
|
90
|
-
// Use microtask to process right after
|
|
134
|
+
// Use microtask to process right after replaceWith completes
|
|
91
135
|
if (config._forceSync && config._processMutations && config._observer) {
|
|
92
136
|
Promise.resolve().then(() => {
|
|
93
137
|
const pending = config._observer.takeRecords();
|
package/runtime/conditionals.js
CHANGED
|
@@ -5,7 +5,7 @@ import { createScopedState, renderAllIterations, initializeBlock } from './itera
|
|
|
5
5
|
import { evalInScope } from './utils.js';
|
|
6
6
|
|
|
7
7
|
// Evaluate conditional expression in state context
|
|
8
|
-
const evaluateCondition = (expression, state) => !!evalInScope(expression, state);
|
|
8
|
+
const evaluateCondition = (expression, state, element = null) => !!evalInScope(expression, state, element);
|
|
9
9
|
|
|
10
10
|
// Extract state dependencies from an expression
|
|
11
11
|
// e.g., "count > 5" → ["count"]
|
|
@@ -78,7 +78,7 @@ export const renderConditional = (node, state, manifest, parentScope = {}) => {
|
|
|
78
78
|
startComment.__vibeRendered = true;
|
|
79
79
|
|
|
80
80
|
// Evaluate condition with current state
|
|
81
|
-
const conditionResult = evaluateCondition(expression, state);
|
|
81
|
+
const conditionResult = evaluateCondition(expression, state, startComment.parentElement);
|
|
82
82
|
|
|
83
83
|
// Determine which branch to mount
|
|
84
84
|
const branchToMount = conditionResult ? branches.if : branches.else;
|
|
@@ -154,7 +154,7 @@ const unmountBranch = (node) => {
|
|
|
154
154
|
|
|
155
155
|
// Update conditional when dependencies change
|
|
156
156
|
export const updateConditional = (node, newState, oldState, manifest, parentScope = {}) => {
|
|
157
|
-
const { expression, branches } = node.meta;
|
|
157
|
+
const { expression, branches, startComment } = node.meta;
|
|
158
158
|
|
|
159
159
|
// If not yet rendered, skip (renderConditional handles initial render)
|
|
160
160
|
if (!node.runtime.templateRemoved) {
|
|
@@ -162,7 +162,7 @@ export const updateConditional = (node, newState, oldState, manifest, parentScop
|
|
|
162
162
|
}
|
|
163
163
|
|
|
164
164
|
// Evaluate expression with new state
|
|
165
|
-
const newConditionResult = evaluateCondition(expression, newState);
|
|
165
|
+
const newConditionResult = evaluateCondition(expression, newState, startComment?.parentElement);
|
|
166
166
|
const newBranchData = newConditionResult ? branches.if : branches.else;
|
|
167
167
|
|
|
168
168
|
// Check if branch changed (compare references)
|
package/runtime/constants.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
// Debug logger name
|
|
2
2
|
export const DEBUGGER_NAME = '[vibe-debug]:';
|
|
3
|
+
export const FOUC_CLASS_OR_ATTR = 'vibe-fouc'; // Class or attribute used to prevent FOUC (default: [vibe])
|
|
3
4
|
|
|
4
5
|
// Lifecycle phase names for debug logging
|
|
5
6
|
// ONE-OFF operations (run once during initialization)
|
|
6
7
|
export const PHASE_ATTACH = 'Attached'; // Latches onto DOM element (index.js)
|
|
7
8
|
export const PHASE_MANIFEST = 'Manifested'; // Creates DOM manifest (manifest.js)
|
|
8
9
|
export const PHASE_OBSERVE = 'Observer'; // Starts MutationObserver (index.js)
|
|
9
|
-
export const
|
|
10
|
+
export const PHASE_READY = 'Ready'; // Removes [vibe-fouc] attribute (or .vibe-fouc class) and dispatches vibe:ready event (cleanup.js)
|
|
10
11
|
|
|
11
12
|
// REPEATED operations (run during init + can repeat during runtime)
|
|
12
13
|
export const PHASE_PARSE = 'Parsed'; // Reads DOM structure (parse.js)
|
|
@@ -16,9 +17,12 @@ export const PHASE_CONDITION = 'Evaluated'; // Renders <!-- if --> blocks (condi
|
|
|
16
17
|
export const PHASE_FETCH = 'Fetched'; // Loads <component> content (component.js)
|
|
17
18
|
export const PHASE_UPDATE = 'Proxy'; // State changes trigger re-hydration (index.js)
|
|
18
19
|
export const PHASE_MUTATE = 'Mutation'; // DOM mutations detected by observer (index.js)
|
|
20
|
+
export const PHASE_HYPERSPEED = 'Hyperspeed'; // Restores @[...] markers from pre-compiled manifest (hyperspeed.js)
|
|
19
21
|
|
|
20
22
|
// Elements that should not have reactive bindings
|
|
21
|
-
|
|
23
|
+
// Note: COMPONENT is NOT in this list - inline component wrappers need to be parsed
|
|
24
|
+
// Fetched components (<component src="">) are handled separately by processComponent()
|
|
25
|
+
export const NON_REACTIVE_ELEMENTS = ['SCRIPT', 'HEAD', 'PRE'];
|
|
22
26
|
|
|
23
27
|
// Attributes where the string value is meaningful (should NOT be removed when falsy)
|
|
24
28
|
// All other attributes are treated as boolean-like (removed when falsy, present when truthy)
|
|
@@ -156,6 +160,46 @@ export const VALUE_ATTRS = [
|
|
|
156
160
|
// Properties that should be set directly on the DOM element (not as attributes)
|
|
157
161
|
export const DOM_PROPERTIES = ['value', 'checked', 'selected'];
|
|
158
162
|
|
|
163
|
+
// DOM element properties that should NOT be rewritten in event handlers
|
|
164
|
+
// When parsing onclick="this.value = this.inputValue", preserve this.value (DOM) but rewrite this.inputValue (component state)
|
|
165
|
+
export const DOM_ELEMENT_PROPERTIES = new Set([
|
|
166
|
+
'value',
|
|
167
|
+
'checked',
|
|
168
|
+
'selected',
|
|
169
|
+
'disabled',
|
|
170
|
+
'readOnly',
|
|
171
|
+
'files',
|
|
172
|
+
'tagName',
|
|
173
|
+
'nodeName',
|
|
174
|
+
'nodeType',
|
|
175
|
+
'classList',
|
|
176
|
+
'className',
|
|
177
|
+
'id',
|
|
178
|
+
'innerHTML',
|
|
179
|
+
'outerHTML',
|
|
180
|
+
'textContent',
|
|
181
|
+
'innerText',
|
|
182
|
+
'parentElement',
|
|
183
|
+
'parentNode',
|
|
184
|
+
'nextSibling',
|
|
185
|
+
'previousSibling',
|
|
186
|
+
'firstChild',
|
|
187
|
+
'lastChild',
|
|
188
|
+
'children',
|
|
189
|
+
'childNodes',
|
|
190
|
+
'offsetWidth',
|
|
191
|
+
'offsetHeight',
|
|
192
|
+
'clientWidth',
|
|
193
|
+
'clientHeight',
|
|
194
|
+
'scrollTop',
|
|
195
|
+
'scrollLeft',
|
|
196
|
+
'scrollWidth',
|
|
197
|
+
'scrollHeight',
|
|
198
|
+
'style',
|
|
199
|
+
'dataset',
|
|
200
|
+
'attributes',
|
|
201
|
+
]);
|
|
202
|
+
|
|
159
203
|
// Regex for matching reactive bindings (@[expression])
|
|
160
204
|
// Supports one level of nested brackets: @[items[0]] or @[obj[key]]
|
|
161
205
|
export const BINDING_REGEX = /\@\[((?:[^\[\]]|\[[^\]]*\])+)\]/g;
|
package/runtime/debug.js
CHANGED
|
@@ -14,6 +14,7 @@ const PHASE_COLORS = {
|
|
|
14
14
|
Fetched: 'oklch(0.59 0.12 307)', // muted purple
|
|
15
15
|
Mutation: 'oklch(0.60 0.11 240)', // muted blue
|
|
16
16
|
Proxy: 'oklch(0.60 0.11 240)', // muted blue
|
|
17
|
+
Hyperspeed: 'oklch(0.60 0.11 180)', // muted cyan
|
|
17
18
|
};
|
|
18
19
|
|
|
19
20
|
// Special colors for styled segments (Tailwind 500)
|