@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.
@@ -3,27 +3,20 @@ import { updateConditional } from './conditionals.js';
3
3
  import { VALUE_ATTRS, DOM_PROPERTIES, BINDING_REGEX, PURE_BINDING_REGEX } from './constants.js';
4
4
  import { evalInScope } from './utils.js';
5
5
 
6
- // Keep track of old state for diffing
7
- let previousState = {};
8
-
9
- export const setPreviousState = (state) => {
10
- previousState = { ...state };
11
- };
12
-
13
- export default (affected, state, manifest = {}) => {
6
+ export default (affected, state, manifest = {}, oldState = {}) => {
14
7
  affected.forEach((aff) => {
15
8
  // Use scoped state if provided (from iteration instances)
16
9
  const effectiveState = aff.scopedState || state;
17
10
 
18
11
  // Handle iteration updates
19
12
  if (aff.type === 'iteration') {
20
- updateIteration(aff.node, state, previousState, manifest);
13
+ updateIteration(aff.node, state, oldState, manifest);
21
14
  return;
22
15
  }
23
16
 
24
17
  // Handle conditional updates
25
18
  if (aff.type === 'conditional') {
26
- updateConditional(aff.node, state, previousState, manifest);
19
+ updateConditional(aff.node, state, oldState, manifest);
27
20
  return;
28
21
  }
29
22
 
@@ -33,7 +26,7 @@ export default (affected, state, manifest = {}) => {
33
26
  try {
34
27
  // HTML lowercases attribute names, so we need case-insensitive lookup
35
28
  // Try exact match first, then try finding a case-insensitive match
36
- let attrName = evalInScope(matchInner, effectiveState);
29
+ let attrName = evalInScope(matchInner, effectiveState, element);
37
30
 
38
31
  // If exact match failed and expression is a simple property (no dots/brackets)
39
32
  if (!attrName && !matchInner.includes('.') && !matchInner.includes('[')) {
@@ -89,14 +82,19 @@ export default (affected, state, manifest = {}) => {
89
82
  attrName.startsWith('on');
90
83
 
91
84
  if (isDomProperty && isPureBinding) {
92
- // For DOM properties like value, set the property directly
85
+ // For DOM properties (value, checked, selected), set BOTH property AND attribute
86
+ // Property: Fast runtime updates, what the user sees
87
+ // Attribute: Truthful DOM representation, enables compiler snapshots via outerHTML
93
88
  const expr = isPureBinding[1];
94
- const value = evalInScope(expr, effectiveState);
89
+ const value = evalInScope(expr, effectiveState, element);
95
90
  element[attrName] = value;
91
+ if (value !== undefined && value !== null) {
92
+ element.setAttribute(attrName, String(value));
93
+ }
96
94
  } else if (!isValueAttr && isPureBinding) {
97
95
  // Boolean-like attributes: add or remove based on truthiness
98
96
  const expr = isPureBinding[1];
99
- const value = evalInScope(expr, effectiveState);
97
+ const value = evalInScope(expr, effectiveState, element);
100
98
  if (value) {
101
99
  element.setAttribute(attrName, '');
102
100
  } else {
@@ -105,7 +103,7 @@ export default (affected, state, manifest = {}) => {
105
103
  } else {
106
104
  // Value attribute - replace bindings with values
107
105
  const newValue = attrValue.replace(BINDING_REGEX, (_, expr) => {
108
- return evalInScope(expr, effectiveState);
106
+ return evalInScope(expr, effectiveState, element);
109
107
  });
110
108
  element.setAttribute(attrName, newValue);
111
109
  }
@@ -114,12 +112,12 @@ export default (affected, state, manifest = {}) => {
114
112
  }
115
113
 
116
114
  // Handle regular element updates
117
- const { matches, matchOuter, matchInner, input, element } = aff;
115
+ const { matches, matchOuter, matchInner, input, element, textNode } = aff;
118
116
 
119
117
  // This prevents undefined store properties to throw an error
120
118
  try {
121
119
  // Evaluate the expression with state as context
122
- const evaluated = evalInScope(matchInner, effectiveState);
120
+ const evaluated = evalInScope(matchInner, effectiveState, element);
123
121
 
124
122
  const toReplace = input.replaceAll(matchOuter, evaluated).trim();
125
123
 
@@ -129,10 +127,14 @@ export default (affected, state, manifest = {}) => {
129
127
  }
130
128
  });
131
129
 
132
- element.textContent = toReplace;
130
+ // If we have a direct reference to the text node, update it specifically
131
+ // This prevents wiping child elements when parent has both text and element children
132
+ if (textNode && textNode.nodeType === 3) {
133
+ textNode.textContent = toReplace;
134
+ } else {
135
+ // Fallback: element has no children or is just a text container
136
+ element.textContent = toReplace;
137
+ }
133
138
  } catch (e) {}
134
139
  });
135
-
136
- // Update previous state for next diff
137
- setPreviousState(state);
138
140
  };
@@ -0,0 +1,425 @@
1
+ /**
2
+ * Hyperspeed - Pre-compiled manifest support for Vibe
3
+ *
4
+ * Handles detection and restoration of pre-compiled pages.
5
+ */
6
+
7
+ // Build hyperspeed manifest with restoration data (captures markers before hydration)
8
+ export const buildHyperspeedManifest = (parsedTree) => {
9
+ // Helper to split text content by @[...] markers into array
10
+ const splitByMarkers = (text) => {
11
+ const regex = /@\[[^\]]+\]/g;
12
+ const parts = [];
13
+ let lastIndex = 0;
14
+
15
+ text.replace(regex, (match, index) => {
16
+ // Add text before marker
17
+ if (index > lastIndex) {
18
+ parts.push(text.slice(lastIndex, index));
19
+ }
20
+ // Add marker
21
+ parts.push(match);
22
+ lastIndex = index + match.length;
23
+ });
24
+
25
+ // Add remaining text
26
+ if (lastIndex < text.length) {
27
+ parts.push(text.slice(lastIndex));
28
+ }
29
+
30
+ return parts.length > 0 ? parts : null;
31
+ };
32
+
33
+ const walkNode = (node) => {
34
+ const result = {
35
+ element: null,
36
+ parsed: [],
37
+ children: {},
38
+ };
39
+
40
+ // Copy type for iterations/conditionals
41
+ if (node.type) {
42
+ result.type = node.type;
43
+ }
44
+
45
+ // Check if parsed string contains bindings - split into array
46
+ if (typeof node.parsed === 'string' && node.parsed.includes('@[')) {
47
+ const parsedArray = splitByMarkers(node.parsed);
48
+ if (parsedArray) {
49
+ result.hyperspeedRestoration = {
50
+ parsed: parsedArray,
51
+ };
52
+ }
53
+ }
54
+
55
+ // Check for attribute bindings
56
+ if (node.attributes && typeof node.attributes === 'object') {
57
+ const attrBindings = {};
58
+ for (const [key, value] of Object.entries(node.attributes)) {
59
+ if (typeof value === 'string' && value.includes('@[')) {
60
+ attrBindings[key] = value;
61
+ }
62
+ }
63
+ if (Object.keys(attrBindings).length > 0) {
64
+ if (!result.hyperspeedRestoration) {
65
+ result.hyperspeedRestoration = {};
66
+ }
67
+ result.hyperspeedRestoration.attributes = attrBindings;
68
+ }
69
+ }
70
+
71
+ // For iterations, extract template as HTML string
72
+ if (node.type === 'iteration' && node.meta?.template?.element) {
73
+ const templateElement = node.meta.template.element;
74
+ if (templateElement && templateElement.innerHTML) {
75
+ if (!result.hyperspeedRestoration) {
76
+ result.hyperspeedRestoration = {};
77
+ }
78
+ result.hyperspeedRestoration.template = templateElement.innerHTML;
79
+ }
80
+ }
81
+
82
+ // For conditionals, extract branch templates as HTML strings
83
+ if (node.type === 'conditional' && node.meta?.branches) {
84
+ const trueBranch = node.meta.branches.true?.element;
85
+ const falseBranch = node.meta.branches.false?.element;
86
+
87
+ if (trueBranch?.innerHTML || falseBranch?.innerHTML) {
88
+ if (!result.hyperspeedRestoration) {
89
+ result.hyperspeedRestoration = {};
90
+ }
91
+ if (trueBranch?.innerHTML) {
92
+ result.hyperspeedRestoration.trueTemplate = trueBranch.innerHTML;
93
+ }
94
+ if (falseBranch?.innerHTML) {
95
+ result.hyperspeedRestoration.falseTemplate = falseBranch.innerHTML;
96
+ }
97
+ }
98
+ }
99
+
100
+ // Recursively process children (skip runtime-only nodes)
101
+ if (node.children && typeof node.children === 'object') {
102
+ for (const [key, childNode] of Object.entries(node.children)) {
103
+ result.children[key] = walkNode(childNode);
104
+ }
105
+ }
106
+
107
+ return result;
108
+ };
109
+
110
+ // Wrap in body structure (parsedTree is already the body's children)
111
+ return {
112
+ element: null,
113
+ parsed: [],
114
+ children: {
115
+ body: {
116
+ element: null,
117
+ parsed: [],
118
+ children: walkNode(parsedTree).children, // Use children directly
119
+ }
120
+ }
121
+ };
122
+ };
123
+
124
+ // Hyperspeed detection - per-page manifest
125
+ // Each compiled page has its own manifest: /vibe-hyperspeed/{page-path}.manifest.js
126
+ let hyperspeedManifest = null;
127
+ let hyperspeedDetectionAttempted = false;
128
+
129
+ /**
130
+ * Detect page-specific manifest (async, cached after first call)
131
+ */
132
+ const detectHyperspeed = async () => {
133
+ if (hyperspeedDetectionAttempted) return hyperspeedManifest;
134
+ hyperspeedDetectionAttempted = true;
135
+
136
+ try {
137
+ const pagePath = window.location.pathname;
138
+ const pathSegments = pagePath.split('/').filter(s => s);
139
+
140
+ if (pathSegments.length === 0) return null;
141
+
142
+ // Extract file name and directory parts
143
+ // For /compiled/playground/test.html -> ['compiled', 'playground', 'test.html']
144
+ const fileName = pathSegments[pathSegments.length - 1];
145
+ const dirSegments = pathSegments.slice(0, -1); // All parts except filename
146
+
147
+ // Build possible manifest paths
148
+ const possiblePaths = [];
149
+
150
+ // Strategy 1: vibe-hyperspeed at the same level as parent directory
151
+ // /compiled/playground/test.html -> /compiled/vibe-hyperspeed/playground/test.html.manifest.js
152
+ if (dirSegments.length >= 1) {
153
+ const subPath = dirSegments.slice(1).join('/'); // Everything after first dir
154
+ const baseDir = '/' + dirSegments[0]; // First directory segment
155
+ possiblePaths.push(`${baseDir}/vibe-hyperspeed/${subPath ? subPath + '/' : ''}${fileName}.manifest.js`);
156
+ }
157
+
158
+ // Strategy 2: vibe-hyperspeed at web root (original behavior)
159
+ // /compiled/playground/test.html -> /vibe-hyperspeed/compiled/playground/test.html.manifest.js
160
+ possiblePaths.push(`/vibe-hyperspeed${pagePath}.manifest.js`);
161
+
162
+ // Strategy 3: vibe-hyperspeed relative to immediate parent
163
+ // /playground/test.html -> /vibe-hyperspeed/playground/test.html.manifest.js
164
+ if (dirSegments.length > 0) {
165
+ const relativePath = dirSegments.join('/');
166
+ possiblePaths.push(`/vibe-hyperspeed/${relativePath}/${fileName}.manifest.js`);
167
+ }
168
+
169
+ // Try each possible path
170
+ for (const manifestPath of possiblePaths) {
171
+ try {
172
+ const module = await import(manifestPath);
173
+ hyperspeedManifest = module.default;
174
+ return hyperspeedManifest;
175
+ } catch {
176
+ // Try next path
177
+ continue;
178
+ }
179
+ }
180
+
181
+ // No manifest found
182
+ return null;
183
+ } catch {
184
+ // No manifest for this page - runtime-only mode
185
+ return null;
186
+ }
187
+ };
188
+
189
+ /**
190
+ * Restore DOM from pre-rendered values to @[...] markers using hyperspeed manifest
191
+ * This enables FOUC-free loading while maintaining runtime reactivity
192
+ *
193
+ * Flow:
194
+ * 1. Page loads with pre-rendered values: <strong>John Doe</strong> (visible, no FOUC)
195
+ * 2. Restoration: Replace with markers: <strong>@[firstName] @[lastName]</strong>
196
+ * 3. Runtime processes normally: Finds markers, makes reactive
197
+ *
198
+ * @param {Element} rootElement - The DOM element to restore (e.g., <body>)
199
+ * @param {Object} subtree - The matching subtree from manifest (e.g., manifest.children.body)
200
+ * @param {Object} fullManifest - The full manifest (unused now, kept for compatibility)
201
+ */
202
+ export const restoreMarkersFromManifest = (rootElement, subtree, fullManifest = null) => {
203
+ const walkTree = (tree, element) => {
204
+ if (!tree || !element) return;
205
+
206
+ // Check for hyperspeed restoration data
207
+ const restoration = tree.hyperspeedRestoration;
208
+
209
+ if (restoration) {
210
+ // Restore text content with markers if parsed contains bindings
211
+ if (restoration.parsed && Array.isArray(restoration.parsed)) {
212
+ const hasBindings = restoration.parsed.some(item =>
213
+ typeof item === 'string' && item.includes('@[')
214
+ );
215
+
216
+ if (hasBindings) {
217
+ // Reconstruct original content with markers
218
+ const originalContent = restoration.parsed.join('');
219
+
220
+ // For text nodes, update parent's innerHTML
221
+ // For elements with children, update only text nodes
222
+ if (element.childNodes.length === 1 && element.childNodes[0].nodeType === 3) {
223
+ // Single text node - replace it
224
+ element.childNodes[0].textContent = originalContent;
225
+ } else if (element.childNodes.length === 0) {
226
+ // No children - set textContent
227
+ element.textContent = originalContent;
228
+ }
229
+ // If element has multiple children, they'll be handled recursively
230
+ }
231
+ }
232
+
233
+ // Skip attribute restoration - manifest merge provides all binding metadata
234
+ // Attributes are already pre-rendered correctly by compiler
235
+ // restoration.attributes exists only for manifest merge, not for DOM restoration
236
+
237
+ // Restore conditionals
238
+ if ((restoration.trueTemplate || restoration.falseTemplate) && tree.type === 'conditional') {
239
+ // Find comment markers
240
+ const walker = document.createTreeWalker(element, NodeFilter.SHOW_COMMENT);
241
+ let startComment = null;
242
+ let elseComment = null;
243
+ let endComment = null;
244
+
245
+ while (walker.nextNode()) {
246
+ const comment = walker.currentNode;
247
+ const text = comment.textContent.trim();
248
+ if (text.startsWith('if')) {
249
+ startComment = comment;
250
+ } else if (text === 'else') {
251
+ elseComment = comment;
252
+ } else if (text === '/if') {
253
+ endComment = comment;
254
+ break;
255
+ }
256
+ }
257
+
258
+ if (startComment && endComment) {
259
+ // Remove pre-rendered content in true branch
260
+ let current = startComment.nextSibling;
261
+ while (current && current !== (elseComment || endComment)) {
262
+ const next = current.nextSibling;
263
+ if (current.nodeType === Node.ELEMENT_NODE) {
264
+ current.remove();
265
+ }
266
+ current = next;
267
+ }
268
+
269
+ // Remove pre-rendered content in false branch if exists
270
+ if (elseComment) {
271
+ current = elseComment.nextSibling;
272
+ while (current && current !== endComment) {
273
+ const next = current.nextSibling;
274
+ if (current.nodeType === Node.ELEMENT_NODE) {
275
+ current.remove();
276
+ }
277
+ current = next;
278
+ }
279
+ }
280
+
281
+ // Insert templates (runtime will process them)
282
+ if (restoration.trueTemplate) {
283
+ const template = document.createElement('div');
284
+ template.innerHTML = restoration.trueTemplate;
285
+ const templateNode = template.firstElementChild; // Use firstElementChild, not firstChild
286
+ if (templateNode) {
287
+ (elseComment || endComment).parentNode.insertBefore(
288
+ templateNode,
289
+ elseComment || endComment
290
+ );
291
+ }
292
+ }
293
+
294
+ if (restoration.falseTemplate && elseComment) {
295
+ const template = document.createElement('div');
296
+ template.innerHTML = restoration.falseTemplate;
297
+ const templateNode = template.firstElementChild; // Use firstElementChild, not firstChild
298
+ if (templateNode) {
299
+ endComment.parentNode.insertBefore(templateNode, endComment);
300
+ }
301
+ }
302
+ }
303
+ }
304
+
305
+ // After restoration, delete hyperspeedRestoration (runtime will parse DOM fresh)
306
+ delete tree.hyperspeedRestoration;
307
+ }
308
+
309
+ // Recursively restore children
310
+ if (tree.children) {
311
+ for (const key in tree.children) {
312
+ const childTree = tree.children[key];
313
+
314
+ // Handle iteration nodes with restoration data
315
+ if (childTree.type === 'iteration' && childTree.hyperspeedRestoration) {
316
+ const restoration = childTree.hyperspeedRestoration;
317
+
318
+ // Find iteration comment by searching (not using index)
319
+ // This avoids index shifting issues after restoration changes DOM
320
+ let startComment = null;
321
+ let endComment = null;
322
+
323
+ const walker = document.createTreeWalker(element, NodeFilter.SHOW_COMMENT);
324
+ while (walker.nextNode()) {
325
+ const comment = walker.currentNode;
326
+ const trimmed = comment.textContent.trim();
327
+
328
+ // Skip already processed iterations
329
+ if (comment._vibeProcessed) continue;
330
+
331
+ if (trimmed.startsWith('each ') && !startComment) {
332
+ startComment = comment;
333
+ } else if (trimmed === '/each' && startComment && !endComment) {
334
+ endComment = comment;
335
+ break;
336
+ }
337
+ }
338
+
339
+ if (startComment && endComment) {
340
+ // Mark as processed
341
+ startComment._vibeProcessed = true;
342
+ endComment._vibeProcessed = true;
343
+
344
+ // Remove all pre-rendered items between comments
345
+ let current = startComment.nextSibling;
346
+ while (current && current !== endComment) {
347
+ const next = current.nextSibling;
348
+ if (current.nodeType === Node.ELEMENT_NODE) {
349
+ current.remove();
350
+ }
351
+ current = next;
352
+ }
353
+
354
+ // Insert template from restoration data
355
+ const temp = document.createElement('div');
356
+ temp.innerHTML = restoration.template;
357
+ const templateNode = temp.firstElementChild;
358
+ if (templateNode) {
359
+ endComment.parentNode.insertBefore(templateNode, endComment);
360
+ }
361
+ }
362
+
363
+ delete childTree.hyperspeedRestoration;
364
+ continue;
365
+ }
366
+
367
+ // Handle conditional nodes (similar approach)
368
+ if (childTree.type === 'conditional') {
369
+ // TODO: Implement conditional restoration when needed
370
+ continue;
371
+ }
372
+
373
+ // Handle text nodes with restoration data
374
+ if (key.startsWith('text_') && childTree.hyperspeedRestoration) {
375
+ const restoration = childTree.hyperspeedRestoration;
376
+ if (restoration.parsed && Array.isArray(restoration.parsed)) {
377
+ const hasBindings = restoration.parsed.some(item =>
378
+ typeof item === 'string' && item.includes('@[')
379
+ );
380
+
381
+ if (hasBindings) {
382
+ const originalContent = restoration.parsed.join('');
383
+ if (element.childNodes.length === 1 && element.childNodes[0].nodeType === 3) {
384
+ element.childNodes[0].textContent = originalContent;
385
+ } else if (element.childNodes.length === 0) {
386
+ element.textContent = originalContent;
387
+ }
388
+ }
389
+ }
390
+ delete childTree.hyperspeedRestoration;
391
+ continue;
392
+ }
393
+
394
+ // Find corresponding child element
395
+ // Keys like "strong_0" use childNodes indices (includes text nodes/comments)
396
+ const match = key.match(/_(\d+)$/);
397
+ if (match) {
398
+ const index = parseInt(match[1], 10);
399
+ const childNode = element.childNodes[index]; // Use childNodes (all nodes)
400
+
401
+ // Only walk if it's an element node
402
+ if (childNode && childNode.nodeType === Node.ELEMENT_NODE) {
403
+ walkTree(childTree, childNode);
404
+ }
405
+ } else {
406
+ // Try to find by tag name for non-indexed keys
407
+ const tagName = key.split('_')[0].toUpperCase();
408
+ const childElement = Array.from(element.children).find(
409
+ child => child.nodeName === tagName
410
+ );
411
+ if (childElement) {
412
+ walkTree(childTree, childElement);
413
+ }
414
+ }
415
+ }
416
+ }
417
+ };
418
+
419
+ walkTree(subtree, rootElement);
420
+ };
421
+
422
+ // Top-level await to detect before module exports
423
+ hyperspeedManifest = await detectHyperspeed();
424
+
425
+ export { hyperspeedManifest };