@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.
@@ -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 };