@ape-egg/vibe 1.0.3 → 1.1.1
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 +121 -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 +339 -0
- package/compiler/src/parser/mod.rs +8 -0
- package/index.js +2 -233
- package/package.json +26 -3
- package/{affected.js → runtime/affected.js} +66 -14
- package/runtime/cleanup.js +59 -0
- package/runtime/component.js +116 -0
- package/{conditionals.js → runtime/conditionals.js} +27 -23
- package/{constants.js → runtime/constants.js} +23 -3
- package/runtime/debug.js +91 -0
- package/{hydrate.js → runtime/hydrate.js} +58 -20
- 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/{utils.js → runtime/utils.js} +13 -0
- 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/{vibe.css → runtime/vibe.css} +0 -0
|
@@ -1,23 +1,15 @@
|
|
|
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
5
|
|
|
5
6
|
// Evaluate conditional expression
|
|
6
|
-
const evaluateCondition = (expression, state) =>
|
|
7
|
-
try {
|
|
8
|
-
const keys = Object.keys(state);
|
|
9
|
-
const values = Object.values(state);
|
|
10
|
-
const result = new Function(...keys, `'use strict'; return !!(${expression})`)(...values);
|
|
11
|
-
return !!result;
|
|
12
|
-
} catch (e) {
|
|
13
|
-
return false;
|
|
14
|
-
}
|
|
15
|
-
};
|
|
7
|
+
const evaluateCondition = (expression, state) => !!evalInScope(expression, state);
|
|
16
8
|
|
|
17
9
|
// Helper function to check if a match references a specific key
|
|
18
10
|
const matchesKey = (matchStr, key) => matchStr === key || matchStr.startsWith(key + '.');
|
|
19
11
|
|
|
20
|
-
const recursive = (tree, state, newState, affected) => {
|
|
12
|
+
const recursive = (tree, state, newState, affected, scopedStateForHydration = null) => {
|
|
21
13
|
// Handle iteration nodes specially
|
|
22
14
|
if (tree.type === 'iteration') {
|
|
23
15
|
const oldArray = resolvePath(state, tree.meta.arrayPath);
|
|
@@ -31,7 +23,22 @@ const recursive = (tree, state, newState, affected) => {
|
|
|
31
23
|
node: tree,
|
|
32
24
|
changeType: 'array',
|
|
33
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
|
+
}
|
|
34
40
|
}
|
|
41
|
+
|
|
35
42
|
return affected;
|
|
36
43
|
}
|
|
37
44
|
|
|
@@ -52,7 +59,7 @@ const recursive = (tree, state, newState, affected) => {
|
|
|
52
59
|
|
|
53
60
|
// Condition didn't change, check for affected elements inside active branch
|
|
54
61
|
if (tree.runtime.activeInstance && tree.runtime.activeInstance.parsedTree) {
|
|
55
|
-
return recursive(tree.runtime.activeInstance.parsedTree, state, newState, affected);
|
|
62
|
+
return recursive(tree.runtime.activeInstance.parsedTree, state, newState, affected, scopedStateForHydration);
|
|
56
63
|
}
|
|
57
64
|
|
|
58
65
|
return affected;
|
|
@@ -112,6 +119,7 @@ const recursive = (tree, state, newState, affected) => {
|
|
|
112
119
|
input: m.input,
|
|
113
120
|
matches: m.matches,
|
|
114
121
|
element: tree.element,
|
|
122
|
+
scopedState: scopedStateForHydration, // Pass scoped state from iteration context
|
|
115
123
|
});
|
|
116
124
|
}
|
|
117
125
|
}
|
|
@@ -156,6 +164,50 @@ const recursive = (tree, state, newState, affected) => {
|
|
|
156
164
|
matchOuter: m.outer,
|
|
157
165
|
matchInner: m.inner,
|
|
158
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,
|
|
159
211
|
});
|
|
160
212
|
}
|
|
161
213
|
}
|
|
@@ -168,7 +220,7 @@ const recursive = (tree, state, newState, affected) => {
|
|
|
168
220
|
for (const key in children) {
|
|
169
221
|
const child = children[key];
|
|
170
222
|
if (child && typeof child === 'object') {
|
|
171
|
-
recursive(child, state, newState, affected);
|
|
223
|
+
recursive(child, state, newState, affected, scopedStateForHydration);
|
|
172
224
|
}
|
|
173
225
|
}
|
|
174
226
|
}
|
|
@@ -176,4 +228,4 @@ const recursive = (tree, state, newState, affected) => {
|
|
|
176
228
|
return affected;
|
|
177
229
|
};
|
|
178
230
|
|
|
179
|
-
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
|
+
};
|
|
@@ -2,20 +2,10 @@ import parse from './parse.js';
|
|
|
2
2
|
import affected from './affected.js';
|
|
3
3
|
import hydrate from './hydrate.js';
|
|
4
4
|
import { createScopedState, renderAllIterations, initializeBlock } from './iterate.js';
|
|
5
|
+
import { evalInScope } from './utils.js';
|
|
5
6
|
|
|
6
7
|
// Evaluate conditional expression in state context
|
|
7
|
-
const evaluateCondition = (expression, state) =>
|
|
8
|
-
try {
|
|
9
|
-
const keys = Object.keys(state);
|
|
10
|
-
const values = Object.values(state);
|
|
11
|
-
// Create a function with state keys as parameters and evaluate the expression
|
|
12
|
-
const result = new Function(...keys, `'use strict'; return !!(${expression})`)(...values);
|
|
13
|
-
return !!result; // Coerce to boolean
|
|
14
|
-
} catch (e) {
|
|
15
|
-
console.warn(`Error evaluating condition "${expression}":`, e);
|
|
16
|
-
return false; // Default to false on error
|
|
17
|
-
}
|
|
18
|
-
};
|
|
8
|
+
const evaluateCondition = (expression, state) => !!evalInScope(expression, state);
|
|
19
9
|
|
|
20
10
|
// Extract state dependencies from an expression
|
|
21
11
|
// e.g., "count > 5" → ["count"]
|
|
@@ -35,11 +25,13 @@ export const extractDependencies = (expression) => {
|
|
|
35
25
|
};
|
|
36
26
|
|
|
37
27
|
// Render all conditionals in the parsed tree
|
|
38
|
-
export const renderAllConditionals = (tree, state,
|
|
28
|
+
export const renderAllConditionals = (tree, state, manifest, parentScope = {}) => {
|
|
29
|
+
let count = 0;
|
|
30
|
+
|
|
39
31
|
// If this is a conditional node, render it
|
|
40
32
|
if (tree.type === 'conditional') {
|
|
41
|
-
renderConditional(tree, state,
|
|
42
|
-
return;
|
|
33
|
+
renderConditional(tree, state, manifest, parentScope);
|
|
34
|
+
return 1;
|
|
43
35
|
}
|
|
44
36
|
|
|
45
37
|
// Recursively render conditionals in child nodes
|
|
@@ -47,16 +39,24 @@ export const renderAllConditionals = (tree, state, linkList, parentScope = {}) =
|
|
|
47
39
|
Object.keys(tree.children).forEach((key) => {
|
|
48
40
|
const child = tree.children[key];
|
|
49
41
|
if (typeof child === 'object' && child !== null) {
|
|
50
|
-
renderAllConditionals(child, state,
|
|
42
|
+
count += renderAllConditionals(child, state, manifest, parentScope);
|
|
51
43
|
}
|
|
52
44
|
});
|
|
53
45
|
}
|
|
46
|
+
|
|
47
|
+
return count;
|
|
54
48
|
};
|
|
55
49
|
|
|
56
50
|
// Initial render of a conditional block
|
|
57
|
-
export const renderConditional = (node, state,
|
|
51
|
+
export const renderConditional = (node, state, manifest, parentScope = {}) => {
|
|
58
52
|
const { expression, startComment, endComment, branches } = node.meta;
|
|
59
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
|
+
|
|
60
60
|
// Remove original template nodes from DOM (between start and end comments)
|
|
61
61
|
// Only do this on first render (when activeBranch is undefined)
|
|
62
62
|
if (node.runtime.activeBranch === undefined) {
|
|
@@ -73,6 +73,10 @@ export const renderConditional = (node, state, linkList, parentScope = {}) => {
|
|
|
73
73
|
node.runtime.templateRemoved = true;
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
// Mark comment as rendered (survives re-parsing)
|
|
77
|
+
// @ts-ignore - adding custom property to comment node
|
|
78
|
+
startComment.__vibeRendered = true;
|
|
79
|
+
|
|
76
80
|
// Evaluate condition with current state
|
|
77
81
|
const conditionResult = evaluateCondition(expression, state);
|
|
78
82
|
|
|
@@ -80,14 +84,14 @@ export const renderConditional = (node, state, linkList, parentScope = {}) => {
|
|
|
80
84
|
const branchToMount = conditionResult ? branches.if : branches.else;
|
|
81
85
|
|
|
82
86
|
// Mount the appropriate branch
|
|
83
|
-
mountBranch(node, branchToMount, state,
|
|
87
|
+
mountBranch(node, branchToMount, state, manifest, parentScope);
|
|
84
88
|
|
|
85
89
|
// Store active branch reference
|
|
86
90
|
node.runtime.activeBranch = branchToMount;
|
|
87
91
|
};
|
|
88
92
|
|
|
89
93
|
// Mount a specific branch
|
|
90
|
-
const mountBranch = (node, branchData, state,
|
|
94
|
+
const mountBranch = (node, branchData, state, manifest, parentScope) => {
|
|
91
95
|
const { startComment, endComment } = node.meta;
|
|
92
96
|
|
|
93
97
|
// If branch doesn't exist (no else clause), just unmount current
|
|
@@ -119,8 +123,8 @@ const mountBranch = (node, branchData, state, linkList, parentScope) => {
|
|
|
119
123
|
|
|
120
124
|
// Recursively render any nested iterations and conditionals
|
|
121
125
|
if (branchTree) {
|
|
122
|
-
renderAllIterations(branchTree, scopedState,
|
|
123
|
-
renderAllConditionals(branchTree, scopedState,
|
|
126
|
+
renderAllIterations(branchTree, scopedState, manifest, parentScope);
|
|
127
|
+
renderAllConditionals(branchTree, scopedState, manifest, parentScope);
|
|
124
128
|
}
|
|
125
129
|
|
|
126
130
|
// Store active instance
|
|
@@ -149,7 +153,7 @@ const unmountBranch = (node) => {
|
|
|
149
153
|
};
|
|
150
154
|
|
|
151
155
|
// Update conditional when dependencies change
|
|
152
|
-
export const updateConditional = (node, newState, oldState,
|
|
156
|
+
export const updateConditional = (node, newState, oldState, manifest, parentScope = {}) => {
|
|
153
157
|
const { expression, branches } = node.meta;
|
|
154
158
|
|
|
155
159
|
// If not yet rendered, skip (renderConditional handles initial render)
|
|
@@ -166,7 +170,7 @@ export const updateConditional = (node, newState, oldState, linkList, parentScop
|
|
|
166
170
|
|
|
167
171
|
if (branchChanged) {
|
|
168
172
|
// Switch branches
|
|
169
|
-
mountBranch(node, newBranchData, newState,
|
|
173
|
+
mountBranch(node, newBranchData, newState, manifest, parentScope);
|
|
170
174
|
node.runtime.activeBranch = newBranchData;
|
|
171
175
|
} else {
|
|
172
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
|
+
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { updateIteration } from './iterate.js';
|
|
2
2
|
import { updateConditional } from './conditionals.js';
|
|
3
3
|
import { VALUE_ATTRS, DOM_PROPERTIES, BINDING_REGEX, PURE_BINDING_REGEX } from './constants.js';
|
|
4
|
+
import { evalInScope } from './utils.js';
|
|
4
5
|
|
|
5
6
|
// Keep track of old state for diffing
|
|
6
7
|
let previousState = {};
|
|
@@ -9,30 +10,67 @@ export const setPreviousState = (state) => {
|
|
|
9
10
|
previousState = { ...state };
|
|
10
11
|
};
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
const evalInScope = (expr, state) => {
|
|
14
|
-
try {
|
|
15
|
-
const keys = Object.keys(state);
|
|
16
|
-
const values = Object.values(state);
|
|
17
|
-
// Create a function with state keys as parameters and evaluate the expression
|
|
18
|
-
return new Function(...keys, `'use strict'; return (${expr})`)(...values);
|
|
19
|
-
} catch (e) {
|
|
20
|
-
// Fallback to undefined if evaluation fails
|
|
21
|
-
return undefined;
|
|
22
|
-
}
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
export default (affected, state, linkList = {}) => {
|
|
13
|
+
export default (affected, state, manifest = {}) => {
|
|
26
14
|
affected.forEach((aff) => {
|
|
15
|
+
// Use scoped state if provided (from iteration instances)
|
|
16
|
+
const effectiveState = aff.scopedState || state;
|
|
17
|
+
|
|
27
18
|
// Handle iteration updates
|
|
28
19
|
if (aff.type === 'iteration') {
|
|
29
|
-
updateIteration(aff.node, state, previousState,
|
|
20
|
+
updateIteration(aff.node, state, previousState, manifest);
|
|
30
21
|
return;
|
|
31
22
|
}
|
|
32
23
|
|
|
33
24
|
// Handle conditional updates
|
|
34
25
|
if (aff.type === 'conditional') {
|
|
35
|
-
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
|
+
}
|
|
36
74
|
return;
|
|
37
75
|
}
|
|
38
76
|
|
|
@@ -53,12 +91,12 @@ export default (affected, state, linkList = {}) => {
|
|
|
53
91
|
if (isDomProperty && isPureBinding) {
|
|
54
92
|
// For DOM properties like value, set the property directly
|
|
55
93
|
const expr = isPureBinding[1];
|
|
56
|
-
const value = evalInScope(expr,
|
|
94
|
+
const value = evalInScope(expr, effectiveState);
|
|
57
95
|
element[attrName] = value;
|
|
58
96
|
} else if (!isValueAttr && isPureBinding) {
|
|
59
97
|
// Boolean-like attributes: add or remove based on truthiness
|
|
60
98
|
const expr = isPureBinding[1];
|
|
61
|
-
const value = evalInScope(expr,
|
|
99
|
+
const value = evalInScope(expr, effectiveState);
|
|
62
100
|
if (value) {
|
|
63
101
|
element.setAttribute(attrName, '');
|
|
64
102
|
} else {
|
|
@@ -67,7 +105,7 @@ export default (affected, state, linkList = {}) => {
|
|
|
67
105
|
} else {
|
|
68
106
|
// Value attribute - replace bindings with values
|
|
69
107
|
const newValue = attrValue.replace(BINDING_REGEX, (_, expr) => {
|
|
70
|
-
return evalInScope(expr,
|
|
108
|
+
return evalInScope(expr, effectiveState);
|
|
71
109
|
});
|
|
72
110
|
element.setAttribute(attrName, newValue);
|
|
73
111
|
}
|
|
@@ -81,7 +119,7 @@ export default (affected, state, linkList = {}) => {
|
|
|
81
119
|
// This prevents undefined store properties to throw an error
|
|
82
120
|
try {
|
|
83
121
|
// Evaluate the expression with state as context
|
|
84
|
-
const evaluated = evalInScope(matchInner,
|
|
122
|
+
const evaluated = evalInScope(matchInner, effectiveState);
|
|
85
123
|
|
|
86
124
|
const toReplace = input.replaceAll(matchOuter, evaluated).trim();
|
|
87
125
|
|