@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.
Files changed (34) hide show
  1. package/CHANGELOG.md +121 -0
  2. package/README.md +228 -23
  3. package/compiler/bin/vibe-compile.js +109 -0
  4. package/compiler/native/.gitkeep +0 -0
  5. package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
  6. package/compiler/src/Cargo.lock +1885 -0
  7. package/compiler/src/Cargo.toml +29 -0
  8. package/compiler/src/compiler/compile.rs +1209 -0
  9. package/compiler/src/compiler/mod.rs +5 -0
  10. package/compiler/src/config.rs +184 -0
  11. package/compiler/src/main.rs +284 -0
  12. package/compiler/src/parser/element.rs +96 -0
  13. package/compiler/src/parser/html.rs +339 -0
  14. package/compiler/src/parser/mod.rs +8 -0
  15. package/index.js +2 -233
  16. package/package.json +26 -3
  17. package/{affected.js → runtime/affected.js} +66 -14
  18. package/runtime/cleanup.js +59 -0
  19. package/runtime/component.js +116 -0
  20. package/{conditionals.js → runtime/conditionals.js} +27 -23
  21. package/{constants.js → runtime/constants.js} +23 -3
  22. package/runtime/debug.js +91 -0
  23. package/{hydrate.js → runtime/hydrate.js} +58 -20
  24. package/runtime/index.js +614 -0
  25. package/{iterate.js → runtime/iterate.js} +53 -45
  26. package/{iteration-utils.js → runtime/iteration-utils.js} +11 -1
  27. package/{parse.js → runtime/parse.js} +37 -7
  28. package/runtime/state.js +52 -0
  29. package/{utils.js → runtime/utils.js} +13 -0
  30. package/llms.txt +0 -279
  31. package/state.js +0 -26
  32. /package/{_vibe-compiled-iteration-batch.js → runtime/_vibe-compiled-iteration-batch.js} +0 -0
  33. /package/{link.js → runtime/manifest.js} +0 -0
  34. /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, linkList, parentScope = {}) => {
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, linkList, parentScope);
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, linkList, parentScope);
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, linkList, parentScope = {}) => {
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, linkList, parentScope);
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, linkList, parentScope) => {
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, linkList);
123
- renderAllConditionals(branchTree, scopedState, linkList, parentScope);
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, linkList, parentScope = {}) => {
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, linkList, parentScope);
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
- export const BINDING_REGEX = /\@\[([^\]]+)\]/g;
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
@@ -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
- // Evaluate expression in the context of state
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, linkList);
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, linkList);
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, state);
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, state);
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, state);
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, state);
122
+ const evaluated = evalInScope(matchInner, effectiveState);
85
123
 
86
124
  const toReplace = input.replaceAll(matchOuter, evaluated).trim();
87
125