@ape-egg/vibe 1.2.0 → 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/index.js CHANGED
@@ -17,12 +17,17 @@ import {
17
17
  PHASE_OBSERVE,
18
18
  PHASE_UPDATE,
19
19
  PHASE_MUTATE,
20
- PHASE_COMPLETE,
20
+ PHASE_HYPERSPEED,
21
21
  } from './constants.js';
22
22
  import { processComponent, abortComponentFetch } from './component.js';
23
23
  import { debugLog } from './debug.js';
24
24
  import { shouldCleanup, cleanup } from './cleanup.js';
25
25
  import { generateComponentId, executeComponentScript } from './component-state.js';
26
+ import {
27
+ buildHyperspeedManifest,
28
+ hyperspeedManifest,
29
+ restoreMarkersFromManifest,
30
+ } from './hyperspeed.js';
26
31
 
27
32
  // Wire up cross-module dependency after all modules are loaded
28
33
  setRenderAllConditionals(renderAllConditionals);
@@ -132,73 +137,239 @@ const processCoreLoop = (node, parsedNode, state, manifest, isNewNode, debug) =>
132
137
  return { hydratedCount, iteratedCount, evaluatedCount };
133
138
  };
134
139
 
140
+ /**
141
+ * Deep clone a tree node (to avoid mutating hyperspeed template)
142
+ */
143
+ const deepCloneNode = (node) => {
144
+ if (!node || typeof node !== 'object') return node;
145
+
146
+ const cloned = { ...node };
147
+
148
+ // Clone children recursively
149
+ if (node.children && typeof node.children === 'object') {
150
+ cloned.children = {};
151
+ for (const key in node.children) {
152
+ cloned.children[key] = deepCloneNode(node.children[key]);
153
+ }
154
+ }
155
+
156
+ // Clone meta if it exists
157
+ if (node.meta && typeof node.meta === 'object') {
158
+ cloned.meta = { ...node.meta };
159
+ if (node.meta.template) {
160
+ cloned.meta.template = { ...node.meta.template };
161
+ }
162
+ if (node.meta.branches) {
163
+ cloned.meta.branches = { ...node.meta.branches };
164
+ }
165
+ }
166
+
167
+ // Clone runtime if it exists
168
+ if (node.runtime && typeof node.runtime === 'object') {
169
+ cloned.runtime = { ...node.runtime };
170
+ if (Array.isArray(node.runtime.instances)) {
171
+ cloned.runtime.instances = [...node.runtime.instances];
172
+ }
173
+ }
174
+
175
+ return cloned;
176
+ };
177
+
178
+ /**
179
+ * Merge hyperspeed manifest with runtime manifest
180
+ * Hyperspeed provides structure/metadata, runtime populates DOM references
181
+ */
182
+ const mergeManifests = (hyperspeedTree, runtimeTree) => {
183
+ if (!hyperspeedTree) return runtimeTree;
184
+ if (!runtimeTree) return hyperspeedTree;
185
+
186
+ // Start with deep clone of hyperspeed (foundation)
187
+ const merged = deepCloneNode(hyperspeedTree);
188
+
189
+ // Helper to recursively augment hyperspeed with runtime data
190
+ const augmentWithRuntime = (mergedNode, runtimeNode) => {
191
+ if (!runtimeNode) return;
192
+
193
+ // Populate DOM references from runtime
194
+ if (runtimeNode.element) {
195
+ mergedNode.element = runtimeNode.element;
196
+ }
197
+
198
+ // Populate parsed data from runtime
199
+ if (runtimeNode.parsed) {
200
+ mergedNode.parsed = runtimeNode.parsed;
201
+ }
202
+
203
+ // For iterations: populate all meta and runtime from runtime
204
+ if (mergedNode.type === 'iteration' && runtimeNode.type === 'iteration') {
205
+ // Copy entire meta object from runtime (all properties needed)
206
+ mergedNode.meta = runtimeNode.meta;
207
+ // Copy runtime object (instances, etc.)
208
+ mergedNode.runtime = runtimeNode.runtime;
209
+ }
210
+
211
+ // For conditionals: populate all meta from runtime
212
+ if (mergedNode.type === 'conditional' && runtimeNode.type === 'conditional') {
213
+ // Copy entire meta object from runtime (all properties needed)
214
+ mergedNode.meta = runtimeNode.meta;
215
+ }
216
+
217
+ // Augment children recursively
218
+ if (runtimeNode.children) {
219
+ if (!mergedNode.children) mergedNode.children = {};
220
+
221
+ for (const key in runtimeNode.children) {
222
+ const runtimeChild = runtimeNode.children[key];
223
+
224
+ // Skip iterations - let runtime control them entirely
225
+ // Iterations are dynamic and restoration changes DOM structure
226
+ if (runtimeChild.type === 'iteration') {
227
+ mergedNode.children[key] = runtimeChild;
228
+ continue;
229
+ }
230
+
231
+ if (mergedNode.children[key]) {
232
+ // Child exists in both - augment it
233
+ augmentWithRuntime(mergedNode.children[key], runtimeNode.children[key]);
234
+ } else {
235
+ // Child only in runtime - add it (dynamic element discovered at runtime)
236
+ mergedNode.children[key] = runtimeNode.children[key];
237
+ }
238
+ }
239
+ }
240
+ };
241
+
242
+ // Augment hyperspeed foundation with runtime data
243
+ augmentWithRuntime(merged, runtimeTree);
244
+
245
+ // Preserve stats from runtime (hyperspeed won't have stats)
246
+ if (runtimeTree.stats) {
247
+ merged.stats = runtimeTree.stats;
248
+ }
249
+
250
+ return merged;
251
+ };
252
+
135
253
  // Store previous state for comparison (needs to be accessible by core loop)
