@ape-egg/vibe 1.1.2 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/runtime/parse.js CHANGED
@@ -1,10 +1,10 @@
1
- import { hash } from './utils.js';
2
1
  import { findEndComment, findConditionalEnd } from './iteration-utils.js';
3
2
  import {
4
3
  NON_REACTIVE_ELEMENTS,
5
4
  BINDING_REGEX,
6
5
  ITERATION_REGEX,
7
6
  CONDITIONAL_REGEX,
7
+ DOM_ELEMENT_PROPERTIES,
8
8
  } from './constants.js';
9
9
 
10
10
  const parseHTML = (children, rootKey = undefined) =>
@@ -59,9 +59,9 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
59
59
  // Parse the template recursively
60
60
  const templateParsed = recursive([...templateContainer.childNodes], undefined, new Set(), stats);
61
61
 
62
- // Store iteration metadata
63
- const iterationKey = `iteration_${hash()}`;
64
- result[iterationKey] = {
62
+ // Store iteration metadata (use index for deterministic keys)
63
+ const iterationKey = `iteration_${i}`;
64
+ const iterationNode = {
65
65
  type: 'iteration',
66
66
  meta: {
67
67
  arrayPath,
@@ -75,12 +75,15 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
75
75
  children: templateParsed,
76
76
  },
77
77
  },
78
- runtime: {
78
+ // Preserve runtime data from previous parse if it exists (stored on startComment by iterate.js)
79
+ // @ts-ignore - custom property added by iterate.js
80
+ runtime: element.__vibeIterationRuntime || {
79
81
  instances: [],
80
82
  templateRemoved: false,
81
83
  },
82
84
  children: {},
83
85
  };
86
+ result[iterationKey] = iterationNode;
84
87
 
85
88
  // Mark template indices as processed
86
89
  for (let j = i + 1; j < endIndex; j++) {
@@ -132,8 +135,8 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
132
135
  falseBranchParsed = recursive([...falseBranchContainer.childNodes], undefined, new Set(), stats);
133
136
  }
134
137
 
135
- // Store conditional metadata
136
- const conditionalKey = `conditional_${hash()}`;
138
+ // Store conditional metadata (use index for deterministic keys)
139
+ const conditionalKey = `conditional_${i}`;
137
140
  result[conditionalKey] = {
138
141
  type: 'conditional',
139
142
  meta: {
@@ -185,10 +188,21 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
185
188
  const name = nodeName.startsWith('#') ? nodeName.slice(1) : nodeName;
186
189
  const nodeIdentifier = `${name}_${i}`.toLowerCase();
187
190
 
191
+ // For text nodes, use parent element (text nodes can't have attributes)
192
+ const isTextNode = nodeName === '#text';
193
+ const elementForBindings = isTextNode ? element.parentElement : element;
194
+ const textNodeRef = isTextNode ? element : null; // Store reference to actual text node
195
+
188
196
  // Check for attribute bindings
189
197
  const attributes = {};
190
198
  const nameBindings = [];
191
- if (element.attributes) {
199
+
200
+ // Skip hydrating attributes on fetched components - they need to be passed raw
201
+ const isFetchedComponent =
202
+ (nodeName === 'COMPONENT' || (nodeName === 'DIV' && element.classList?.contains('component'))) &&
203
+ element.hasAttribute('src');
204
+
205
+ if (element.attributes && !isFetchedComponent) {
192
206
  for (let j = 0; j < element.attributes.length; j++) {
193
207
  const attr = element.attributes[j];
194
208
  // Reset lastIndex before test - BINDING_REGEX has 'g' flag which persists state
@@ -200,6 +214,18 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
200
214
  continue; // Don't process as regular attribute
201
215
  }
202
216
 
217
+ // Rewrite event handlers with this. to use component state
218
+ if (attr.name.startsWith('on') && attr.value.includes('this.')) {
219
+ const componentId = findComponentIdForElement(element);
220
+ if (componentId) {
221
+ // Rewrite this.property to $['componentId'].property, but skip DOM properties
222
+ const rewritten = attr.value.replace(/\bthis\.(\w+)/g, (match, prop) => {
223
+ return DOM_ELEMENT_PROPERTIES.has(prop) ? match : `$['${componentId}'].${prop}`;
224
+ });
225
+ element.setAttribute(attr.name, rewritten);
226
+ }
227
+ }
228
+
203
229
  // Check if attribute value contains binding
204
230
  BINDING_REGEX.lastIndex = 0;
205
231
  if (BINDING_REGEX.test(attr.value)) {
@@ -207,6 +233,16 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
207
233
  }
208
234
  }
209
235
  }
236
+
237
+ // Helper to find component ID for an element
238
+ // Looks for nearest ancestor with data-vibe-component-id
239
+ function findComponentIdForElement(element) {
240
+ if (!element) return null;
241
+
242
+ // Find nearest component wrapper (tagged by component.js)
243
+ const wrapper = element.closest('[data-vibe-component-id]');
244
+ return wrapper ? wrapper.getAttribute('data-vibe-component-id') : null;
245
+ }
210
246
  const hasAttributeBindings = Object.keys(attributes).length > 0;
211
247
  const hasNameBindings = nameBindings.length > 0;
212
248
 
@@ -218,18 +254,20 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
218
254
 
219
255
  result[rootKey || nodeIdentifier] = {
220
256
  parsed,
221
- element,
257
+ element: elementForBindings,
222
258
  children: recursive(iteratableChildren, undefined, new Set(), stats),
223
259
  ...(hasAttributeBindings && { attributes }),
224
260
  ...(hasNameBindings && { nameBindings }),
261
+ ...(textNodeRef && { textNode: textNodeRef }),
225
262
  };
226
263
  } else {
227
264
  result[nodeIdentifier] = {
228
265
  parsed: innerHTML || textContent,
229
- element,
266
+ element: elementForBindings,
230
267
  children: {},
231
268
  ...(hasAttributeBindings && { attributes }),
232
269
  ...(hasNameBindings && { nameBindings }),
270
+ ...(textNodeRef && { textNode: textNodeRef }),
233
271
  };
234
272
  }
235
273
  }
@@ -0,0 +1,50 @@
1
+ // Scope resolution for component state
2
+
3
+ /**
4
+ * Find the component ID that owns this element
5
+ * Finds nearest ancestor with data-vibe-component-id (set by component.js)
6
+ * @param {Element} element - DOM element to find component for
7
+ * @returns {string|null} - Component ID or null if not in component scope
8
+ */
9
+ export const findComponentId = (element) => {
10
+ if (!element || !element.closest) return null;
11
+
12
+ // Find nearest component wrapper (tagged by component.js)
13
+ const wrapper = element.closest('[data-vibe-component-id]');
14
+ return wrapper ? wrapper.getAttribute('data-vibe-component-id') : null;
15
+ };
16
+
17
+ /**
18
+ * Resolve a property path with component scope fallback
19
+ * Checks component state first, then global state
20
+ * @param {string} path - Property path (e.g., "count" or "user.name")
21
+ * @param {Element} element - DOM element for context
22
+ * @param {object} globalState - Global $ object
23
+ * @returns {*} - Resolved value
24
+ */
25
+ export const resolveWithScope = (path, element, globalState) => {
26
+ const componentId = findComponentId(element);
27
+
28
+ if (componentId && globalState[componentId]) {
29
+ // Check component state first
30
+ const componentState = globalState[componentId];
31
+ const value = resolvePath(path, componentState);
32
+
33
+ if (value !== undefined) {
34
+ return value;
35
+ }
36
+ }
37
+
38
+ // Fallback to global state
39
+ return resolvePath(path, globalState);
40
+ };
41
+
42
+ /**
43
+ * Resolve a dot-notation path in an object
44
+ * @param {string} path - Property path
45
+ * @param {object} obj - Object to resolve path in
46
+ * @returns {*} - Resolved value or undefined
47
+ */
48
+ const resolvePath = (path, obj) => {
49
+ return path.split('.').reduce((current, key) => current?.[key], obj);
50
+ };
package/runtime/state.js CHANGED
@@ -16,17 +16,22 @@ const createDeepProxy = (target, rerender, rootState = null, rootProp = null) =>
16
16
  const proxy = new Proxy(target, {
17
17
  set(obj, prop, value) {
18
18
  const oldValue = obj[prop];
19
- const ref = Reflect.set(obj, prop, value);
20
19
 
21
20
  // Only trigger rerender if value actually changed
22
21
  if (oldValue !== value) {
23
- // If we're at root level, use the prop being set
24
- // If we're nested, use the root prop that contains this nested object
22
+ // Perform the mutation
23
+ const ref = Reflect.set(obj, prop, value);
24
+
25
+ // Trigger rerender with changed state
26
+ // If we're nested, use the root prop; otherwise use the prop itself
25
27
  const changedProp = rootProp || prop;
26
- rerender({ [changedProp]: rootState[changedProp] });
28
+ rerender({ [changedProp]: rootState[changedProp] }, null);
29
+
30
+ return ref;
27
31
  }
28
32
 
29
- return ref;
33
+ // No change, just set
34
+ return Reflect.set(obj, prop, value);
30
35
  },
31
36
 
32
37
  get(target, prop) {
package/runtime/utils.js CHANGED
@@ -3,18 +3,76 @@ let hashCounter = 0;
3
3
  export const hash = () => `_${hashCounter++}`;
4
4
 
5
5
  // Evaluate expression in the context of state
6
- export const evalInScope = (expr, state) => {
6
+ // Handles @[this.property] for component state and @[property] for global state
7
+ export const evalInScope = (expr, state, element = null) => {
7
8
  try {
8
9
  // Normalize whitespace - collapse newlines/spaces to single space (resilient to IDE formatting)
9
- const normalized = expr.replace(/\s+/g, ' ').trim();
10
- const keys = Object.keys(state);
11
- const values = Object.values(state);
12
- return new Function(...keys, `'use strict'; return (${normalized})`)(...values);
10
+ let normalized = expr.replace(/\s+/g, ' ').trim();
11
+
12
+ // If expression contains 'this.', replace with component state path
13
+ if (normalized.includes('this.') && element) {
14
+ const componentId = findComponentIdForElement(element);
15
+ if (componentId) {
16
+ // Replace this.property with $['componentId'].property
17
+ normalized = normalized.replace(/\bthis\.(\w+)/g, `$['${componentId}'].$1`);
18
+ }
19
+ }
20
+
21
+ // Add $ to scope for this.property references
22
+ const effectiveState = { ...state, $: state };
23
+
24
+ const keys = Object.keys(effectiveState);
25
+ const values = Object.values(effectiveState);
26
+
27
+ const result = new Function(...keys, `'use strict'; return (${normalized})`)(...values);
28
+
29
+ // If result is undefined and we're accessing a component property, try case-insensitive match
30
+ // This handles HTML lowercasing attribute names like @[this.iconName] -> @[this.iconname]
31
+ if (result === undefined) {
32
+ const componentMatch = normalized.match(/\$\['([^']+)'\]\.(\w+)/);
33
+ if (componentMatch) {
34
+ const [, componentId, propName] = componentMatch;
35
+ const componentState = state[componentId];
36
+ if (componentState) {
37
+ // Try case-insensitive property lookup
38
+ const actualKey = Object.keys(componentState).find(k => k.toLowerCase() === propName.toLowerCase());
39
+ if (actualKey) {
40
+ return componentState[actualKey];
41
+ }
42
+ }
43
+ }
44
+ }
45
+
46
+ return result;
13
47
  } catch (e) {
14
48
  return undefined;
15
49
  }
16
50
  };
17
51
 
52
+ // Helper to find component ID for an element
53
+ // Walks up DOM tree to find nearest component wrapper
54
+ export const findComponentIdForElement = (element) => {
55
+ if (!element || !element.closest) return null;
56
+
57
+ const wrapper = element.closest('[data-vibe-component-id]');
58
+ return wrapper ? wrapper.getAttribute('data-vibe-component-id') : null;
59
+ };
60
+
61
+ // Helper to resolve this.property paths to componentId.property
62
+ // Used for component-scoped iterations, conditionals, etc.
63
+ export const resolveThisPath = (path, element) => {
64
+ if (!path.startsWith('this.')) {
65
+ return path;
66
+ }
67
+
68
+ const componentId = findComponentIdForElement(element);
69
+ if (componentId) {
70
+ return path.replace(/^this\./, `${componentId}.`);
71
+ }
72
+
73
+ return path;
74
+ };
75
+
18
76
  // Instead of using lodash-es as a dependency, we run our own deepMerge (mergeWith in lodash)
19
77
  export const deepMerge = (target, source) => {
20
78
  // Handle null/undefined
@@ -2,10 +2,12 @@
2
2
  * Elements with [vibe] attribute are hidden until framework removes it after hydration.
3
3
  * This prevents flash of unprocessed content and disables transitions during init.
4
4
  */
5
- [vibe] {
5
+ [vibe-fouc],
6
+ .vibe-fouc {
6
7
  visibility: hidden;
7
8
  }
8
9
 
9
- [vibe] * {
10
+ [vibe-fouc] *,
11
+ .vibe-fouc * {
10
12
  transition: none !important;
11
13
  }