mnfst-render 0.5.1 → 0.5.3

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.
Files changed (2) hide show
  1. package/manifest.render.mjs +155 -35
  2. package/package.json +1 -1
@@ -172,6 +172,7 @@ function parseArgs() {
172
172
  if (args[i] === '--wait' && args[i + 1]) { out.wait = parseInt(args[++i], 10); continue; }
173
173
  if (args[i] === '--wait-after-idle' && args[i + 1]) { out.waitAfterIdle = parseInt(args[++i], 10); continue; }
174
174
  if (args[i] === '--concurrency' && args[i + 1]) { out.concurrency = parseInt(args[++i], 10); continue; }
175
+ if (args[i] === '--retries' && args[i + 1]) { out.retries = parseInt(args[++i], 10); continue; }
175
176
  if (args[i] === '--dry-run') { out.dryRun = true; continue; }
176
177
  if (args[i] === '--debug-prerender') { out.debugPrerender = true; continue; }
177
178
  }
@@ -231,6 +232,7 @@ function resolveConfig() {
231
232
  wait: cli.wait ?? pre.wait ?? null,
232
233
  waitAfterIdle: 0,
233
234
  concurrency: Math.max(1, cli.concurrency ?? pre.concurrency ?? Math.max(4, cpus().length - 1)),
235
+ retries: Math.max(0, cli.retries ?? pre.retries ?? 2),
234
236
  localeSubstitution: true,
235
237
  localeSubstitutionExclude: [],
236
238
  /** Explicit locale-neutral paths to render in addition to those discovered automatically.
@@ -1751,8 +1753,9 @@ async function runPrerender(config) {
1751
1753
  browser = await puppeteer.default.launch({ headless: true });
1752
1754
  }
1753
1755
 
1754
- const timeout = config.wait ?? 15000;
1756
+ const timeout = config.wait ?? 30000;
1755
1757
  const concurrency = config.concurrency;
1758
+ const maxRetries = config.retries ?? 2;
1756
1759
  const pathTotal = pathList.length;
1757
1760
  const failedPaths = [];
1758
1761
  const debugRows = [];
@@ -1883,6 +1886,36 @@ async function runPrerender(config) {
1883
1886
  let nextId = 0;
1884
1887
  const skipTags = new Set(['MAIN', 'BODY', 'HTML']);
1885
1888
 
1889
+ // Own MutationObserver registered before any other script on the page.
1890
+ // This guarantees we process DOM additions before Alpine's observer does —
1891
+ // critically, before Alpine's observer calls initTree on newly expanded
1892
+ // Manifest components (preloaded or lazy) and bakes their `:class` state.
1893
+ const installHydrateObserver = () => {
1894
+ if (window.__manifestHydrateObserver || !document.body) return;
1895
+ const obs = new MutationObserver((mutations) => {
1896
+ for (const m of mutations) {
1897
+ if (m.type !== 'childList') continue;
1898
+ for (const node of m.addedNodes) {
1899
+ if (node.nodeType !== 1) continue;
1900
+ try { snapshotSubtree(node); } catch (_) {}
1901
+ }
1902
+ }
1903
+ });
1904
+ obs.observe(document.body, { childList: true, subtree: true });
1905
+ window.__manifestHydrateObserver = obs;
1906
+ };
1907
+ if (typeof document !== 'undefined') {
1908
+ if (document.body) {
1909
+ installHydrateObserver();
1910
+ } else {
1911
+ document.addEventListener('DOMContentLoaded', installHydrateObserver, { once: true });
1912
+ // Also try once readyState flips to interactive
1913
+ document.addEventListener('readystatechange', () => {
1914
+ if (document.readyState !== 'loading') installHydrateObserver();
1915
+ });
1916
+ }
1917
+ }
1918
+
1886
1919
  const snapshotElement = (el) => {
1887
1920
  if (!el || el.nodeType !== 1) return;
1888
1921
  if (el.hasAttribute('data-manifest-hyd-id')) return; // already snapshotted
@@ -1894,7 +1927,7 @@ async function runPrerender(config) {
1894
1927
  if (a.name === 'data-manifest-hyd-id') continue;
1895
1928
  attrs[a.name] = a.value;
1896
1929
  }
1897
- allSnapshots.push({ id, attrs });
1930
+ allSnapshots.push({ id, tag: el.tagName, attrs });
1898
1931
  };
1899
1932
 
1900
1933
  const snapshotElementAndDescendants = (el) => {
@@ -1943,24 +1976,38 @@ async function runPrerender(config) {
1943
1976
  window.__manifestHydrateSnapshots = allSnapshots;
1944
1977
  };
1945
1978
 
1946
- // Wrap Alpine.initTree. This MUST happen before any plugin calls initTree
1947
- // otherwise preloaded components (loaded eagerly by manifest.components before
1948
- // Alpine starts walking the tree) get processed by the original initTree and
1949
- // their hydrate targets are missed. We use a defineProperty setter on
1950
- // window.Alpine so the wrap happens the instant Alpine's CDN script does
1951
- // `window.Alpine = ...` earlier than any event we could listen for.
1979
+ // Wrap Alpine.start so the snapshot runs INSIDE the start call, before
1980
+ // Alpine has a chance to walk and mutate the tree. alpine:init as an
1981
+ // external hook proved unreliable in some configurations it fires after
1982
+ // Alpine has already processed some elements. Alpine.start is the single
1983
+ // synchronous entry point for the initial walk, so wrapping it guarantees
1984
+ // we capture source state before any directive has been applied.
1985
+ //
1986
+ // We also wrap Alpine.initTree for lazy-loaded components that appear in
1987
+ // the DOM after Alpine.start() has completed (fetched by the components
1988
+ // plugin in response to new <x-*> placeholders).
1989
+ //
1990
+ // Both wraps are installed via a defineProperty setter on window.Alpine
1991
+ // so they land the instant Alpine's CDN script does `window.Alpine = ...`.
1952
1992
  const wrap = (alpine) => {
1953
1993
  if (!alpine || alpine.__manifestRenderWrapped) return;
1954
- if (typeof alpine.initTree !== 'function') return;
1955
1994
  alpine.__manifestRenderWrapped = true;
1956
- const original = alpine.initTree.bind(alpine);
1957
- alpine.initTree = function (root) {
1958
- try { snapshotSubtree(root || document.body); } catch (_) { /* graceful */ }
1959
- return original.apply(this, arguments);
1960
- };
1995
+ if (typeof alpine.start === 'function') {
1996
+ const originalStart = alpine.start.bind(alpine);
1997
+ alpine.start = function () {
1998
+ try { snapshotSubtree(document.body); } catch (_) { /* graceful */ }
1999
+ return originalStart.apply(this, arguments);
2000
+ };
2001
+ }
2002
+ if (typeof alpine.initTree === 'function') {
2003
+ const originalInit = alpine.initTree.bind(alpine);
2004
+ alpine.initTree = function (root) {
2005
+ try { snapshotSubtree(root || document.body); } catch (_) { /* graceful */ }
2006
+ return originalInit.apply(this, arguments);
2007
+ };
2008
+ }
1961
2009
  };
