@ape-egg/vibe 1.0.5 → 1.1.2
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 +112 -0
- package/README.md +228 -23
- package/compiler/bin/vibe-compile.js +109 -0
- package/compiler/native/.gitkeep +0 -0
- package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
- package/compiler/src/Cargo.lock +1885 -0
- package/compiler/src/Cargo.toml +29 -0
- package/compiler/src/compiler/compile.rs +1209 -0
- package/compiler/src/compiler/mod.rs +5 -0
- package/compiler/src/config.rs +184 -0
- package/compiler/src/main.rs +284 -0
- package/compiler/src/parser/element.rs +96 -0
- package/compiler/src/parser/html.rs +335 -0
- package/compiler/src/parser/mod.rs +8 -0
- package/index.js +2 -248
- package/package.json +26 -3
- package/{affected.js → runtime/affected.js} +64 -4
- package/runtime/cleanup.js +59 -0
- package/runtime/component.js +116 -0
- package/{conditionals.js → runtime/conditionals.js} +25 -11
- package/{constants.js → runtime/constants.js} +23 -3
- package/runtime/debug.js +91 -0
- package/{hydrate.js → runtime/hydrate.js} +57 -7
- package/runtime/index.js +614 -0
- package/{iterate.js → runtime/iterate.js} +53 -45
- package/{iteration-utils.js → runtime/iteration-utils.js} +11 -1
- package/{parse.js → runtime/parse.js} +37 -7
- package/runtime/state.js +52 -0
- package/ROADMAP.md +0 -289
- package/llms.txt +0 -279
- package/state.js +0 -26
- /package/{_vibe-compiled-iteration-batch.js → runtime/_vibe-compiled-iteration-batch.js} +0 -0
- /package/{link.js → runtime/manifest.js} +0 -0
- /package/{utils.js → runtime/utils.js} +0 -0
- /package/{vibe.css → runtime/vibe.css} +0 -0
|
@@ -9,7 +9,7 @@ const evaluateCondition = (expression, state) => !!evalInScope(expression, state
|
|
|
9
9
|
// Helper function to check if a match references a specific key
|
|
10
10
|
const matchesKey = (matchStr, key) => matchStr === key || matchStr.startsWith(key + '.');
|
|
11
11
|
|
|
12
|
-
const recursive = (tree, state, newState, affected) => {
|
|
12
|
+
const recursive = (tree, state, newState, affected, scopedStateForHydration = null) => {
|
|
13
13
|
// Handle iteration nodes specially
|
|
14
14
|
if (tree.type === 'iteration') {
|
|
15
15
|
const oldArray = resolvePath(state, tree.meta.arrayPath);
|
|
@@ -23,7 +23,22 @@ const recursive = (tree, state, newState, affected) => {
|
|
|
23
23
|
node: tree,
|
|
24
24
|
changeType: 'array',
|
|
25
25
|
});
|
|
26
|
+
return affected;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Array didn't change, but check for affected elements inside iteration instances
|
|
30
|
+
// (e.g., when tutorialProgress changes, need to update checkmarks in menu items)
|
|
31
|
+
if (tree.runtime.instances) {
|
|
32
|
+
for (const instance of tree.runtime.instances) {
|
|
33
|
+
if (instance.tree && instance.scopedState) {
|
|
34
|
+
// Use the instance's scoped state (includes item, index, etc.)
|
|
35
|
+
// Merge newState into scopedState to get updated global values
|
|
36
|
+
const mergedNewState = { ...instance.scopedState, ...newState };
|
|
37
|
+
recursive(instance.tree, instance.scopedState, mergedNewState, affected, mergedNewState);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
26
40
|
}
|
|
41
|
+
|
|
27
42
|
return affected;
|
|
28
43
|
}
|
|
29
44
|
|
|
@@ -44,7 +59,7 @@ const recursive = (tree, state, newState, affected) => {
|
|
|
44
59
|
|
|
45
60
|
// Condition didn't change, check for affected elements inside active branch
|
|
46
61
|
if (tree.runtime.activeInstance && tree.runtime.activeInstance.parsedTree) {
|
|
47
|
-
return recursive(tree.runtime.activeInstance.parsedTree, state, newState, affected);
|
|
62
|
+
return recursive(tree.runtime.activeInstance.parsedTree, state, newState, affected, scopedStateForHydration);
|
|
48
63
|
}
|
|
49
64
|
|
|
50
65
|
return affected;
|
|
@@ -104,6 +119,7 @@ const recursive = (tree, state, newState, affected) => {
|
|
|
104
119
|
input: m.input,
|
|
105
120
|
matches: m.matches,
|
|
106
121
|
element: tree.element,
|
|
122
|
+
scopedState: scopedStateForHydration, // Pass scoped state from iteration context
|
|
107
123
|
});
|
|
108
124
|
}
|
|
109
125
|
}
|
|
@@ -148,6 +164,50 @@ const recursive = (tree, state, newState, affected) => {
|
|
|
148
164
|
matchOuter: m.outer,
|
|
149
165
|
matchInner: m.inner,
|
|
150
166
|
element: tree.element,
|
|
167
|
+
scopedState: scopedStateForHydration, // Pass scoped state from iteration context
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Check name bindings (bindings in attribute names)
|
|
175
|
+
if (tree.nameBindings) {
|
|
176
|
+
const shallowState = Object.keys(state);
|
|
177
|
+
const shallowNewState = Object.keys(newState);
|
|
178
|
+
const isInitialHydration = state === newState;
|
|
179
|
+
|
|
180
|
+
for (const nameBinding of tree.nameBindings) {
|
|
181
|
+
BINDING_REGEX.lastIndex = 0;
|
|
182
|
+
const nameMatches = [];
|
|
183
|
+
let nameMatch;
|
|
184
|
+
while ((nameMatch = BINDING_REGEX.exec(nameBinding))) {
|
|
185
|
+
nameMatches.push({ outer: nameMatch[0], inner: nameMatch[1] });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
for (const m of nameMatches) {
|
|
189
|
+
const noMatch = !shallowState.some((key) => matchesKey(m.inner, key));
|
|
190
|
+
|
|
191
|
+
let shouldAffect = false;
|
|
192
|
+
|
|
193
|
+
if (isInitialHydration) {
|
|
194
|
+
const newMatches = shallowNewState.filter((key) => matchesKey(m.inner, key));
|
|
195
|
+
shouldAffect = noMatch || newMatches.length > 0;
|
|
196
|
+
} else {
|
|
197
|
+
const changedKeys = shallowNewState.filter((key) =>
|
|
198
|
+
matchesKey(m.inner, key) && state[key] !== newState[key]
|
|
199
|
+
);
|
|
200
|
+
shouldAffect = noMatch || changedKeys.length > 0;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (shouldAffect) {
|
|
204
|
+
affected.push({
|
|
205
|
+
type: 'nameBinding',
|
|
206
|
+
nameBinding,
|
|
207
|
+
matchOuter: m.outer,
|
|
208
|
+
matchInner: m.inner,
|
|
209
|
+
element: tree.element,
|
|
210
|
+
scopedState: scopedStateForHydration,
|
|
151
211
|
});
|
|
152
212
|
}
|
|
153
213
|
}
|
|
@@ -160,7 +220,7 @@ const recursive = (tree, state, newState, affected) => {
|
|
|
160
220
|
for (const key in children) {
|
|
161
221
|
const child = children[key];
|
|
162
222
|
if (child && typeof child === 'object') {
|
|
163
|
-
recursive(child, state, newState, affected);
|
|
223
|
+
recursive(child, state, newState, affected, scopedStateForHydration);
|
|
164
224
|
}
|
|
165
225
|
}
|
|
166
226
|
}
|
|
@@ -168,4 +228,4 @@ const recursive = (tree, state, newState, affected) => {
|
|
|
168
228
|
return affected;
|
|
169
229
|
};
|
|
170
230
|
|
|
171
|
-
export default (tree, state, newState) => recursive(tree, state, newState, []);
|
|
231
|
+
export default (tree, state, newState) => recursive(tree, state, newState, [], null);
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { debugLog } from './debug.js';
|
|
2
|
+
import { PHASE_COMPLETE } from './constants.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Check if all Vibe processing is complete and cleanup can run
|
|
6
|
+
* @param {Element} rootElement - Root element to check
|
|
7
|
+
* @returns {Boolean} - true if cleanup should run
|
|
8
|
+
*/
|
|
9
|
+
export const shouldCleanup = (rootElement) => {
|
|
10
|
+
// 1. Check for pending <component> elements
|
|
11
|
+
const componentElements = rootElement.querySelectorAll('component');
|
|
12
|
+
if (componentElements.length > 0) {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// 2. Check for unhydrated bindings (literal @[...] in DOM, excluding dehydrated elements)
|
|
17
|
+
const walker = document.createTreeWalker(
|
|
18
|
+
rootElement,
|
|
19
|
+
NodeFilter.SHOW_TEXT,
|
|
20
|
+
{
|
|
21
|
+
acceptNode(node) {
|
|
22
|
+
// Check if this text node is inside a dehydrated element
|
|
23
|
+
let parent = node.parentElement;
|
|
24
|
+
while (parent && parent !== rootElement) {
|
|
25
|
+
if (parent.hasAttribute('dehydrate')) {
|
|
26
|
+
return NodeFilter.FILTER_REJECT; // Skip dehydrated content
|
|
27
|
+
}
|
|
28
|
+
parent = parent.parentElement;
|
|
29
|
+
}
|
|
30
|
+
return NodeFilter.FILTER_ACCEPT;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
let node;
|
|
36
|
+
while ((node = walker.nextNode())) {
|
|
37
|
+
if (/@\[.+?\]/.test(node.textContent)) {
|
|
38
|
+
return false; // Found literal binding (not in dehydrated element)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 3. All processing appears complete
|
|
43
|
+
return true;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Perform cleanup - remove vibe attribute to reveal content
|
|
48
|
+
* @param {Element} rootElement - Root element
|
|
49
|
+
* @param {String} attrName - Attribute name to remove (default: 'vibe')
|
|
50
|
+
* @param {Boolean} debug - Debug mode
|
|
51
|
+
*/
|
|
52
|
+
export const cleanup = (rootElement, attrName = 'vibe', debug = false) => {
|
|
53
|
+
// Force reflow
|
|
54
|
+
rootElement.offsetHeight;
|
|
55
|
+
|
|
56
|
+
// Remove vibe attribute
|
|
57
|
+
debugLog(PHASE_COMPLETE, `removing [${attrName}] attribute`, debug);
|
|
58
|
+
rootElement.removeAttribute(attrName);
|
|
59
|
+
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { debugLog } from './debug.js';
|
|
2
|
+
import { PHASE_FETCH } from './constants.js';
|
|
3
|
+
import { evalInScope } from './utils.js';
|
|
4
|
+
|
|
5
|
+
// Helper to escape regex special characters
|
|
6
|
+
const escapeRegex = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
7
|
+
|
|
8
|
+
// Track pending fetches to cancel them if element is removed
|
|
9
|
+
const pendingFetches = new WeakMap(); // element → AbortController
|
|
10
|
+
|
|
11
|
+
// Cancel a pending fetch for a component element
|
|
12
|
+
export const abortComponentFetch = (element) => {
|
|
13
|
+
const controller = pendingFetches.get(element);
|
|
14
|
+
if (controller) {
|
|
15
|
+
controller.abort();
|
|
16
|
+
pendingFetches.delete(element);
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const processComponent = (rootElement, onComplete, config = {}) => {
|
|
21
|
+
const debug = !!config?.debug;
|
|
22
|
+
const componentElements = rootElement.querySelectorAll('component');
|
|
23
|
+
|
|
24
|
+
if (componentElements.length === 0) {
|
|
25
|
+
if (onComplete) onComplete();
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Process just the first element - MutationObserver will trigger next call
|
|
30
|
+
const el = componentElements[0];
|
|
31
|
+
const src = el.getAttribute('src');
|
|
32
|
+
|
|
33
|
+
if (!src) {
|
|
34
|
+
el.remove();
|
|
35
|
+
// Don't recursively call - let MutationObserver handle it
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Capture children and props before fetching
|
|
40
|
+
const children = el.innerHTML.trim();
|
|
41
|
+
const props = {};
|
|
42
|
+
Array.from(el.attributes).forEach((attr) => {
|
|
43
|
+
if (attr.name !== 'src') {
|
|
44
|
+
props[attr.name] = attr.value;
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Create AbortController to cancel fetch if element is removed
|
|
49
|
+
const controller = new AbortController();
|
|
50
|
+
pendingFetches.set(el, controller);
|
|
51
|
+
|
|
52
|
+
fetch(src, { signal: controller.signal })
|
|
53
|
+
.then((r) => r.text())
|
|
54
|
+
.then((html) => {
|
|
55
|
+
// Transform the fetched HTML
|
|
56
|
+
let transformedHtml = html;
|
|
57
|
+
|
|
58
|
+
// Replace props
|
|
59
|
+
Object.entries(props).forEach(([propName, propValue]) => {
|
|
60
|
+
const bindingMatch = propValue.match(/^@\[(.+)\]$/);
|
|
61
|
+
|
|
62
|
+
if (bindingMatch) {
|
|
63
|
+
// Reactive prop: replace propName as word boundary
|
|
64
|
+
const path = bindingMatch[1];
|
|
65
|
+
const propPattern = new RegExp(`\\b${escapeRegex(propName)}\\b`, 'g');
|
|
66
|
+
transformedHtml = transformedHtml.replace(propPattern, path);
|
|
67
|
+
} else {
|
|
68
|
+
// Static prop: replace @[propName] with literal value
|
|
69
|
+
const propPattern = new RegExp(`@\\[${escapeRegex(propName)}\\]`, 'g');
|
|
70
|
+
transformedHtml = transformedHtml.replace(propPattern, propValue);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Replace <slot></slot> with children
|
|
75
|
+
if (children) {
|
|
76
|
+
transformedHtml = transformedHtml.replace(/<slot><\/slot>/g, children);
|
|
77
|
+
transformedHtml = transformedHtml.replace(/<slot\s*\/>/g, children);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Clean up pending fetch tracker
|
|
81
|
+
pendingFetches.delete(el);
|
|
82
|
+
|
|
83
|
+
// Set outerHTML - this triggers MutationObserver
|
|
84
|
+
// Check if element still has a parent (might have been removed during fetch)
|
|
85
|
+
if (el.parentNode) {
|
|
86
|
+
el.outerHTML = transformedHtml;
|
|
87
|
+
debugLog(PHASE_FETCH, src, debug);
|
|
88
|
+
|
|
89
|
+
// Force immediate processing of the mutation (MutationObserver is async, but we need sync)
|
|
90
|
+
// Use microtask to process right after outerHTML completes
|
|
91
|
+
if (config._forceSync && config._processMutations && config._observer) {
|
|
92
|
+
Promise.resolve().then(() => {
|
|
93
|
+
const pending = config._observer.takeRecords();
|
|
94
|
+
if (pending.length > 0) {
|
|
95
|
+
config._processMutations(pending);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
.catch((error) => {
|
|
102
|
+
// Clean up pending fetch tracker
|
|
103
|
+
pendingFetches.delete(el);
|
|
104
|
+
|
|
105
|
+
// If fetch was aborted (element removed), silently skip
|
|
106
|
+
if (error.name === 'AbortError') {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
console.error('[vibe] Failed to load:', src, error);
|
|
111
|
+
if (el.parentNode) {
|
|
112
|
+
el.remove();
|
|
113
|
+
}
|
|
114
|
+
// Don't recursively call - let MutationObserver handle it
|
|
115
|
+
});
|
|
116
|
+
};
|
|
@@ -25,11 +25,13 @@ export const extractDependencies = (expression) => {
|
|
|
25
25
|
};
|
|
26
26
|
|
|
27
27
|
// Render all conditionals in the parsed tree
|
|
28
|
-
export const renderAllConditionals = (tree, state,
|
|
28
|
+
export const renderAllConditionals = (tree, state, manifest, parentScope = {}) => {
|
|
29
|
+
let count = 0;
|
|
30
|
+
|
|
29
31
|
// If this is a conditional node, render it
|
|
30
32
|
if (tree.type === 'conditional') {
|
|
31
|
-
renderConditional(tree, state,
|
|
32
|
-
return;
|
|
33
|
+
renderConditional(tree, state, manifest, parentScope);
|
|
34
|
+
return 1;
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
// Recursively render conditionals in child nodes
|
|
@@ -37,16 +39,24 @@ export const renderAllConditionals = (tree, state, linkList, parentScope = {}) =
|
|
|
37
39
|
Object.keys(tree.children).forEach((key) => {
|
|
38
40
|
const child = tree.children[key];
|
|
39
41
|
if (typeof child === 'object' && child !== null) {
|
|
40
|
-
renderAllConditionals(child, state,
|
|
42
|
+
count += renderAllConditionals(child, state, manifest, parentScope);
|
|
41
43
|
}
|
|
42
44
|
});
|
|
43
45
|
}
|
|
46
|
+
|
|
47
|
+
return count;
|
|
44
48
|
};
|
|
45
49
|
|
|
46
50
|
// Initial render of a conditional block
|
|
47
|
-
export const renderConditional = (node, state,
|
|
51
|
+
export const renderConditional = (node, state, manifest, parentScope = {}) => {
|
|
48
52
|
const { expression, startComment, endComment, branches } = node.meta;
|
|
49
53
|
|
|
54
|
+
// Check if already rendered (using marker on comment node)
|
|
55
|
+
// @ts-ignore - adding custom property to comment node
|
|
56
|
+
if (startComment.__vibeRendered) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
50
60
|
// Remove original template nodes from DOM (between start and end comments)
|
|
51
61
|
// Only do this on first render (when activeBranch is undefined)
|
|
52
62
|
if (node.runtime.activeBranch === undefined) {
|
|
@@ -63,6 +73,10 @@ export const renderConditional = (node, state, linkList, parentScope = {}) => {
|
|
|
63
73
|
node.runtime.templateRemoved = true;
|
|
64
74
|
}
|
|
65
75
|
|
|
76
|
+
// Mark comment as rendered (survives re-parsing)
|
|
77
|
+
// @ts-ignore - adding custom property to comment node
|
|
78
|
+
startComment.__vibeRendered = true;
|
|
79
|
+
|
|
66
80
|
// Evaluate condition with current state
|
|
67
81
|
const conditionResult = evaluateCondition(expression, state);
|
|
68
82
|
|
|
@@ -70,14 +84,14 @@ export const renderConditional = (node, state, linkList, parentScope = {}) => {
|
|
|
70
84
|
const branchToMount = conditionResult ? branches.if : branches.else;
|
|
71
85
|
|
|
72
86
|
// Mount the appropriate branch
|
|
73
|
-
mountBranch(node, branchToMount, state,
|
|
87
|
+
mountBranch(node, branchToMount, state, manifest, parentScope);
|
|
74
88
|
|
|
75
89
|
// Store active branch reference
|
|
76
90
|
node.runtime.activeBranch = branchToMount;
|
|
77
91
|
};
|
|
78
92
|
|
|
79
93
|
// Mount a specific branch
|
|
80
|
-
const mountBranch = (node, branchData, state,
|
|
94
|
+
const mountBranch = (node, branchData, state, manifest, parentScope) => {
|
|
81
95
|
const { startComment, endComment } = node.meta;
|
|
82
96
|
|
|
83
97
|
// If branch doesn't exist (no else clause), just unmount current
|
|
@@ -109,8 +123,8 @@ const mountBranch = (node, branchData, state, linkList, parentScope) => {
|
|
|
109
123
|
|
|
110
124
|
// Recursively render any nested iterations and conditionals
|
|
111
125
|
if (branchTree) {
|
|
112
|
-
renderAllIterations(branchTree, scopedState,
|
|
113
|
-
renderAllConditionals(branchTree, scopedState,
|
|
126
|
+
renderAllIterations(branchTree, scopedState, manifest, parentScope);
|
|
127
|
+
renderAllConditionals(branchTree, scopedState, manifest, parentScope);
|
|
114
128
|
}
|
|
115
129
|
|
|
116
130
|
// Store active instance
|
|
@@ -139,7 +153,7 @@ const unmountBranch = (node) => {
|
|
|
139
153
|
};
|
|
140
154
|
|
|
141
155
|
// Update conditional when dependencies change
|
|
142
|
-
export const updateConditional = (node, newState, oldState,
|
|
156
|
+
export const updateConditional = (node, newState, oldState, manifest, parentScope = {}) => {
|
|
143
157
|
const { expression, branches } = node.meta;
|
|
144
158
|
|
|
145
159
|
// If not yet rendered, skip (renderConditional handles initial render)
|
|
@@ -156,7 +170,7 @@ export const updateConditional = (node, newState, oldState, linkList, parentScop
|
|
|
156
170
|
|
|
157
171
|
if (branchChanged) {
|
|
158
172
|
// Switch branches
|
|
159
|
-
mountBranch(node, newBranchData, newState,
|
|
173
|
+
mountBranch(node, newBranchData, newState, manifest, parentScope);
|
|
160
174
|
node.runtime.activeBranch = newBranchData;
|
|
161
175
|
} else {
|
|
162
176
|
// Same branch, but state might have changed - rehydrate
|
|
@@ -1,5 +1,24 @@
|
|
|
1
|
+
// Debug logger name
|
|
2
|
+
export const DEBUGGER_NAME = '[vibe-debug]:';
|
|
3
|
+
|
|
4
|
+
// Lifecycle phase names for debug logging
|
|
5
|
+
// ONE-OFF operations (run once during initialization)
|
|
6
|
+
export const PHASE_ATTACH = 'Attached'; // Latches onto DOM element (index.js)
|
|
7
|
+
export const PHASE_MANIFEST = 'Manifested'; // Creates DOM manifest (manifest.js)
|
|
8
|
+
export const PHASE_OBSERVE = 'Observer'; // Starts MutationObserver (index.js)
|
|
9
|
+
export const PHASE_COMPLETE = 'Cleanup'; // Removes [vibe] attribute (index.js)
|
|
10
|
+
|
|
11
|
+
// REPEATED operations (run during init + can repeat during runtime)
|
|
12
|
+
export const PHASE_PARSE = 'Parsed'; // Reads DOM structure (parse.js)
|
|
13
|
+
export const PHASE_HYDRATE = 'Hydrated'; // Replaces @[...] with values (hydrate.js)
|
|
14
|
+
export const PHASE_ITERATE = 'Iterated'; // Renders <!-- each --> blocks (iterate.js)
|
|
15
|
+
export const PHASE_CONDITION = 'Evaluated'; // Renders <!-- if --> blocks (conditionals.js)
|
|
16
|
+
export const PHASE_FETCH = 'Fetched'; // Loads <component> content (component.js)
|
|
17
|
+
export const PHASE_UPDATE = 'Proxy'; // State changes trigger re-hydration (index.js)
|
|
18
|
+
export const PHASE_MUTATE = 'Mutation'; // DOM mutations detected by observer (index.js)
|
|
19
|
+
|
|
1
20
|
// Elements that should not have reactive bindings
|
|
2
|
-
export const NON_REACTIVE_ELEMENTS = ['SCRIPT', 'HEAD', 'PRE'];
|
|
21
|
+
export const NON_REACTIVE_ELEMENTS = ['SCRIPT', 'HEAD', 'PRE', 'COMPONENT'];
|
|
3
22
|
|
|
4
23
|
// Attributes where the string value is meaningful (should NOT be removed when falsy)
|
|
5
24
|
// All other attributes are treated as boolean-like (removed when falsy, present when truthy)
|
|
@@ -138,10 +157,11 @@ export const VALUE_ATTRS = [
|
|
|
138
157
|
export const DOM_PROPERTIES = ['value', 'checked', 'selected'];
|
|
139
158
|
|
|
140
159
|
// Regex for matching reactive bindings (@[expression])
|
|
141
|
-
|
|
160
|
+
// Supports one level of nested brackets: @[items[0]] or @[obj[key]]
|
|
161
|
+
export const BINDING_REGEX = /\@\[((?:[^\[\]]|\[[^\]]*\])+)\]/g;
|
|
142
162
|
|
|
143
163
|
// Regex for detecting a pure binding (entire value is just @[expression])
|
|
144
|
-
export const PURE_BINDING_REGEX = /^\@\[([^\]]+)\]$/;
|
|
164
|
+
export const PURE_BINDING_REGEX = /^\@\[((?:[^\[\]]|\[[^\]]*\])+)\]$/;
|
|
145
165
|
|
|
146
166
|
// Regex for parsing iteration comment syntax (<!-- each items as item, index -->)
|
|
147
167
|
// Supports nested paths like category.items
|
package/runtime/debug.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { DEBUGGER_NAME } from './constants.js';
|
|
2
|
+
|
|
3
|
+
// Phase colors - synced with index.css debug phase colors
|
|
4
|
+
// Muted palette based on comment green (#6a9955)
|
|
5
|
+
const PHASE_COLORS = {
|
|
6
|
+
Attached: 'oklch(0.55 0.02 250)', // slate (minimal saturation)
|
|
7
|
+
Manifested: 'oklch(0.55 0.02 250)', // slate (minimal saturation)
|
|
8
|
+
Observer: 'oklch(0.60 0.11 240)', // muted blue
|
|
9
|
+
Cleanup: 'oklch(0.55 0.02 250)', // slate (minimal saturation)
|
|
10
|
+
Parsed: 'oklch(0.62 0.12 50)', // muted orange
|
|
11
|
+
Hydrated: 'oklch(0.60 0.12 340)', // muted pink
|
|
12
|
+
Iterated: 'oklch(0.58 0.11 142)', // comment green (baseline)
|
|
13
|
+
Evaluated: 'oklch(0.58 0.11 142)', // comment green (baseline)
|
|
14
|
+
Fetched: 'oklch(0.59 0.12 307)', // muted purple
|
|
15
|
+
Mutation: 'oklch(0.60 0.11 240)', // muted blue
|
|
16
|
+
Proxy: 'oklch(0.60 0.11 240)', // muted blue
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// Special colors for styled segments (Tailwind 500)
|
|
20
|
+
const COLORS = {
|
|
21
|
+
green: 'oklch(0.769 0.227 141.41)', // green-500
|
|
22
|
+
red: 'oklch(0.637 0.237 27.33)', // red-500
|
|
23
|
+
yellow: 'oklch(0.809 0.177 94.36)', // yellow-500
|
|
24
|
+
pink: 'oklch(0.649 0.237 346.06)', // pink-500
|
|
25
|
+
slate: 'oklch(0.556 0.016 256.85)', // slate-500
|
|
26
|
+
commentGreen: 'oklch(0.58 0.11 142)', // muted green (same as Iterated/Evaluated)
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Debug logging helper for Vibe
|
|
31
|
+
* Only logs when debug mode is enabled
|
|
32
|
+
* @param {string} phase - The lifecycle phase (Attach, Parse, Hydrate, etc.)
|
|
33
|
+
* @param {string|Array} message - The message to log (string or array of {text, colored: boolean, color: string})
|
|
34
|
+
* @param {boolean} debug - Whether debug mode is enabled
|
|
35
|
+
* @param {number} indent - Indentation level (0 = no indent, 1+ = nested operations)
|
|
36
|
+
* @param {HTMLElement} element - Optional DOM element to log (becomes clickable in console)
|
|
37
|
+
*/
|
|
38
|
+
export const debugLog = (phase, message, debug = false, indent = 0, element = null) => {
|
|
39
|
+
if (!debug) return;
|
|
40
|
+
|
|
41
|
+
const phaseBracket = `[${phase}] `.padEnd(13, ' '); // Pad to 13 chars (longest is "[Manifested] ")
|
|
42
|
+
const indentStr = indent > 0 ? ' '.repeat(indent) + '├─ ' : '';
|
|
43
|
+
const phaseColor = PHASE_COLORS[phase] || 'oklch(0.55 0.02 250)';
|
|
44
|
+
|
|
45
|
+
// Handle array of styled segments
|
|
46
|
+
if (Array.isArray(message)) {
|
|
47
|
+
let formatStr = `%c${phaseBracket}%c${DEBUGGER_NAME}%c ${indentStr}`;
|
|
48
|
+
const styles = [
|
|
49
|
+
`color: ${phaseColor}; font-weight: bold`,
|
|
50
|
+
'color: oklch(0.70 0.01 250)',
|
|
51
|
+
'color: inherit',
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
message.forEach((segment) => {
|
|
55
|
+
formatStr += '%c' + segment.text;
|
|
56
|
+
// Support both 'colored' (uses phase color) and 'color' (uses specific color)
|
|
57
|
+
if (segment.color) {
|
|
58
|
+
styles.push(`color: ${COLORS[segment.color] || segment.color}; font-weight: bold`);
|
|
59
|
+
} else if (segment.colored) {
|
|
60
|
+
styles.push(`color: ${phaseColor}; font-weight: bold`);
|
|
61
|
+
} else {
|
|
62
|
+
styles.push('color: inherit');
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Add element if provided (makes it clickable in console)
|
|
67
|
+
if (element) {
|
|
68
|
+
console.info(formatStr, ...styles, element);
|
|
69
|
+
} else {
|
|
70
|
+
console.info(formatStr, ...styles);
|
|
71
|
+
}
|
|
72
|
+
} else {
|
|
73
|
+
// Simple string message
|
|
74
|
+
if (element) {
|
|
75
|
+
console.info(
|
|
76
|
+
`%c${phaseBracket}%c${DEBUGGER_NAME}%c ${indentStr}${message}`,
|
|
77
|
+
`color: ${phaseColor}; font-weight: bold`,
|
|
78
|
+
'color: oklch(0.70 0.01 250)', // light gray
|
|
79
|
+
'color: inherit',
|
|
80
|
+
element,
|
|
81
|
+
);
|
|
82
|
+
} else {
|
|
83
|
+
console.info(
|
|
84
|
+
`%c${phaseBracket}%c${DEBUGGER_NAME}%c ${indentStr}${message}`,
|
|
85
|
+
`color: ${phaseColor}; font-weight: bold`,
|
|
86
|
+
'color: oklch(0.70 0.01 250)', // light gray
|
|
87
|
+
'color: inherit',
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
};
|
|
@@ -10,17 +10,67 @@ export const setPreviousState = (state) => {
|
|
|
10
10
|
previousState = { ...state };
|
|
11
11
|
};
|
|
12
12
|
|
|
13
|
-
export default (affected, state,
|
|
13
|
+
export default (affected, state, manifest = {}) => {
|
|
14
14
|
affected.forEach((aff) => {
|
|
15
|
+
// Use scoped state if provided (from iteration instances)
|
|
16
|
+
const effectiveState = aff.scopedState || state;
|
|
17
|
+
|
|
15
18
|
// Handle iteration updates
|
|
16
19
|
if (aff.type === 'iteration') {
|
|
17
|
-
updateIteration(aff.node, state, previousState,
|
|
20
|
+
updateIteration(aff.node, state, previousState, manifest);
|
|
18
21
|
return;
|
|
19
22
|
}
|
|
20
23
|
|
|
21
24
|
// Handle conditional updates
|
|
22
25
|
if (aff.type === 'conditional') {
|
|
23
|
-
updateConditional(aff.node, state, previousState,
|
|
26
|
+
updateConditional(aff.node, state, previousState, manifest);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Handle name bindings (e.g., <icon @[section.icon]>)
|
|
31
|
+
if (aff.type === 'nameBinding') {
|
|
32
|
+
const { nameBinding, matchInner, element } = aff;
|
|
33
|
+
try {
|
|
34
|
+
// HTML lowercases attribute names, so we need case-insensitive lookup
|
|
35
|
+
// Try exact match first, then try finding a case-insensitive match
|
|
36
|
+
let attrName = evalInScope(matchInner, effectiveState);
|
|
37
|
+
|
|
38
|
+
// If exact match failed and expression is a simple property (no dots/brackets)
|
|
39
|
+
if (!attrName && !matchInner.includes('.') && !matchInner.includes('[')) {
|
|
40
|
+
// Find the property with case-insensitive match
|
|
41
|
+
const keys = Object.keys(effectiveState);
|
|
42
|
+
const matchingKey = keys.find(k => k.toLowerCase() === matchInner.toLowerCase());
|
|
43
|
+
if (matchingKey) {
|
|
44
|
+
attrName = effectiveState[matchingKey];
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Track multiple name bindings per element (need a map of binding -> evaluated attr)
|
|
49
|
+
if (!element._vibeNameBindings) {
|
|
50
|
+
element._vibeNameBindings = new Map();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Remove the old evaluated attribute for this specific binding
|
|
54
|
+
const oldAttrName = element._vibeNameBindings.get(nameBinding);
|
|
55
|
+
if (oldAttrName) {
|
|
56
|
+
element.removeAttribute(oldAttrName);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Remove the binding attribute itself
|
|
60
|
+
if (element.hasAttribute(nameBinding)) {
|
|
61
|
+
element.removeAttribute(nameBinding);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Set the new attribute (empty value for boolean-like attributes)
|
|
65
|
+
if (attrName) {
|
|
66
|
+
element.setAttribute(attrName, '');
|
|
67
|
+
element._vibeNameBindings.set(nameBinding, attrName);
|
|
68
|
+
} else {
|
|
69
|
+
element._vibeNameBindings.delete(nameBinding);
|
|
70
|
+
}
|
|
71
|
+
} catch (e) {
|
|
72
|
+
console.error('Error hydrating name binding:', e);
|
|
73
|
+
}
|
|
24
74
|
return;
|
|
25
75
|
}
|
|
26
76
|
|
|
@@ -41,12 +91,12 @@ export default (affected, state, linkList = {}) => {
|
|
|
41
91
|
if (isDomProperty && isPureBinding) {
|
|
42
92
|
// For DOM properties like value, set the property directly
|
|
43
93
|
const expr = isPureBinding[1];
|
|
44
|
-
const value = evalInScope(expr,
|
|
94
|
+
const value = evalInScope(expr, effectiveState);
|
|
45
95
|
element[attrName] = value;
|
|
46
96
|
} else if (!isValueAttr && isPureBinding) {
|
|
47
97
|
// Boolean-like attributes: add or remove based on truthiness
|
|
48
98
|
const expr = isPureBinding[1];
|
|
49
|
-
const value = evalInScope(expr,
|
|
99
|
+
const value = evalInScope(expr, effectiveState);
|
|
50
100
|
if (value) {
|
|
51
101
|
element.setAttribute(attrName, '');
|
|
52
102
|
} else {
|
|
@@ -55,7 +105,7 @@ export default (affected, state, linkList = {}) => {
|
|
|
55
105
|
} else {
|
|
56
106
|
// Value attribute - replace bindings with values
|
|
57
107
|
const newValue = attrValue.replace(BINDING_REGEX, (_, expr) => {
|
|
58
|
-
return evalInScope(expr,
|
|
108
|
+
return evalInScope(expr, effectiveState);
|
|
59
109
|
});
|
|
60
110
|
element.setAttribute(attrName, newValue);
|
|
61
111
|
}
|
|
@@ -69,7 +119,7 @@ export default (affected, state, linkList = {}) => {
|
|
|
69
119
|
// This prevents undefined store properties to throw an error
|
|
70
120
|
try {
|
|
71
121
|
// Evaluate the expression with state as context
|
|
72
|
-
const evaluated = evalInScope(matchInner,
|
|
122
|
+
const evaluated = evalInScope(matchInner, effectiveState);
|
|
73
123
|
|
|
74
124
|
const toReplace = input.replaceAll(matchOuter, evaluated).trim();
|
|
75
125
|
|