136
254
  let previousState = {};
137
255
 
138
- const main = (s, attrName = 'vibe', config = {}) => {
256
+ const main = (s, config = {}, stringSelector = '') => {
139
257
  const debug = !!config?.debug;
140
258
  const verbose = !!config?.verbose;
141
259
 
260
+ // Detect if running in compiler's headless browser
261
+ // When true: skip cleanup to preserve [vibe] attribute in compiled HTML
262
+ const isCompiling = typeof window !== 'undefined' && window.__vibeCompiling === true;
263
+
142
264
  // Reset previous state for each new instance
143
265
  previousState = {};
144
266
 
145
- // Find element(s) with the specified attribute
146
- const elements = document.querySelectorAll(`[${attrName}]`);
267
+ // Use page-specific hyperspeed manifest (detected at module load)
268
+ const hyperspeedTree = hyperspeedManifest;
147
269
 
148
- if (elements.length === 0) {
149
- console.info(`[vibe] No element found with attribute "${attrName}". Falling back to body.`);
150
- } else if (elements.length > 1) {
151
- console.info(
152
- `[vibe] Multiple elements (${elements.length}) found with attribute "${attrName}". Hydrating the first one.`,
153
- );
154
- }
270
+ let rootElement = document.body;
155
271
 
156
- const rootElement = elements[0] || document.body;
272
+ if (stringSelector) {
273
+ // Find element(s) with the specified attribute
274
+ const elements = document.querySelectorAll(`${stringSelector}`);
275
+ if (elements?.[0]) {
276
+ rootElement = elements[0];
277
+ }
278
+ }
157
279
 
158
- debugLog(PHASE_ATTACH, `Vibe attached to`, debug, 0, rootElement);
280
+ debugLog(PHASE_ATTACH, `Vibe attached to`, true, 0, rootElement);
281
+
282
+ // Component tagging is now handled by component.js before boot
283
+ // Component state is merged into s by boot.js
284
+
285
+ // RESTORATION PHASE: If hyperspeed detected, restore @[...] markers in DOM
286
+ // This allows pre-rendered values to be visible (no FOUC) but makes DOM reactive
287
+ let hyperspeedSubtree = null;
288
+ if (hyperspeedTree) {
289
+ debugLog(PHASE_HYPERSPEED, 'Applied pre-compiled vibe-hyperspeed/**/*.manifest.js', debug);
290
+
291
+ // Find matching subtree by DOM path (not just tag name)
292
+ // Build path from document to rootElement
293
+ const buildDomPath = (element) => {
294
+ const path = [];
295
+ let current = element;
296
+
297
+ // Walk up to document itself (include html in the path)
298
+ while (current && current.parentNode && current !== document) {
299
+ const parent = current.parentNode;
300
+
301
+ // Skip document node itself
302
+ if (parent === document) {
303
+ const siblings = Array.from(document.childNodes);
304
+ const index = siblings.indexOf(current);
305
+ const tag = current.nodeName.toLowerCase();
306
+ path.unshift({ tag, index });
307
+ break;
308
+ }
159
309
 
160
- // Process inline component scripts FIRST (before parse)
161
- // This sets componentIds so parse can rewrite event handlers
162
- const componentScripts = rootElement.querySelectorAll('script[type="component"]');
310
+ const siblings = Array.from(parent.childNodes);
311
+ const index = siblings.indexOf(current);
312
+ const tag = current.nodeName.toLowerCase();
163
313
 
164
- if (componentScripts.length > 0) {
165
- componentScripts.forEach((script) => {
166
- // Skip if already processed (e.g., by auto.js)
167
- if (script.hasAttribute('data-vibe-component-id')) return;
314
+ path.unshift({ tag, index });
315
+ current = parent;
316
+ }
168
317
 
169
- const scriptContent = script.textContent?.trim() || '';
170
- if (!scriptContent) return;
318
+ return path;
319
+ };
171
320
 
172
- const componentId = generateComponentId();
321
+ // Walk manifest tree following DOM path
322
+ const findManifestNodeByPath = (manifest, path) => {
323
+ let current = manifest;
173
324
 
174
- // Store componentId on script element BEFORE parsing
175
- script.setAttribute('data-vibe-component-id', componentId);
325
+ for (const { tag, index } of path) {
326
+ if (!current || !current.children) return null;
176
327
 
177
- // Tag all following sibling elements with this component ID (shallow)
178
- // Stop at next component script or end of siblings
179
- let sibling = script.nextElementSibling;
180
- while (sibling) {
181
- // Stop if we hit another component script
182
- if (sibling.tagName === 'SCRIPT' && sibling.getAttribute('type') === 'component') {
183
- break;
328
+ // Look for matching child by tag_index pattern
329
+ const key = `${tag}_${index}`;
330
+ if (current.children[key]) {
331
+ current = current.children[key];
332
+ } else {
333
+ // Fallback: search all children for matching tag at this level
334
+ let found = false;
335
+ for (const childKey in current.children) {
336
+ if (childKey.startsWith(tag + '_')) {
337
+ current = current.children[childKey];
338
+ found = true;
339
+ break;
340
+ }
341
+ }
342
+ if (!found) return null;
184
343
  }
185
- // Tag only the sibling (descendants will use closest() to find it)
186
- sibling.setAttribute('data-vibe-component-id', componentId);
187
- sibling = sibling.nextElementSibling;
188
344
  }
189
345
 
190
- // Execute component script to get state
191
- const componentState = executeComponentScript(scriptContent);
346
+ return current;
347
+ };
192
348
 
193
- // Add component state to initial state
194
- s[componentId] = componentState;
195
- });
349
+ const domPath = buildDomPath(rootElement);
350
+ hyperspeedSubtree = findManifestNodeByPath(hyperspeedTree, domPath);
351
+
352
+ if (!hyperspeedSubtree) {
353
+ // Fallback to root if path matching fails
354
+ hyperspeedSubtree = hyperspeedTree;
355
+ }
356
+
357
+ restoreMarkersFromManifest(rootElement, hyperspeedSubtree, hyperspeedTree);
196
358
  }
197
359
 
198
- // Now parse (event handlers will be rewritten correctly)
360
+ // Runtime parses DOM (which now has restored markers if hyperspeed was used)
199
361
  let parsedTree = parse(rootElement);
362
+
363
+ // Merge with hyperspeed if available (hyperspeed as foundation, runtime augments)
364
+ if (hyperspeedSubtree) {
365
+ parsedTree = mergeManifests(hyperspeedSubtree, parsedTree);
366
+ }
367
+
200
368
  let manifest = createManifest(parsedTree);
201
369
 
370
+ // Build hyperspeed manifest (before hydration, extract markers from parsed strings)
371
+ const hyperspeedManifestData = buildHyperspeedManifest(parsedTree);
372
+
202
373
  const manifestEntries = Object.entries(manifest);
203
374
  const totalCount = manifestEntries.length;
204
375
  const elementsOnly = manifestEntries.filter(
@@ -220,7 +391,11 @@ const main = (s, attrName = 'vibe', config = {}) => {
220
391
  );
221
392
  }
222
393
  debugLog(PHASE_PARSE, segments, debug);
223
- debugLog(PHASE_MANIFEST, `DOM manifest created`, debug);
394
+ debugLog(
395
+ PHASE_MANIFEST,
396
+ hyperspeedTree ? 'DOM manifest merged with hyperspeed' : 'DOM manifest created',
397
+ debug,
398
+ );
224
399
 
225
400
  // Lifecycle hooks that users can subscribe to
226
401
  const hooks = {
@@ -241,9 +416,7 @@ const main = (s, attrName = 'vibe', config = {}) => {
241
416
  return plain;
242
417
  };
243
418
 
244
- // Observer reference and callback - defined here so state handler can access processMutations
245
- let observer = null;
246
- let processMutations = null;
419
+ // Observer callback will be defined below (already declared above before processComponent)
247
420
 
248
421
  const $ = state(s, (newState, oldState) => {
249
422
  // Extract current state (after mutation)
@@ -350,6 +523,10 @@ const main = (s, attrName = 'vibe', config = {}) => {
350
523
  // After all initial rendering, capture a clean snapshot for comparison
351
524
  previousState = extractPlainValue($);
352
525
 
526
+ // Observer reference and processMutations
527
+ let observer = null;
528
+ let processMutations = null;
529
+
353
530
  // Define observer callback as named function so we can call it manually for pending mutations
354
531
  processMutations = (mutations) => {
355
532
  // Early exit if no mutations to process (common case)
@@ -436,8 +613,13 @@ const main = (s, attrName = 'vibe', config = {}) => {
436
613
  picked.element = target;
437
614
  }
438
615
 
439
- // Parse the newly added node
440
- const name = `${node.nodeName.toLowerCase()}_${hash()}`;
616
+ // Parse the newly added node (use monotonically increasing counter for deterministic key)
617
+ // Initialize counter if it doesn't exist
618
+ if (!picked._nextChildIndex) {
619
+ picked._nextChildIndex = Object.keys(picked.children).length;
620
+ }
621
+ const name = `${node.nodeName.toLowerCase()}_${picked._nextChildIndex}`;
622
+ picked._nextChildIndex++; // Always increment, never decrement
441
623
  const parsedNode = parse(node);
442
624
 
443
625
  // Accumulate skipped stats
@@ -633,7 +815,7 @@ const main = (s, attrName = 'vibe', config = {}) => {
633
815
 
634
816
  // Check if cleanup should run
635
817
  const checkCleanup = () => {
636
- if (cleanupExecuted) return;
818
+ if (cleanupExecuted || isCompiling) return;
637
819
 
638
820
  // Check for pending mutations first
639
821
  const pendingMutations = observer ? observer.takeRecords() : [];
@@ -646,7 +828,7 @@ const main = (s, attrName = 'vibe', config = {}) => {
646
828
 
647
829
  // Check if all processing is complete
648
830
  if (shouldCleanup(rootElement)) {
649
- cleanup(rootElement, attrName, debug);
831
+ cleanup(rootElement, debug);
650
832
  cleanupExecuted = true;
651
833
  }
652
834
  };
@@ -674,6 +856,11 @@ const main = (s, attrName = 'vibe', config = {}) => {
674
856
  componentConfig,
675
857
  );
676
858
 
859
+ // Export hyperspeed manifest globally for compiler extraction
860
+ if (typeof window !== 'undefined') {
861
+ window.__vibeManifest = hyperspeedManifestData;
862
+ }
863
+
677
864
  return $;
678
865
  };
679
866
 
@@ -322,8 +322,11 @@ export const renderIteration = (iterationNode, state, manifest, parentScope = {}
322
322
  iterationNode.runtime.instances = instances;
323
323
 
324
324
  // Mark comment as rendered (survives re-parsing since comment nodes persist in DOM)
325
+ // Also store runtime data on the DOM node so it persists across re-parses
325
326
  // @ts-ignore - adding custom property to comment node
326
327
  startComment.__vibeRendered = true;
328
+ // @ts-ignore - adding custom property to comment node
329
+ startComment.__vibeIterationRuntime = iterationNode.runtime;
327
330
  };
328
331
 
329
332
  // Update an iteration block when array changes
package/runtime/parse.js CHANGED
@@ -1,4 +1,3 @@
1
- import { hash } from './utils.js';
2
1
  import { findEndComment, findConditionalEnd } from './iteration-utils.js';
3
2
  import {
4
3
  NON_REACTIVE_ELEMENTS,
@@ -60,9 +59,9 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
60
59
  // Parse the template recursively
61
60
  const templateParsed = recursive([...templateContainer.childNodes], undefined, new Set(), stats);
62
61
 
63
- // Store iteration metadata
64
- const iterationKey = `iteration_${hash()}`;
65
- result[iterationKey] = {
62
+ // Store iteration metadata (use index for deterministic keys)
63
+ const iterationKey = `iteration_${i}`;
64
+ const iterationNode = {
66
65
  type: 'iteration',
67
66
  meta: {
68
67
  arrayPath,
@@ -76,12 +75,15 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
76
75
  children: templateParsed,
77
76
  },
78
77
  },
79
- 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 || {
80
81
  instances: [],
81
82
  templateRemoved: false,
82
83
  },
83
84
  children: {},
84
85
  };
86
+ result[iterationKey] = iterationNode;
85
87
 
86
88
  // Mark template indices as processed
87
89
  for (let j = i + 1; j < endIndex; j++) {
@@ -133,8 +135,8 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
133
135
  falseBranchParsed = recursive([...falseBranchContainer.childNodes], undefined, new Set(), stats);
134
136
  }
135
137
 
136
- // Store conditional metadata
137
- const conditionalKey = `conditional_${hash()}`;
138
+ // Store conditional metadata (use index for deterministic keys)
139
+ const conditionalKey = `conditional_${i}`;
138
140
  result[conditionalKey] = {
139
141
  type: 'conditional',
140
142
  meta: {
@@ -194,7 +196,13 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
194
196
  // Check for attribute bindings
195
197
  const attributes = {};
196
198
  const nameBindings = [];
197
- 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) {
198
206
  for (let j = 0; j < element.attributes.length; j++) {
199
207
  const attr = element.attributes[j];
200
208
  // Reset lastIndex before test - BINDING_REGEX has 'g' flag which persists state
@@ -227,29 +235,13 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
227
235
  }
228
236
 
229
237
  // Helper to find component ID for an element
238
+ // Looks for nearest ancestor with data-vibe-component-id
230
239
  function findComponentIdForElement(element) {
231
- if (!element || !element.parentElement) return null;
232
-
233
- let current = element;
234
-
235
- while (current && current !== document.body) {
236
- // Check previous siblings for script[data-vibe-component-id]
237
- let sibling = current.previousElementSibling;
238
- while (sibling) {
239
- if (
240
- sibling.tagName === 'SCRIPT' &&
241
- sibling.getAttribute('type') === 'component' &&
242
- sibling.hasAttribute('data-vibe-component-id')
243
- ) {
244
- return sibling.getAttribute('data-vibe-component-id');
245
- }
246
- sibling = sibling.previousElementSibling;
247
- }
248
-
249
- current = current.parentElement;
250
- }
240
+ if (!element) return null;
251
241
 
252
- return null;
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;
253
245
  }
254
246
  const hasAttributeBindings = Object.keys(attributes).length > 0;
255
247
  const hasNameBindings = nameBindings.length > 0;
package/runtime/scope.js CHANGED
@@ -2,36 +2,16 @@
2
2
 
3
3
  /**
4
4
  * Find the component ID that owns this element
5
- * Walks up DOM to find nearest script[data-vibe-component-id]
5
+ * Finds nearest ancestor with data-vibe-component-id (set by component.js)
6
6
  * @param {Element} element - DOM element to find component for
7
7
  * @returns {string|null} - Component ID or null if not in component scope
8
8
  */
9
9
  export const findComponentId = (element) => {
10
- let current = element;
10
+ if (!element || !element.closest) return null;
11
11
 
12
- while (current && current !== document.body) {
13
- // Check if this element has component ID
14
- if (current.hasAttribute && current.hasAttribute('data-vibe-component-id')) {
15
- return current.getAttribute('data-vibe-component-id');
16
- }
17
-
18
- // Check previous siblings for script[data-vibe-component-id]
19
- let sibling = current.previousElementSibling;
20
- while (sibling) {
21
- if (
22
- sibling.tagName === 'SCRIPT' &&
23
- sibling.getAttribute('type') === 'component' &&
24
- sibling.hasAttribute('data-vibe-component-id')
25
- ) {
26
- return sibling.getAttribute('data-vibe-component-id');
27
- }
28
- sibling = sibling.previousElementSibling;
29
- }
30
-
31
- current = current.parentElement;
32
- }
33
-
34
- return null;
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;
35
15
  };
36
16
 
37
17
  /**
package/runtime/utils.js CHANGED
@@ -50,21 +50,12 @@ export const evalInScope = (expr, state, element = null) => {
50
50
  };
51
51
 
52
52
  // Helper to find component ID for an element
53
+ // Walks up DOM tree to find nearest component wrapper
53
54
  export const findComponentIdForElement = (element) => {
54
- if (!element) return null;
55
+ if (!element || !element.closest) return null;
55
56
 
56
- // First check if element itself has the ID (tagged during parse)
57
- if (element.hasAttribute('data-vibe-component-id')) {
58
- return element.getAttribute('data-vibe-component-id');
59
- }
60
-
61
- // Check if any ancestor has the ID
62
- const ancestor = element.closest('[data-vibe-component-id]');
63
- if (ancestor) {
64
- return ancestor.getAttribute('data-vibe-component-id');
65
- }
66
-
67
- return null;
57
+ const wrapper = element.closest('[data-vibe-component-id]');
58
+ return wrapper ? wrapper.getAttribute('data-vibe-component-id') : null;
68
59
  };
69
60
 
70
61
  // Helper to resolve this.property paths to componentId.property
package/vibe.css CHANGED
@@ -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
  }