1962
2010
 
1963
- // Setter trap: as soon as Alpine is assigned to window, wrap it.
1964
2011
  let _Alpine;
1965
2012
  try {
1966
2013
  Object.defineProperty(window, 'Alpine', {
@@ -1971,9 +2018,8 @@ async function runPrerender(config) {
1971
2018
  });
1972
2019
  } catch (_) { /* defineProperty failed, fall back to event listeners */ }
1973
2020
 
1974
- // Belt-and-braces: also try to wrap on alpine:init events in case some
1975
- // environment beats the setter. wrap() is idempotent.
1976
2021
  if (typeof document !== 'undefined') {
2022
+ // Event-based fallback in case the setter trap missed Alpine assignment.
1977
2023
  document.addEventListener('alpine:init', () => wrap(window.Alpine));
1978
2024
  document.addEventListener('alpine:initialized', () => wrap(window.Alpine));
1979
2025
  }
@@ -2218,22 +2264,81 @@ async function runPrerender(config) {
2218
2264
  // it would in the live SPA. After restoring source attributes we re-add the
2219
2265
  // `data-prerender-hydrate` marker so downstream Node.js stripping passes
2220
2266
  // continue to skip these elements.
2221
- await page.evaluate(() => {
2267
+ //
2268
+ // Implementation note: we use `outerHTML` to swap the element rather than
2269
+ // `setAttribute` per-attribute. Alpine's special attribute names (`@click`,
2270
+ // possibly others starting with `@`) are not valid DOM Names per the XML
2271
+ // production, so `setAttribute('@click', …)` throws InvalidCharacterError.
2272
+ // The HTML parser, on the other hand, is lenient and accepts these names.
2273
+ // Building an HTML string and assigning it via outerHTML round-trips through
2274
+ // the parser and produces an element with all source attributes intact.
2275
+ // Stop Alpine from observing further DOM mutations and flush any pending
2276
+ // effects. Then restore each hydrate target by replacing it with a fresh
2277
+ // element parsed from a source-attribute HTML string. Replacing the element
2278
+ // (rather than mutating attributes in place) detaches it from Alpine's
2279
+ // reactive bindings entirely — the new node has no `_x_*` state, no
2280
+ // effects, and no observers. Alpine's MutationObserver is stopped first
2281
+ // so it can't pick up the new node and re-process it.
2282
+ //
2283
+ // We process snapshots deepest-first so that when an ancestor is rebuilt,
2284
+ // its children have already been replaced with their pristine versions and
2285
+ // are captured (via innerHTML) into the new ancestor.
2286
+ const restoreReport = await page.evaluate(async () => {
2287
+ try { window.Alpine && window.Alpine.flushAndStopDeferringMutations && window.Alpine.flushAndStopDeferringMutations(); } catch (_) {}
2288
+ try { window.Alpine && window.Alpine.stopObservingMutations && window.Alpine.stopObservingMutations(); } catch (_) {}
2289
+ await Promise.resolve();
2290
+ await Promise.resolve();
2291
+
2222
2292
  const snapshots = window.__manifestHydrateSnapshots || [];
2293
+ const report = { total: snapshots.length, restored: 0, notFound: 0, errors: [] };
2294
+
2295
+ // Resolve every snapshot to its element, then sort by depth (deepest first).
2296
+ const items = [];
2223
2297
  snapshots.forEach(({ id, attrs }) => {
2224
2298
  const el = document.querySelector(`[data-manifest-hyd-id="${id}"]`);
2225
- if (!el) return;
2226
- // Wipe everything Alpine may have added or mutated.
2227
- Array.from(el.attributes).map((a) => a.name).forEach((name) => el.removeAttribute(name));
2228
- // Put the original source attributes back.
2229
- Object.entries(attrs).forEach(([name, value]) => {
2230
- el.setAttribute(name, value);
2231
- });
2232
- // Marker so downstream stripping (xfor processing, dynamic-binding strip,
2233
- // component pre-render marking, etc.) skips this element.
2234
- el.setAttribute('data-prerender-hydrate', '1');
2299
+ if (!el) { report.notFound++; return; }
2300
+ let depth = 0;
2301
+ for (let p = el.parentNode; p; p = p.parentNode) depth++;
2302
+ items.push({ id, el, attrs, depth });
2235
2303
  });
2304
+ items.sort((a, b) => b.depth - a.depth);
2305
+
2306
+ const voidEls = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
2307
+ const escAttr = (s) => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/"/g, '&quot;');
2308
+
2309
+ items.forEach(({ id, attrs }) => {
2310
+ // Re-resolve the element by id every iteration: ancestors that were
2311
+ // already rebuilt will have re-parsed their children, so previous
2312
+ // references are stale.
2313
+ const el = document.querySelector(`[data-manifest-hyd-id="${id}"]`);
2314
+ if (!el || !el.parentNode) { report.errors.push({ id, msg: 'lost reference' }); return; }
2315
+ const tag = el.tagName.toLowerCase();
2316
+ const attrString = Object.entries(attrs)
2317
+ .map(([name, value]) => `${name}="${escAttr(value)}"`)
2318
+ .join(' ');
2319
+ const innerHTML = voidEls.has(tag) ? '' : el.innerHTML;
2320
+ const newHTML = voidEls.has(tag)
2321
+ ? `<${tag} ${attrString} data-prerender-hydrate="1">`
2322
+ : `<${tag} ${attrString} data-prerender-hydrate="1">${innerHTML}</${tag}>`;
2323
+ // Parse via a temporary container so we can use replaceChild (more
2324
+ // reliable than outerHTML in nested-replace scenarios).
2325
+ const tmp = document.createElement(el.parentNode.tagName === 'TR' ? 'tr' : 'div');
2326
+ tmp.innerHTML = newHTML;
2327
+ const parsed = tmp.firstElementChild;
2328
+ if (!parsed) { report.errors.push({ id, msg: 'parse failed' }); return; }
2329
+ try {
2330
+ el.parentNode.replaceChild(parsed, el);
2331
+ report.restored++;
2332
+ } catch (e) {
2333
+ report.errors.push({ id, tag, msg: String(e && e.message || e) });
2334
+ }
2335
+ });
2336
+
2337
+ return report;
2236
2338
  });
2339
+ if (config.debugPrerender) {
2340
+ pushDebug({ path: displayPath, stage: 'hydrate-restore', metrics: restoreReport });
2341
+ }
2237
2342
 
2238
2343
  // x-for lists: keep static lists in the HTML for SEO; collapse only dynamic lists so Alpine re-renders.
2239
2344
  // Explicit: data-prerender="dynamic"|"skip". Inferred: x-for uses $search/$query,
@@ -2619,19 +2724,34 @@ async function runPrerender(config) {
2619
2724
  }
2620
2725
  }
2621
2726
 
2622
- // Phase 1: Puppeteer — render base paths, cache raw DOM for substitution
2727
+ // Phase 1: Puppeteer — render base paths, cache raw DOM for substitution.
2728
+ // Any failures (e.g. transient navigation timeouts) are retried up to
2729
+ // `maxRetries` times with a short backoff before being reported as fatal.
2623
2730
  try {
2624
2731
  let index = 0;
2625
2732
  async function worker() {
2626
2733
  while (true) {
2627
2734
  const i = index++;
2628
2735
  if (i >= puppeteerPaths.length) return;
2629
- await processPath(puppeteerPaths[i], i, {
2630
- onRawHtml: (seg, html) => {
2631
- // Cache raw DOM snapshot for locale variant generation (NOT_FOUND_PATH excluded)
2632
- if (seg !== NOT_FOUND_PATH) baseHtmlCache.set(seg || '', html);
2633
- },
2634
- });
2736
+ const pathSeg = puppeteerPaths[i];
2737
+ let attempt = 0;
2738
+ while (true) {
2739
+ const failureCountBefore = failedPaths.length;
2740
+ await processPath(pathSeg, i, {
2741
+ onRawHtml: (seg, html) => {
2742
+ // Cache raw DOM snapshot for locale variant generation (NOT_FOUND_PATH excluded)
2743
+ if (seg !== NOT_FOUND_PATH) baseHtmlCache.set(seg || '', html);
2744
+ },
2745
+ });
2746
+ if (failedPaths.length === failureCountBefore) break; // success
2747
+ if (attempt >= maxRetries) break; // out of retries — leave the failure recorded
2748
+ // Pop the failure record and retry after a short backoff.
2749
+ failedPaths.pop();
2750
+ attempt++;
2751
+ const displayPath = pathSeg === '' ? '/' : (pathSeg === NOT_FOUND_PATH ? '/__prerender_404__' : '/' + pathSeg);
2752
+ process.stderr.write(`prerender: retrying ${displayPath} (attempt ${attempt + 1}/${maxRetries + 1})\n`);
2753
+ await new Promise((r) => setTimeout(r, 500 * attempt));
2754
+ }
2635
2755
  }
2636
2756
  }
2637
2757
  await Promise.all(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mnfst-render",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "description": "Render Manifest sites to static HTML for SEO",
5
5
  "type": "module",
6
6
  "bin": {