@ape-egg/vibe 1.7.2 → 1.9.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.
@@ -1,5 +1,6 @@
1
1
  const recursive = (tree, results, tagChain) => {
2
- results[tagChain.join('.')] = tree.element;
2
+ const path = tagChain.join('.');
3
+ results[path] = tree.element;
3
4
 
4
5
  if (tree.children && Object.keys(tree.children).length > 0) {
5
6
  Object.keys(tree.children).forEach((tag) => {
package/runtime/parse.js CHANGED
@@ -8,6 +8,66 @@ import {
8
8
  DEHYDRATE_CLASS_OR_ATTR,
9
9
  } from './constants.js';
10
10
 
11
+ // Walks up the DOM for the nearest component wrapper tagged by component.js.
12
+ // Used to rewrite `this.property` in event handlers to the component's state path.
13
+ const findComponentIdForElement = (element) => {
14
+ if (!element?.closest) return null;
15
+ const wrapper = element.closest('[data-vibe-component-id]');
16
+ return wrapper ? wrapper.getAttribute('data-vibe-component-id') : null;
17
+ };
18
+
19
+ // Single source of truth for reading attribute/name bindings off an element.
20
+ // Called from both the root handler and recursive() so they can't drift. Any
21
+ // element classified as a fetched component (`<component src>` or
22
+ // `<div class="component" src>`) returns nulls — its attributes are props
23
+ // owned by processComponent and must stay raw; hydrating them would coerce
24
+ // objects to "[object Object]" or strip boolean-like attrs to empty.
25
+ const captureAttributeBindings = (element) => {
26
+ const nodeName = element.nodeName;
27
+ const isFetchedComponent =
28
+ (nodeName === 'COMPONENT' || (nodeName === 'DIV' && element.classList?.contains('component'))) &&
29
+ element.hasAttribute?.('src');
30
+
31
+ if (isFetchedComponent || !element.attributes || element.attributes.length === 0) {
32
+ return { attributes: null, nameBindings: null };
33
+ }
34
+
35
+ const attributes = {};
36
+ const nameBindings = [];
37
+
38
+ for (let j = 0; j < element.attributes.length; j++) {
39
+ const attr = element.attributes[j];
40
+ BINDING_REGEX.lastIndex = 0;
41
+
42
+ // Attribute name itself contains a binding (e.g. <icon @[section.icon]>).
43
+ if (BINDING_REGEX.test(attr.name)) {
44
+ nameBindings.push(attr.name);
45
+ continue;
46
+ }
47
+
48
+ // Rewrite `this.property` inside event handlers to the component's state path.
49
+ if (attr.name.startsWith('on') && attr.value.includes('this.')) {
50
+ const componentId = findComponentIdForElement(element);
51
+ if (componentId) {
52
+ const rewritten = attr.value.replace(/\bthis\.(\w+)/g, (match, prop) => {
53
+ return DOM_ELEMENT_PROPERTIES.has(prop) ? match : `$['${componentId}'].${prop}`;
54
+ });
55
+ element.setAttribute(attr.name, rewritten);
56
+ }
57
+ }
58
+
59
+ BINDING_REGEX.lastIndex = 0;
60
+ if (BINDING_REGEX.test(attr.value)) {
61
+ attributes[attr.name] = attr.value;
62
+ }
63
+ }
64
+
65
+ return {
66
+ attributes: Object.keys(attributes).length > 0 ? attributes : null,
67
+ nameBindings: nameBindings.length > 0 ? nameBindings : null,
68
+ };
69
+ };
70
+
11
71
  const parseHTML = (children, rootKey = undefined) =>
12
72
  children.reduce((s, element, i) => {
13
73
  const { nodeName, textContent } = element;
@@ -140,6 +200,7 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
140
200
  // Store conditional metadata (use index for deterministic keys)
141
201
  const conditionalKey = `conditional_${i}`;
142
202
  result[conditionalKey] = {
203
+ _key: conditionalKey,
143
204
  type: 'conditional',
144
205
  meta: {
145
206
  expression,
@@ -195,59 +256,7 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
195
256
  const elementForBindings = isTextNode ? element.parentElement : element;
196
257
  const textNodeRef = isTextNode ? element : null; // Store reference to actual text node
197
258
 
198
- // Check for attribute bindings
199
- const attributes = {};
200
- const nameBindings = [];
201
-
202
- // Skip hydrating attributes on fetched components - they need to be passed raw
203
- const isFetchedComponent =
204
- (nodeName === 'COMPONENT' || (nodeName === 'DIV' && element.classList?.contains('component'))) &&
205
- element.hasAttribute('src');
206
-
207
- if (element.attributes && !isFetchedComponent) {
208
- for (let j = 0; j < element.attributes.length; j++) {
209
- const attr = element.attributes[j];
210
- // Reset lastIndex before test - BINDING_REGEX has 'g' flag which persists state
211
- BINDING_REGEX.lastIndex = 0;
212
-
213
- // Check if attribute name contains binding (e.g., @[section.icon])
214
- if (BINDING_REGEX.test(attr.name)) {
215
- nameBindings.push(attr.name);
216
- continue; // Don't process as regular attribute
217
- }
218
-
219
- // Rewrite event handlers with this. to use component state
220
- if (attr.name.startsWith('on') && attr.value.includes('this.')) {
221
- const componentId = findComponentIdForElement(element);
222
- if (componentId) {
223
- // Rewrite this.property to $['componentId'].property, but skip DOM properties
224
- const rewritten = attr.value.replace(/\bthis\.(\w+)/g, (match, prop) => {
225
- return DOM_ELEMENT_PROPERTIES.has(prop) ? match : `$['${componentId}'].${prop}`;
226
- });
227
- element.setAttribute(attr.name, rewritten);
228
- }
229
- }
230
-
231
- // Check if attribute value contains binding
232
- BINDING_REGEX.lastIndex = 0;
233
- if (BINDING_REGEX.test(attr.value)) {
234
- attributes[attr.name] = attr.value;
235
- }
236
- }
237
- }
238
-
239
- // Helper to find component ID for an element
240
- // Looks for nearest ancestor with data-vibe-component-id
241
- function findComponentIdForElement(element) {
242
- if (!element) return null;
243
-
244
- // Find nearest component wrapper (tagged by component.js)
245
- const wrapper = element.closest('[data-vibe-component-id]');
246
- return wrapper ? wrapper.getAttribute('data-vibe-component-id') : null;
247
- }
248
- const hasAttributeBindings = Object.keys(attributes).length > 0;
249
- const hasNameBindings = nameBindings.length > 0;
250
-
259
+ const { attributes, nameBindings } = captureAttributeBindings(element);
251
260
  const hasChildren = childNodes.length;
252
261
 
253
262
  if (hasChildren) {
@@ -258,8 +267,8 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
258
267
  parsed,
259
268
  element: elementForBindings,
260
269
  children: recursive(iteratableChildren, undefined, new Set(), stats),
261
- ...(hasAttributeBindings && { attributes }),
262
- ...(hasNameBindings && { nameBindings }),
270
+ ...(attributes && { attributes }),
271
+ ...(nameBindings && { nameBindings }),
263
272
  ...(textNodeRef && { textNode: textNodeRef }),
264
273
  };
265
274
  } else {
@@ -267,8 +276,8 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
267
276
  parsed: innerHTML || textContent,
268
277
  element: elementForBindings,
269
278
  children: {},
270
- ...(hasAttributeBindings && { attributes }),
271
- ...(hasNameBindings && { nameBindings }),
279
+ ...(attributes && { attributes }),
280
+ ...(nameBindings && { nameBindings }),
272
281
  ...(textNodeRef && { textNode: textNodeRef }),
273
282
  };
274
283
  }
@@ -281,29 +290,7 @@ export default (root, rootKey = undefined) => {
281
290
  const { childNodes } = root;
282
291
  const stats = { skipped: 0 };
283
292
 
284
- // Check for attribute bindings on the root element itself (only if element has attributes)
285
- let attributes = null;
286
- let nameBindings = null;
287
- if (root.attributes && root.attributes.length > 0) {
288
- for (let j = 0; j < root.attributes.length; j++) {
289
- const attr = root.attributes[j];
290
- BINDING_REGEX.lastIndex = 0;
291
-
292
- // Check if attribute name contains binding
293
- if (BINDING_REGEX.test(attr.name)) {
294
- if (!nameBindings) nameBindings = [];
295
- nameBindings.push(attr.name);
296
- continue;
297
- }
298
-
299
- // Check if attribute value contains binding
300
- BINDING_REGEX.lastIndex = 0;
301
- if (BINDING_REGEX.test(attr.value)) {
302
- if (!attributes) attributes = {};
303
- attributes[attr.name] = attr.value;
304
- }
305
- }
306
- }
293
+ const { attributes, nameBindings } = captureAttributeBindings(root);
307
294
 
308
295
  return {
309
296
  // html: root.outerHTML,
@@ -60,23 +60,46 @@ export const getCompiledMeta = (iterationNode) => {
60
60
  };
61
61
 
62
62
  /**
63
- * Render iteration using pre-compiled batch function from manifest
63
+ * Build a scoped wrapper that puts state keys in scope for the batch function.
64
+ * The compiler generates (arr, $) => { ... } with bare variable names like `selectedCategory`,
65
+ * but those aren't parameters of the arrow function. We create a wrapper that defines
66
+ * state keys as parameters, then evaluates the batch function in that scope.
64
67
  */
65
- export const renderCompiled = (iterationNode, array, state, compiledMeta, parent, endComment) => {
66
- // Create function from string if not cached
68
+ const buildScopedFn = (batchFnStr, stateKeys) => {
69
+ return new Function(
70
+ ...stateKeys, 'arr',
71
+ `const $ = {${stateKeys.map(k => k + ':' + k).join(',')}};
72
+ const __batchFn = ${batchFnStr};
73
+ return __batchFn(arr, $);`
74
+ );
75
+ };
76
+
77
+ /**
78
+ * Call a compiled batch function with state keys spread into scope
79
+ */
80
+ const callCompiled = (iterationNode, array, state, compiledMeta) => {
67
81
  if (!iterationNode.runtime.compiledFn) {
68
82
  try {
69
- // compiledMeta.batchFn is a complete arrow function: (arr, $) => { ... }
70
- // Wrap in a function that returns it, then call to get the actual function
71
- iterationNode.runtime.compiledFn = new Function('return ' + compiledMeta.batchFn)();
83
+ const stateKeys = Object.keys(state);
84
+ iterationNode.runtime.compiledFn = buildScopedFn(compiledMeta.batchFn, stateKeys);
85
+ iterationNode.runtime.compiledStateKeys = stateKeys;
72
86
  } catch (e) {
73
87
  console.error('[compiled-iteration] Failed to create compiled function:', e);
74
- return false; // Signal failure
88
+ return null;
75
89
  }
76
90
  }
77
91
 
78
- // Build HTML using compiled function
79
- const html = iterationNode.runtime.compiledFn(array, state);
92
+ const keys = iterationNode.runtime.compiledStateKeys;
93
+ const values = keys.map(k => state[k]);
94
+ return iterationNode.runtime.compiledFn(...values, array);
95
+ };
96
+
97
+ /**
98
+ * Render iteration using pre-compiled batch function from manifest
99
+ */
100
+ export const renderCompiled = (iterationNode, array, state, compiledMeta, parent, endComment) => {
101
+ const html = callCompiled(iterationNode, array, state, compiledMeta);
102
+ if (html === null) return false;
80
103
 
81
104
  // Parse and insert
82
105
  if (parseTemplate) {
@@ -118,18 +141,8 @@ export const updateCompiled = (iterationNode, newArray, state, compiledMeta, sta
118
141
  return true;
119
142
  }
120
143
 
121
- // Create function if not cached
122
- if (!iterationNode.runtime.compiledFn) {
123
- try {
124
- iterationNode.runtime.compiledFn = new Function('return ' + compiledMeta.batchFn)();
125
- } catch (e) {
126
- console.error('Failed to create compiled function:', e);
127
- return false;
128
- }
129
- }
130
-
131
- // Build HTML using compiled function
132
- const html = iterationNode.runtime.compiledFn(newArray, state);
144
+ const html = callCompiled(iterationNode, newArray, state, compiledMeta);
145
+ if (html === null) return false;
133
146
 
134
147
  // Parse and insert
135
148
  if (parseTemplate) {
@@ -134,6 +134,14 @@ const detectHyperspeed = async () => {
134
134
  if (hyperspeedDetectionAttempted) return hyperspeedData;
135
135
  hyperspeedDetectionAttempted = true;
136
136
 
137
+ // Runtime-mode pages still have [vibe-fouc] / .vibe-fouc on their vibe root
138
+ // at this point — the compiler strips it at build time, and the runtime only
139
+ // clears it after hydration (PHASE_READY). Vibe can latch to any element, so
140
+ // search the whole document. If any fouc marker is still here, we're in
141
+ // runtime mode and no hyperspeed manifest will exist — skip the network
142
+ // fetches and avoid the 404 devtools noise.
143
+ const skipNetwork = !!document.querySelector("[vibe-fouc], .vibe-fouc");
144
+
137
145
  try {
138
146
  let pagePath = window.location.pathname;
139
147
 
@@ -185,21 +193,38 @@ const detectHyperspeed = async () => {
185
193
  );
186
194
  }
187
195
 
188
- // Try each possible path
189
- for (const manifestPath of possiblePaths) {
190
- try {
191
- const module = await import(manifestPath);
192
- hyperspeedData = {
193
- manifest: module.default,
194
- path: manifestPath,
195
- };
196
- return hyperspeedData;
197
- } catch (e) {
198
- // Try next path
199
- continue;
196
+ if (!skipNetwork) {
197
+ // Fully-runtime dynamic import. Hidden behind `new Function` so any
198
+ // bundler's static-analysis can't read into it — there's nothing we
199
+ // could or should tell it about these manifest paths, which are decided
200
+ // at runtime by searching a list.
201
+ const dynamicImport = new Function('p', 'return import(p)');
202
+ // Try each possible path
203
+ for (const manifestPath of possiblePaths) {
204
+ try {
205
+ const module = await dynamicImport(manifestPath);
206
+ hyperspeedData = {
207
+ manifest: module.default,
208
+ path: manifestPath,
209
+ };
210
+ return hyperspeedData;
211
+ } catch (e) {
212
+ // Try next path
213
+ continue;
214
+ }
200
215
  }
201
216
  }
202
217
 
218
+ // When skipping network, yield a macrotask so module-graph timing matches
219
+ // the old behavior where `await import()` on a missing manifest resolved
220
+ // via a network 404 (macrotask), not a microtask. Without this yield, the
221
+ // vibe module-graph resolves too fast and the post-boot microtask fires
222
+ // before sibling `<script type="module">` tags (e.g. component scripts)
223
+ // have had a chance to register their state.
224
+ if (skipNetwork) {
225
+ await new Promise((resolve) => setTimeout(resolve, 0));
226
+ }
227
+
203
228
  // No manifest found
204
229
  return null;
205
230
  } catch {
@@ -468,7 +493,7 @@ export const restoreMarkersFromManifest = (
468
493
  if (text.startsWith("if")) {
469
494
  if (!startComment) {
470
495
  const expression = childTree.meta?.expression;
471
- const expectedText = expression ? 'if ' + expression : null;
496
+ const expectedText = expression ? "if " + expression : null;
472
497
  if (expectedText && text === expectedText) {
473
498
  startComment = comment;
474
499
  conditionalDepth = 1;