@ape-egg/vibe 4.0.0 → 4.1.1

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,28 +1,17 @@
1
- /**
2
- * Pre-compiled Manifest - Manifest support for pre-compiled Vibe pages
3
- *
4
- * Handles detection, loading, and restoration of pre-compiled page manifests.
5
- */
6
-
7
- // Build manifest with restoration data (captures markers before hydration)
8
1
  export const buildHyperspeedManifest = (parsedTree) => {
9
- // Helper to split text content by @[...] markers into array
10
2
  const splitByMarkers = (text) => {
11
3
  const regex = /@\[[^\]]+\]/g;
12
4
  const parts = [];
13
5
  let lastIndex = 0;
14
6
 
15
7
  text.replace(regex, (match, index) => {
16
- // Add text before marker
17
8
  if (index > lastIndex) {
18
9
  parts.push(text.slice(lastIndex, index));
19
10
  }
20
- // Add marker
21
11
  parts.push(match);
22
12
  lastIndex = index + match.length;
23
13
  });
24
14
 
25
- // Add remaining text
26
15
  if (lastIndex < text.length) {
27
16
  parts.push(text.slice(lastIndex));
28
17
  }
@@ -37,12 +26,10 @@ export const buildHyperspeedManifest = (parsedTree) => {
37
26
  children: {},
38
27
  };
39
28
 
40
- // Copy type for iterations/conditionals
41
29
  if (node.type) {
42
30
  result.type = node.type;
43
31
  }
44
32
 
45
- // Check if parsed string contains bindings - split into array
46
33
  if (typeof node.parsed === "string" && node.parsed.includes("@[")) {
47
34
  const parsedArray = splitByMarkers(node.parsed);
48
35
  if (parsedArray) {
@@ -52,7 +39,6 @@ export const buildHyperspeedManifest = (parsedTree) => {
52
39
  }
53
40
  }
54
41
 
55
- // Check for attribute bindings
56
42
  if (node.attributes && typeof node.attributes === "object") {
57
43
  const attrBindings = {};
58
44
  for (const [key, value] of Object.entries(node.attributes)) {
@@ -68,7 +54,6 @@ export const buildHyperspeedManifest = (parsedTree) => {
68
54
  }
69
55
  }
70
56
 
71
- // For iterations, extract template as HTML string
72
57
  if (node.type === "iteration" && node.meta?.template?.element) {
73
58
  const templateElement = node.meta.template.element;
74
59
  if (templateElement && templateElement.innerHTML) {
@@ -79,7 +64,6 @@ export const buildHyperspeedManifest = (parsedTree) => {
79
64
  }
80
65
  }
81
66
 
82
- // For conditionals, extract branch templates as HTML strings
83
67
  if (node.type === "conditional" && node.meta?.branches) {
84
68
  const trueBranch = node.meta.branches.true?.element;
85
69
  const falseBranch = node.meta.branches.false?.element;
@@ -97,7 +81,6 @@ export const buildHyperspeedManifest = (parsedTree) => {
97
81
  }
98
82
  }
99
83
 
100
- // Recursively process children (skip runtime-only nodes)
101
84
  if (node.children && typeof node.children === "object") {
102
85
  for (const [key, childNode] of Object.entries(node.children)) {
103
86
  result.children[key] = walkNode(childNode);
@@ -107,7 +90,6 @@ export const buildHyperspeedManifest = (parsedTree) => {
107
90
  return result;
108
91
  };
109
92
 
110
- // Wrap in body structure (parsedTree is already the body's children)
111
93
  return {
112
94
  element: null,
113
95
  parsed: [],
@@ -115,37 +97,18 @@ export const buildHyperspeedManifest = (parsedTree) => {
115
97
  body: {
116
98
  element: null,
117
99
  parsed: [],
118
- children: walkNode(parsedTree).children, // Use children directly
100
+ children: walkNode(parsedTree).children,
119
101
  },
120
102
  },
121
103
  };
122
104
  };
123
105
 
124
- // Manifest detection - per-page manifest
125
- // Each compiled page has its own manifest: /vibe-hyperspeed/{page-path}.manifest.js
126
106
  let hyperspeedData = null;
127
107
  let hyperspeedDetectionAttempted = false;
128
108
 
129
- /**
130
- * Build the ordered list of manifest URLs to try for a page, most-likely first.
131
- *
132
- * `pathname` is window.location.pathname; `route` is the optional route template
133
- * the page declares (window.__ROUTE__, e.g. "/brawlers/:index"). When the route
134
- * marks a segment dynamic with `:param`, the compiler has collapsed that segment
135
- * to `$` in the manifest path — so we point straight at the tokenized manifest
136
- * instead of probing literal paths (`/brawlers/0.html.manifest.js`) that are
137
- * guaranteed to 404. Without the route hint the original literal-first strategies
138
- * apply unchanged. The result is de-duplicated (subdirectory pages otherwise
139
- * produce the same candidate twice).
140
- *
141
- * @param {string} pathname
142
- * @param {string|null|undefined} route
143
- * @returns {string[]}
144
- */
145
109
  export const buildManifestCandidatePaths = (pathname, route) => {
146
110
  let pagePath = pathname;
147
111
 
148
- // Normalize path: handle directory URLs and missing extensions
149
112
  if (pagePath.endsWith("/")) {
150
113
  pagePath = pagePath + "index.html";
151
114
  } else if (!pagePath.includes(".")) {
@@ -164,23 +127,12 @@ export const buildManifestCandidatePaths = (pathname, route) => {
164
127
 
165
128
  const possiblePaths = [];
166
129
 
167
- // Route-aware fast path: a `:segment` in the declared route is a dynamic param
168
- // the compiler tokenized to `$`. Tokenize exactly those positions (params can
169
- // sit mid-path, e.g. /a/:id/b) and try that manifest first — a direct hit, no
170
- // 404 probing. Skipped entirely when no route is declared.
171
130
  const routeSegments = route ? route.split("/").filter((s) => s) : null;
172
131
  const isCatchAll =
173
132
  routeSegments &&
174
133
  routeSegments[routeSegments.length - 1]?.startsWith(":") &&
175
134
  routeSegments[routeSegments.length - 1]?.endsWith("*");
176
135
  if (isCatchAll) {
177
- // A trailing `:name*` catch-all (pages/x/$$name.html) swallows every
178
- // remaining URL segment — zero or more — so tokenizing by position is
179
- // meaningless past the static prefix. The compiler collapses the whole
180
- // `$$name.html` file to the same `$` token as a single `$param`, giving
181
- // ONE manifest for every depth: <static-prefix>/$.html.manifest.js.
182
- // Built from the raw pathname: with zero extra segments the .html
183
- // normalization above has already mutated the prefix's last segment.
184
136
  const rawSegments = pathname.split("/").filter((s) => s);
185
137
  const prefix = rawSegments
186
138
  .slice(0, routeSegments.length - 1)
@@ -195,22 +147,16 @@ export const buildManifestCandidatePaths = (pathname, route) => {
195
147
  possiblePaths.push(`/vibe-hyperspeed/${tokenized.join("/")}.manifest.js`);
196
148
  }
197
149
 
198
- // Strategy 1: vibe-hyperspeed at the same level as parent directory
199
- // /compiled/playground/test.html -> /compiled/vibe-hyperspeed/playground/test.html.manifest.js
200
150
  if (dirSegments.length >= 1) {
201
- const subPath = dirSegments.slice(1).join("/"); // Everything after first dir
202
- const baseDir = "/" + dirSegments[0]; // First directory segment
151
+ const subPath = dirSegments.slice(1).join("/");
152
+ const baseDir = "/" + dirSegments[0];
203
153
  possiblePaths.push(
204
154
  `${baseDir}/vibe-hyperspeed/${subPath ? subPath + "/" : ""}${fileName}.manifest.js`,
205
155
  );
206
156
  }
207
157
 
208
- // Strategy 2: vibe-hyperspeed at web root (original behavior)
209
- // /compiled/playground/test.html -> /vibe-hyperspeed/compiled/playground/test.html.manifest.js
210
158
  possiblePaths.push(`/vibe-hyperspeed${pagePath}.manifest.js`);
211
159
 
212
- // Strategy 3: vibe-hyperspeed relative to immediate parent
213
- // /playground/test.html -> /vibe-hyperspeed/playground/test.html.manifest.js
214
160
  if (dirSegments.length > 0) {
215
161
  const relativePath = dirSegments.join("/");
216
162
  possiblePaths.push(
@@ -218,11 +164,6 @@ export const buildManifestCandidatePaths = (pathname, route) => {
218
164
  );
219
165
  }
220
166
 
221
- // Strategy 4: dynamic routes without a declared route. The compiler collapses a
222
- // `$param` segment to a single `$` token (the-arena/$id.html ->
223
- // the-arena/$.html.manifest.js), so a concrete URL only matches once its
224
- // trailing segment is tokenized. Tried after the literal strategies, so static
225
- // pages still win on an exact hit.
226
167
  const dot = fileName.indexOf(".");
227
168
  const tokenized = dot >= 0 ? "$" + fileName.slice(dot) : "$";
228
169
  if (tokenized !== fileName) {
@@ -230,37 +171,16 @@ export const buildManifestCandidatePaths = (pathname, route) => {
230
171
  possiblePaths.push(`/vibe-hyperspeed${dirPrefix}/${tokenized}.manifest.js`);
231
172
  }
232
173
 
233
- // Subdirectory pages make strategies 2 and 3 collapse to the same URL — probe
234
- // each candidate once.
235
174
  return [...new Set(possiblePaths)];
236
175
  };
237
176
 
238
- /**
239
- * Detect page-specific manifest (async, cached after first call)
240
- * Returns { manifest, path } or null
241
- */
242
177
  const detectHyperspeed = async () => {
243
178
  if (hyperspeedDetectionAttempted) return hyperspeedData;
244
179
  hyperspeedDetectionAttempted = true;
245
180
 
246
- // Runtime-mode pages still have [vibe-fouc] / .vibe-fouc on their vibe root
247
- // at this point — the compiler strips it at build time, and the runtime only
248
- // clears it after hydration (PHASE_READY). Vibe can latch to any element, so
249
- // search the whole document. If any fouc marker is still here, we're in
250
- // runtime mode and no hyperspeed manifest will exist — skip the network
251
- // fetches and avoid the 404 devtools noise.
252
181
  const skipNetwork = !!document.querySelector("[vibe-fouc], .vibe-fouc");
253
182
 
254
183
  try {
255
- // window.__MANIFEST__ is an explicit manifest URL stamped into the page by
256
- // the build/dev server. The compiled SPA shell needs it: the shell is
257
- // served for EVERY route, so pathname-derived candidates point at manifests
258
- // that don't exist (deep links probed 404s and never found the shell's own
259
- // manifest). When present it is the single, authoritative candidate.
260
- //
261
- // window.__ROUTE__ is the page's route template (e.g. "/brawlers/:index"),
262
- // injected by the compiler/dev server for dynamic pages. It lets us resolve
263
- // the tokenized `$` manifest directly instead of probing literal 404s.
264
184
  const hinted = typeof window !== "undefined" ? window.__MANIFEST__ : null;
265
185
  const possiblePaths = hinted
266
186
  ? [hinted]
@@ -272,12 +192,7 @@ const detectHyperspeed = async () => {
272
192
  if (possiblePaths.length === 0) return null;
273
193
 
274
194
  if (!skipNetwork) {
275
- // Fully-runtime dynamic import. Hidden behind `new Function` so any
276
- // bundler's static-analysis can't read into it — there's nothing we
277
- // could or should tell it about these manifest paths, which are decided
278
- // at runtime by searching a list.
279
195
  const dynamicImport = new Function('p', 'return import(p)');
280
- // Try each possible path
281
196
  for (const manifestPath of possiblePaths) {
282
197
  try {
283
198
  const module = await dynamicImport(manifestPath);
@@ -287,43 +202,21 @@ const detectHyperspeed = async () => {
287
202
  };
288
203
  return hyperspeedData;
289
204
  } catch (e) {
290
- // Try next path
291
205
  continue;
292
206
  }
293
207
  }
294
208
  }
295
209
 
296
- // When skipping network, yield a macrotask so module-graph timing matches
297
- // the old behavior where `await import()` on a missing manifest resolved
298
- // via a network 404 (macrotask), not a microtask. Without this yield, the
299
- // vibe module-graph resolves too fast and the post-boot microtask fires
300
- // before sibling `<script type="module">` tags (e.g. component scripts)
301
- // have had a chance to register their state.
302
210
  if (skipNetwork) {
303
211
  await new Promise((resolve) => setTimeout(resolve, 0));
304
212
  }
305
213
 
306
- // No manifest found
307
214
  return null;
308
215
  } catch {
309
- // No manifest for this page - runtime-only mode
310
216
  return null;
311
217
  }
312
218
  };
313
219
 
314
- /**
315
- * Restore DOM from pre-rendered values to @[...] markers using pre-compiled manifest
316
- * This enables FOUC-free loading while maintaining runtime reactivity
317
- *
318
- * Flow:
319
- * 1. Page loads with pre-rendered values: <strong>John Doe</strong> (visible, no FOUC)
320
- * 2. Restoration: Replace with markers: <strong>@[firstName] @[lastName]</strong>
321
- * 3. Runtime processes normally: Finds markers, makes reactive
322
- *
323
- * @param {Element} rootElement - The DOM element to restore (e.g., <body>)
324
- * @param {Object} subtree - The matching subtree from manifest (e.g., manifest.children.body)
325
- * @param {Object} fullManifest - The full manifest (unused now, kept for compatibility)
326
- */
327
220
  export const restoreMarkersFromManifest = (
328
221
  rootElement,
329
222
  subtree,
@@ -332,68 +225,46 @@ export const restoreMarkersFromManifest = (
332
225
  const walkTree = (tree, element) => {
333
226
  if (!tree || !element) return;
334
227
 
335
- // Check for compiled restoration data
336
228
  const restoration = tree.compiled?.restoration;
337
229
 
338
230
  if (restoration) {
339
- // Restore text content with markers if parsed contains bindings
340
231
  if (restoration.parsed && Array.isArray(restoration.parsed)) {
341
232
  const hasBindings = restoration.parsed.some(
342
233
  (item) => typeof item === "string" && item.includes("@["),
343
234
  );
344
235
 
345
236
  if (hasBindings) {
346
- // Reconstruct original content with markers
347
237
  let originalContent = restoration.parsed.join("");
348
238
 
349
- // Transform component-scoped bindings back to this. format
350
- // Compiler transforms @[this.count] → @[_c0.count] for stamping
351
- // Runtime expects @[this.count], so transform back
352
239
  originalContent = originalContent.replace(/@\[_c\d+\./g, "@[this.");
353
240
 
354
- // For text nodes, update parent's innerHTML
355
- // For elements with children, update only text nodes
356
241
  if (
357
242
  element.childNodes.length === 1 &&
358
243
  element.childNodes[0].nodeType === 3
359
244
  ) {
360
- // Single text node - replace it
361
245
  element.childNodes[0].textContent = originalContent;
362
246
  } else if (element.childNodes.length === 0) {
363
- // No children - set textContent
364
247
  element.textContent = originalContent;
365
248
  }
366
- // If element has multiple children, they'll be handled recursively
367
249
  }
368
250
  }
369
251
 
370
- // Restore attribute bindings for reactivity
371
- // Two cases:
372
- // 1. Boolean-like attributes with falsy values were removed - restore them
373
- // 2. Value attributes were stamped - replace stamped values with markers
374
252
  if (restoration.attributes) {
375
253
  for (let [attrName, attrValue] of Object.entries(
376
254
  restoration.attributes,
377
255
  )) {
378
- // Transform component-scoped bindings back to this. format
379
- // Compiler transforms @[this.count] → @[_c0.count], runtime expects @[this.count]
380
256
  attrValue = attrValue.replace(/@\[_c\d+\./g, "@[this.");
381
257
 
382
- // Always set the attribute to restore the marker
383
- // - If missing (boolean-like, falsy): adds it back
384
- // - If present (value attr, stamped): replaces stamped value with marker
385
258
  element.setAttribute(attrName, attrValue);
386
259
  }
387
260
  }
388
261
 
389
- // Restore name bindings (sparse array indexed by attribute position)
390
262
  if (restoration.nameBindings && Array.isArray(restoration.nameBindings)) {
391
263
  const attrs = Array.from(element.attributes);
392
264
 
393
265
  for (let i = 0; i < restoration.nameBindings.length; i++) {
394
266
  const marker = restoration.nameBindings[i];
395
267
  if (marker && attrs[i]) {
396
- // Replace the stamped attribute name with the marker
397
268
  const stampedName = attrs[i].name;
398
269
  const attrValue = attrs[i].value;
399
270
 
@@ -403,17 +274,11 @@ export const restoreMarkersFromManifest = (
403
274
  }
404
275
  }
405
276
 
406
- // After restoration, delete compiled.restoration (runtime will parse DOM fresh)
407
277
  if (tree.compiled) {
408
278
  delete tree.compiled.restoration;
409
279
  }
410
280
  }
411
281
 
412
- // IMPORTANT: Restore text nodes BEFORE processing conditionals/iterations
413
- // Text nodes use childNodes indices which become invalid after DOM modifications
414
- // Ascending index order, same invariant the directive restoration below
415
- // relies on: each text node is returned to its pre-stamp state before a
416
- // later index is consulted, so every index stays valid as we go.
417
282
  if (tree.children) {
418
283
  const texts = Object.keys(tree.children)
419
284
  .filter((key) => key.startsWith("text_"))
@@ -431,23 +296,16 @@ export const restoreMarkersFromManifest = (
431
296
  );
432
297
 
433
298
  if (hasBindings) {
434
- // Transform component-scoped bindings back to this. format
435
299
  const originalContent = restoration.parsed
436
300
  .join("")
437
301
  .replace(/@\[_c\d+\./g, "@[this.");
438
302
 
439
303
  const node = element.childNodes[index];
440
- if (node && node.nodeType === Node.TEXT_NODE) {
304
+ if (index === 0 && Object.keys(tree.children).length === 1) {
305
+ element.textContent = originalContent;
306
+ } else if (node && node.nodeType === Node.TEXT_NODE) {
441
307
  node.textContent = originalContent;
442
308
  } else {
443
- // The stamp ELIDED this text node: every binding in it rendered
444
- // to an empty string, and an empty text node has no HTML
445
- // serialization, so the compiled page carries no node here at all
446
- // (`<out>@[removed.join(',')]</out>` with an empty array stamps to
447
- // `<out></out>`). Recreate it at its recorded index — otherwise
448
- // the marker never returns and the binding is dead for the page's
449
- // lifetime, and every later sibling index at this level is off by
450
- // one, so their restorations silently miss too.
451
309
  element.insertBefore(
452
310
  document.createTextNode(originalContent),
453
311
  node ?? null,
@@ -462,13 +320,6 @@ export const restoreMarkersFromManifest = (
462
320
  }
463
321
  }
464
322
 
465
- // Restore iterations/conditionals at THIS level by their childNodes index.
466
- // The manifest key encodes the start comment's pre-stamp position
467
- // (`conditional_13` = element.childNodes[13]); processing in ascending
468
- // order keeps every index valid, because each restoration returns its
469
- // region to the exact pre-stamp node count before the next index is
470
- // consulted. Locating by index — not by expression text — is what
471
- // disambiguates sibling directives that share the same expression.
472
323
  if (tree.children) {
473
324
  const directives = Object.keys(tree.children)
474
325
  .filter((key) => {
@@ -483,8 +334,6 @@ export const restoreMarkersFromManifest = (
483
334
  const childTree = tree.children[key];
484
335
  const restoration = childTree.compiled?.restoration;
485
336
 
486
- // Always delete the directive node from the tree — the runtime
487
- // re-creates it when it parses the restored DOM.
488
337
  delete tree.children[key];
489
338
 
490
339
  if (!restoration?.template) continue;
@@ -494,8 +343,6 @@ export const restoreMarkersFromManifest = (
494
343
  continue;
495
344
  }
496
345
 
497
- // Find the matching end marker at this sibling level, depth-counted
498
- // so nested same-kind directives inside the region don't end it early.
499
346
  const isIteration = childTree.type === "iteration";
500
347
  const openPrefix = isIteration ? "each " : "if ";
501
348
  const closeMarker = isIteration ? "/each" : "/if";
@@ -516,9 +363,6 @@ export const restoreMarkersFromManifest = (
516
363
  }
517
364
  if (!endComment) continue;
518
365
 
519
- // Drop the stamped content (including the <!-- else --> marker and
520
- // any stamped-row comments) and re-insert the pre-stamp template —
521
- // node-for-node identical to what the manifest was built against.
522
366
  let current = startComment.nextSibling;
523
367
  while (current && current !== endComment) {
524
368
  const next = current.nextSibling;
@@ -532,12 +376,10 @@ export const restoreMarkersFromManifest = (
532
376
  }
533
377
  }
534
378
 
535
- // Recursively restore children (skip conditional/iteration children as they were already handled)
536
379
  if (tree.children) {
537
380
  for (const key in tree.children) {
538
381
  const childTree = tree.children[key];
539
382
 
540
- // Skip conditionals and iterations (already processed/deleted above)
541
383
  if (
542
384
  childTree.type === "conditional" ||
543
385
  childTree.type === "iteration"
@@ -545,14 +387,10 @@ export const restoreMarkersFromManifest = (
545
387
  continue;
546
388
  }
547
389
 
548
- // Skip text nodes - already handled before conditional/iteration processing
549
390
  if (key.startsWith("text_")) {
550
391
  continue;
551
392
  }
552
393
 
553
- // Find corresponding child element by tag name and index
554
- // Keys like "layout_1" mean the node at childNodes index 1 (includes text nodes)
555
- // Tag names can contain digits (h1, h2, etc.) so use [a-z0-9-]+
556
394
  const match = key.match(/^([a-z0-9-]+)_(\d+)$/);
557
395
  if (match) {
558
396
  const tagName = match[1].toUpperCase();
@@ -575,7 +413,6 @@ export const restoreMarkersFromManifest = (
575
413
  walkTree(subtree, rootElement);
576
414
  };
577
415
 
578
- // Top-level await to detect before module exports
579
416
  hyperspeedData = await detectHyperspeed();
580
417
 
581
418
  export const hyperspeedManifest = hyperspeedData?.manifest || null;
@@ -1,8 +1,3 @@
1
- // Marker for trusted raw-HTML output. `$.unsafe(str)` wraps a string so the
2
- // hydrate path knows to set innerHTML instead of escaping via textContent.
3
- // toString() returns the raw string so any non-pure use (the marker spliced
4
- // into surrounding text) degrades to escaped literal text automatically — the
5
- // browser escapes it when it lands in a text node.
6
1
  export class RawHtml {
7
2
  constructor(html) {
8
3
  this.html = html == null ? '' : String(html);