mnfst-render 0.5.4 → 0.5.6

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 +72 -17
  2. package/package.json +1 -1
@@ -1356,7 +1356,7 @@ function generateLocaleVariantHtml({
1356
1356
  html = stripEmptyInlineMaskStyles(html);
1357
1357
  html = stripResolvedXIconDirectives(html);
1358
1358
  // markPrerenderedManifestComponents must run BEFORE stripPrerenderHydrateMarkers so it can
1359
- // detect data-prerender-hydrate markers and skip components inside hydrate islands.
1359
+ // detect data-hydrate markers and skip components inside hydrate islands.
1360
1360
  html = markPrerenderedManifestComponents(html);
1361
1361
 
1362
1362
  const fileSegments = pathToFileSegments(pathSeg ? '/' + pathSeg : '/');
@@ -1787,6 +1787,11 @@ async function runPrerender(config) {
1787
1787
  const browserRecycleEvery = Math.max(0, pre.browserRecycleEvery ?? 40);
1788
1788
  let pagesSinceRecycle = 0;
1789
1789
  const recycleLock = { busy: false };
1790
+ // Workers block on this promise before touching `browser`. While a recycle
1791
+ // is in progress it's a pending promise; once the new browser is up it
1792
+ // resolves and workers can proceed. This prevents "browser not ready"
1793
+ // errors from racing retries during recycle.
1794
+ let browserReadyPromise = Promise.resolve();
1790
1795
  const pathTotal = pathList.length;
1791
1796
  const failedPaths = [];
1792
1797
  const debugRows = [];
@@ -1878,11 +1883,11 @@ async function runPrerender(config) {
1878
1883
  : defaultLocale || 'en'
1879
1884
  : defaultLocale || 'en';
1880
1885
 
1881
- // Refuse to newPage() against a closed browser (happens briefly during
1882
- // recycle); the worker loop will retry.
1883
- if (!browser || !browser.connected) {
1884
- throw new Error('browser not ready');
1885
- }
1886
+ // Wait for any in-progress browser recycle to complete before touching
1887
+ // `browser`. This transparently handles the window between the old
1888
+ // browser being closed and the new one being launched — workers block
1889
+ // here instead of throwing "browser not ready".
1890
+ await browserReadyPromise;
1886
1891
  const page = await browser.newPage();
1887
1892
  try {
1888
1893
  // Align <html lang> with the URL being prerendered before any app script runs.
@@ -2234,13 +2239,36 @@ async function runPrerender(config) {
2234
2239
  });
2235
2240
 
2236
2241
  // Strip x-markdown from elements that already have baked content.
2237
- // The markdown plugin hides elements with opacity:0 on init, then re-fetches and re-renders.
2238
- // For prerendered pages the content is already baked — removing x-markdown prevents the
2239
- // runtime plugin from re-processing (and temporarily hiding) the static content.
2242
+ // The markdown plugin hides elements with opacity:0 on init, then re-fetches
2243
+ // and re-renders. For prerendered pages the content is already baked —
2244
+ // removing x-markdown prevents the runtime plugin from re-processing (and
2245
+ // temporarily hiding) the static content.
2246
+ //
2247
+ // We ALSO clear any leftover `opacity: 0` inline style the plugin set
2248
+ // before/while rendering. On dynamic expressions that initially evaluate
2249
+ // empty (e.g. article content keyed off `$route` before `$x.articles` has
2250
+ // loaded), the plugin sets opacity to 0 and may never restore it to 1 if
2251
+ // the effect re-fires with an empty value. The end state in the
2252
+ // serialized HTML has rendered content but opacity:0 — invisible in
2253
+ // production. Since we're also removing x-markdown (so the runtime
2254
+ // plugin doesn't re-hide the element), leaving the inline style would
2255
+ // permanently hide authored content.
2240
2256
  await page.evaluate(() => {
2241
2257
  document.querySelectorAll('[x-markdown]').forEach((el) => {
2242
2258
  if (!el.textContent.trim() && !el.innerHTML.trim()) return;
2243
2259
  el.removeAttribute('x-markdown');
2260
+ // Clean up opacity-0 + transition inline styles the plugin left behind.
2261
+ const style = el.getAttribute('style') || '';
2262
+ if (style) {
2263
+ const cleaned = style
2264
+ .replace(/\bopacity\s*:\s*0(?:\.\d+)?\s*;?/gi, '')
2265
+ .replace(/\btransition\s*:\s*opacity[^;]*;?/gi, '')
2266
+ .replace(/;\s*;/g, ';')
2267
+ .replace(/^\s*;\s*|\s*;\s*$/g, '')
2268
+ .trim();
2269
+ if (cleaned) el.setAttribute('style', cleaned);
2270
+ else el.removeAttribute('style');
2271
+ }
2244
2272
  });
2245
2273
  });
2246
2274
 
@@ -2399,7 +2427,7 @@ async function runPrerender(config) {
2399
2427
  // $url, $auth, or iterates over getter names (filtered*, results, searchResults). See docs prerender + local.data.
2400
2428
  await page.evaluate(() => {
2401
2429
  document.querySelectorAll('template[x-for]').forEach((tpl) => {
2402
- if (tpl.hasAttribute('data-prerender-hydrate') || tpl.closest('[data-prerender-hydrate]')) {
2430
+ if (tpl.hasAttribute('data-hydrate') || tpl.closest('[data-hydrate]')) {
2403
2431
  tpl.removeAttribute('data-prerender-collapsed');
2404
2432
  tpl.removeAttribute('data-prerender-static-generated');
2405
2433
  return;
@@ -2480,7 +2508,7 @@ async function runPrerender(config) {
2480
2508
  await page.evaluate(() => {
2481
2509
  const loopVarRx = /^\s*(?:\(\s*([A-Za-z_$][\w$]*)(?:\s*,\s*([A-Za-z_$][\w$]*))?\s*\)|([A-Za-z_$][\w$]*))\s+in\s+/;
2482
2510
  document.querySelectorAll('template[x-for][data-prerender-static-generated="1"]').forEach((tpl) => {
2483
- if (tpl.hasAttribute('data-prerender-hydrate') || tpl.closest('[data-prerender-hydrate]')) return;
2511
+ if (tpl.hasAttribute('data-hydrate') || tpl.closest('[data-hydrate]')) return;
2484
2512
  const xFor = (tpl.getAttribute('x-for') || '').trim();
2485
2513
  const m = xFor.match(loopVarRx);
2486
2514
  const itemVar = m ? (m[1] || m[3] || '') : '';
@@ -2494,7 +2522,7 @@ async function runPrerender(config) {
2494
2522
  // Only process clones that contain data-hydrate descendants
2495
2523
  if (
2496
2524
  !n.hasAttribute('x-data') &&
2497
- (n.hasAttribute('data-prerender-hydrate') || n.querySelector('[data-prerender-hydrate]'))
2525
+ (n.hasAttribute('data-hydrate') || n.querySelector('[data-hydrate]'))
2498
2526
  ) {
2499
2527
  try {
2500
2528
  const A = window.Alpine;
@@ -2536,7 +2564,7 @@ async function runPrerender(config) {
2536
2564
  const nodes = [el, ...Array.from(el.querySelectorAll('*'))];
2537
2565
  for (const node of nodes) {
2538
2566
  // Skip elements inside data-hydrate islands — their bindings must remain live
2539
- if (node.hasAttribute('data-prerender-hydrate') || node.closest('[data-prerender-hydrate]')) continue;
2567
+ if (node.hasAttribute('data-hydrate') || node.closest('[data-hydrate]')) continue;
2540
2568
  const attrs = node.attributes ? Array.from(node.attributes) : [];
2541
2569
  for (const attr of attrs) {
2542
2570
  if (!bindingAttrRegex.test(attr.name)) continue;
@@ -2574,7 +2602,7 @@ async function runPrerender(config) {
2574
2602
  };
2575
2603
 
2576
2604
  document.querySelectorAll('template[x-for]').forEach((tpl) => {
2577
- if (tpl.hasAttribute('data-prerender-hydrate') || tpl.closest('[data-prerender-hydrate]')) return;
2605
+ if (tpl.hasAttribute('data-hydrate') || tpl.closest('[data-hydrate]')) return;
2578
2606
  const xFor = (tpl.getAttribute('x-for') || '').trim();
2579
2607
  const m = xFor.match(loopVarRegex);
2580
2608
  const itemVar = m ? (m[1] || m[3] || '') : '';
@@ -2603,7 +2631,7 @@ async function runPrerender(config) {
2603
2631
  const runBatch = typeof A?.mutateDom === 'function' ? (fn) => A.mutateDom(fn) : (fn) => fn();
2604
2632
  runBatch(() => {
2605
2633
  document.querySelectorAll('template[x-for][data-prerender-static-generated="1"]').forEach((tpl) => {
2606
- if (tpl.hasAttribute('data-prerender-hydrate') || tpl.closest('[data-prerender-hydrate]')) return;
2634
+ if (tpl.hasAttribute('data-hydrate') || tpl.closest('[data-hydrate]')) return;
2607
2635
  const parent = tpl.parentNode;
2608
2636
  if (!parent) {
2609
2637
  tpl.remove();
@@ -2816,14 +2844,23 @@ async function runPrerender(config) {
2816
2844
  if (pagesSinceRecycle < browserRecycleEvery) return;
2817
2845
  if (recycleLock.busy) return;
2818
2846
  recycleLock.busy = true;
2847
+ // Wait for all in-flight workers to finish their current page BEFORE
2848
+ // we gate `browserReadyPromise`, so workers already mid-processPath
2849
+ // don't deadlock awaiting a promise we haven't yet started.
2850
+ await waitUntilZero();
2851
+ // Now gate newPage() calls from any worker that enters processPath
2852
+ // after this point.
2853
+ let resolveReady;
2854
+ browserReadyPromise = new Promise((r) => { resolveReady = r; });
2819
2855
  try {
2820
- // Wait for all in-flight workers to finish their current page.
2821
- await waitUntilZero();
2822
2856
  process.stdout.write(`prerender: recycling browser (processed ${pagesSinceRecycle} pages)\n`);
2823
2857
  try { await browser.close(); } catch (_) {}
2824
2858
  browser = await launchBrowser();
2825
2859
  pagesSinceRecycle = 0;
2826
2860
  } finally {
2861
+ // Release the gate first so any waiting workers can proceed, then
2862
+ // clear the recycle lock so the outer while loop stops pausing.
2863
+ try { resolveReady(); } catch (_) {}
2827
2864
  recycleLock.busy = false;
2828
2865
  const r = recycleGate.resume;
2829
2866
  recycleGate.resume = null;
@@ -2835,12 +2872,19 @@ async function runPrerender(config) {
2835
2872
  while (true) {
2836
2873
  // Pause if a recycle is underway.
2837
2874
  if (recycleLock.busy) await waitForResume();
2875
+ // Also wait for any pending browser readiness (e.g. another worker
2876
+ // started a recycle while we were processing).
2877
+ await browserReadyPromise;
2838
2878
 
2839
2879
  const i = index++;
2840
2880
  if (i >= puppeteerPaths.length) return;
2841
2881
  const pathSeg = puppeteerPaths[i];
2842
2882
  let attempt = 0;
2843
2883
  while (true) {
2884
+ // Re-check recycle state at the start of every retry iteration.
2885
+ if (recycleLock.busy) await waitForResume();
2886
+ await browserReadyPromise;
2887
+
2844
2888
  const failureCountBefore = failedPaths.length;
2845
2889
  activeWorkers++;
2846
2890
  try {
@@ -2849,6 +2893,17 @@ async function runPrerender(config) {
2849
2893
  if (seg !== NOT_FOUND_PATH) baseHtmlCache.set(seg || '', html);
2850
2894
  },
2851
2895
  });
2896
+ } catch (err) {
2897
+ // Unexpected exception escaped processPath (e.g. browser died
2898
+ // mid-call). Record as a failure so the retry logic can handle
2899
+ // it gracefully instead of tearing down the whole worker.
2900
+ failedPaths.push({
2901
+ path: pathSeg === '' ? '/' : '/' + pathSeg,
2902
+ message: err && err.message ? err.message : String(err),
2903
+ });
2904
+ if (failedPaths.length <= 10) {
2905
+ process.stderr.write(`prerender: worker exception on ${pathSeg || '/'}: ${failedPaths[failedPaths.length - 1].message}\n`);
2906
+ }
2852
2907
  } finally {
2853
2908
  activeWorkers--;
2854
2909
  if (activeWorkers === 0 && recycleGate.waitForZero) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mnfst-render",
3
- "version": "0.5.4",
3
+ "version": "0.5.6",
4
4
  "description": "Render Manifest sites to static HTML for SEO",
5
5
  "type": "module",
6
6
  "bin": {