mnfst-render 0.5.36 → 0.5.38

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 +127 -20
  2. package/package.json +1 -1
@@ -2,7 +2,7 @@
2
2
 
3
3
  /* Manifest Render */
4
4
 
5
- import { readFileSync, readSync, mkdirSync, writeFileSync, existsSync, rmSync, statSync, readdirSync, cpSync, unlinkSync, renameSync } from 'node:fs';
5
+ import { readFileSync, readSync, mkdirSync, writeFileSync, appendFileSync, existsSync, rmSync, statSync, readdirSync, cpSync, unlinkSync, renameSync } from 'node:fs';
6
6
  import { spawnSync } from 'node:child_process';
7
7
  import { join, resolve, dirname, relative, basename, sep } from 'node:path';
8
8
  import { createServer } from 'node:http';
@@ -282,6 +282,10 @@ function resolveConfig() {
282
282
  : [],
283
283
  dryRun: !!cli.dryRun,
284
284
  debugPrerender: !!cli.debugPrerender,
285
+ // render.icons: "lazy" strips prerender-inlined x-icon SVGs from the
286
+ // output — the icons plugin re-renders them at boot (decorative content;
287
+ // an icon-heavy nav duplicates kilobytes of identical SVG per link).
288
+ lazyIcons: pre.icons === 'lazy',
285
289
  // Cap on the manifest:render-ready wait. When the data plugin dispatches
286
290
  // the event, we resolve immediately; when it doesn't (most projects), we
287
291
  // fall back to the timeout. 10s gives slow data plugin pipelines a
@@ -1127,6 +1131,18 @@ function stripRedundantImgSrcBindings(html) {
1127
1131
  */
1128
1132
  // Prerender inlined Iconify SVG under <i x-icon="iterator.icon">; clear x-icon value so Alpine does not evaluate
1129
1133
  // loop/item expressions while the attribute remains for CSS (e.g. inline layout that keys off [x-icon]).
1134
+ // render.icons: "lazy" — drop prerender-inlined icon SVGs from the output.
1135
+ // Each svg carries its resolved name (data-icon), which is written back into
1136
+ // the host's x-icon attribute as a static, scope-free value — so the icons
1137
+ // plugin re-renders it at boot even on baked clones whose expression attrs
1138
+ // were emptied. Only svg-as-first-child hosts match; anything else stays inline.
1139
+ function stripLazyIconSvgs(html) {
1140
+ return html.replace(
1141
+ /(x-icon\s*=\s*")[^"]*("[^>]*>\s*)<svg\b[^>]*\bdata-icon="([^"]+)"[^>]*>[\s\S]*?<\/svg>/gi,
1142
+ (m, pre, post, name) => `${pre}${name}${post}`
1143
+ );
1144
+ }
1145
+
1130
1146
  function stripResolvedXIconDirectives(html) {
1131
1147
  return html.replace(/<i\b([^>]*)>([\s\S]*?)<\/i>/gi, (full, attrs, inner) => {
1132
1148
  if (isHydrateMarkedAttrs(attrs)) return full;
@@ -1593,6 +1609,7 @@ function generateLocaleVariantHtml({
1593
1609
  html = stripRedundantImgSrcBindings(html);
1594
1610
  html = stripEmptyInlineMaskStyles(html);
1595
1611
  html = stripResolvedXIconDirectives(html);
1612
+ if (config.lazyIcons) html = stripLazyIconSvgs(html);
1596
1613
  // markPrerenderedManifestComponents must run BEFORE stripPrerenderHydrateMarkers so it can
1597
1614
  // detect data-hydrate markers and skip components inside hydrate islands.
1598
1615
  html = markPrerenderedManifestComponents(html);
@@ -1814,20 +1831,9 @@ function computeGlobalAssetSignature(rootDir) {
1814
1831
  async function waitForVisualSettle(page, { settleMs = 300, mdWaitMs = 5000, capMs = 8000 } = {}) {
1815
1832
  try {
1816
1833
  await page.evaluate(async ({ settleMs, mdWaitMs, capMs }) => {
1817
- // Preferred: the framework's own settled signal (loader ≥0.5.190).
1818
- if (window.__manifestReadyCoordinator) {
1819
- const start = Date.now();
1820
- while (!window.__manifestReady && Date.now() - start < capMs + 8000) {
1821
- await new Promise((r) => setTimeout(r, 100));
1822
- }
1823
- if (document.fonts?.ready) { try { await document.fonts.ready; } catch { /* best-effort */ } }
1824
- await new Promise((r) => {
1825
- const t = setTimeout(r, 250);
1826
- requestAnimationFrame(() => requestAnimationFrame(() => { clearTimeout(t); r(); }));
1827
- });
1828
- return;
1829
- }
1830
- // Fallback heuristic for older framework builds:
1834
+ // The framework's boot signal (loader ≥0.5.190) gates the start, but it
1835
+ // fires once per page — per-ROUTE work is tracked by the live markdown
1836
+ // pending counter and the DOM heuristic below, so all three AND together.
1831
1837
  const state = () => {
1832
1838
  let styleLen = 0;
1833
1839
  for (const s of document.querySelectorAll('style')) styleLen += (s.textContent || '').length;
@@ -1838,14 +1844,59 @@ async function waitForVisualSettle(page, { settleMs = 300, mdWaitMs = 5000, capM
1838
1844
  const sig = `${mdEmpty}:${document.body?.innerText.length || 0}:${styleLen}:${document.fonts ? document.fonts.status : ''}`;
1839
1845
  return { sig, mdEmpty };
1840
1846
  };
1847
+ // Alpine 3.x can strand already-flushed effects when a sibling effect
1848
+ // creates state mid-flush — a route's x-if chain then never stamps until
1849
+ // something re-queues it. Bumping the data store's version re-runs every
1850
+ // store-reading effect: a no-op on settled pages, an unstick on stuck ones.
1851
+ const nudge = () => {
1852
+ try {
1853
+ if (window.Alpine?.store) {
1854
+ const s = window.Alpine.store('data');
1855
+ if (s) window.Alpine.store('data', { ...s, _dataVersion: (s._dataVersion || 0) + 1 });
1856
+ }
1857
+ } catch { /* best-effort */ }
1858
+ };
1859
+ // Responsive/variant utilities (md:row, dark:bg-*) are compiled at
1860
+ // runtime and the compiler throttles recompiles — a late-discovered
1861
+ // variant class leaves the layout in its mobile/stacked state at capture.
1862
+ // Hold while any variant class used in the DOM has no matching CSS rule.
1863
+ const unresolvedVariants = () => {
1864
+ try {
1865
+ const used = new Set();
1866
+ for (const el of document.querySelectorAll('[class*=":"]')) {
1867
+ for (const c of el.classList) if (c.includes(':') && !c.startsWith('[')) used.add(c);
1868
+ if (used.size > 300) break;
1869
+ }
1870
+ if (!used.size) return 0;
1871
+ let selectors = '';
1872
+ for (const sheet of document.styleSheets) {
1873
+ try { for (const r of sheet.cssRules) if (r.selectorText) selectors += r.selectorText + '\n'; } catch { /* cross-origin */ }
1874
+ }
1875
+ let missing = 0;
1876
+ for (const c of used) {
1877
+ if (!selectors.includes('.' + CSS.escape(c))) missing++;
1878
+ }
1879
+ return missing;
1880
+ } catch { return 0; }
1881
+ };
1841
1882
  const start = Date.now();
1842
1883
  let last = state();
1843
1884
  let stableSince = Date.now();
1885
+ let nudged = 0;
1844
1886
  while (Date.now() - start < capMs) {
1845
1887
  await new Promise((r) => setTimeout(r, 100));
1888
+ const elapsed = Date.now() - start;
1889
+ if ((nudged === 0 && elapsed > 300) || (nudged === 1 && elapsed > 2000)) { nudged++; nudge(); }
1846
1890
  const cur = state();
1891
+ const bootPending = !!window.__manifestReadyCoordinator && !window.__manifestReady;
1892
+ const inflight = (window.__manifestMarkdownPending || 0) > 0;
1847
1893
  const mdPending = cur.mdEmpty > 0 && Date.now() - start < mdWaitMs;
1848
- if (cur.sig !== last.sig || mdPending) {
1894
+ const utilPending = (window.__manifestUtilitiesPending || 0) > 0
1895
+ || (elapsed < mdWaitMs + 4000 && unresolvedVariants() > 0);
1896
+ // A throttled compiler can DROP the request that would resolve them —
1897
+ // a class mutation re-triggers its observer's schedule.
1898
+ if (utilPending && elapsed % 500 < 100) document.body.classList.toggle('mnfst-og-nudge');
1899
+ if (cur.sig !== last.sig || bootPending || inflight || mdPending || utilPending) {
1849
1900
  last = cur;
1850
1901
  stableSince = Date.now();
1851
1902
  } else if (Date.now() - stableSince >= settleMs) {
@@ -1953,13 +2004,31 @@ async function takeOgSnapshot(page, outputDir, pathSeg, globalAssetSignature, ca
1953
2004
  // body min-h-screen + flex grow) can otherwise screenshot as blank if
1954
2005
  // the compositor doesn't repaint between setViewport and screenshot.
1955
2006
  await page.evaluate(() => window.scrollTo(0, 0));
1956
- await page.screenshot({
2007
+ // Throwaway shot first: it forces the compositor to produce a fresh frame,
2008
+ // so the real capture below reflects the current DOM rather than a stale
2009
+ // surface (headless pages can sit frames behind after heavy updates).
2010
+ await page.screenshot({ type: 'png', clip: { x: 0, y: 0, width: 8, height: 8 }, captureBeyondViewport: false }).catch(() => { });
2011
+ await new Promise((r) => setTimeout(r, 120));
2012
+ const shoot = () => page.screenshot({
1957
2013
  path: filePath,
1958
2014
  type: 'png',
1959
2015
  clip: { x: 0, y: 0, width: 1200, height: 630 },
1960
2016
  omitBackground: false,
1961
2017
  captureBeyondViewport: false,
1962
2018
  });
2019
+ await shoot();
2020
+ // Residual-race retry: a text-heavy page yielding a shell-weight card
2021
+ // means the main content missed the frame — settle again and reshoot.
2022
+ for (let attempt = 0; attempt < 2; attempt++) {
2023
+ let size = 0;
2024
+ try { size = statSync(filePath).size; } catch { break; }
2025
+ if (size >= 34 * 1024) break;
2026
+ const textLen = await page.evaluate(() => document.body?.innerText.length || 0).catch(() => 0);
2027
+ if (textLen < 2500) break;
2028
+ await new Promise((r) => setTimeout(r, 1200));
2029
+ await waitForVisualSettle(page, { settleMs: 400, capMs: 5000 });
2030
+ await shoot();
2031
+ }
1963
2032
  // Sanity check: a blank 1200×630 PNG (header only, white body) is ~8–10KB;
1964
2033
  // a content-rich page is 50KB+. When the resulting file is suspiciously
1965
2034
  // small the snapshot is treated as failed and the renderer falls through
@@ -3322,6 +3391,24 @@ async function runPrerender(config) {
3322
3391
  const apply = () => {
3323
3392
  try {
3324
3393
  if (locale && typeof locale === 'string') document.documentElement.lang = locale;
3394
+ // View transitions freeze a snapshot layer over the updated DOM in
3395
+ // headless — OG screenshots then capture stale pre-route pixels.
3396
+ // Router opt-out for SPA swaps, plus a late <style> so the reset's
3397
+ // cross-document @view-transition{navigation:auto} is overridden
3398
+ // (appended after stylesheets; last descriptor wins).
3399
+ document.documentElement.setAttribute('data-no-view-transitions', '');
3400
+ const killCrossDocVT = () => {
3401
+ try {
3402
+ const s = document.createElement('style');
3403
+ s.textContent = '@view-transition { navigation: none }';
3404
+ (document.head || document.documentElement).appendChild(s);
3405
+ } catch { /* no-op */ }
3406
+ };
3407
+ if (document.readyState === 'loading') {
3408
+ document.addEventListener('DOMContentLoaded', killCrossDocVT, { once: true });
3409
+ } else {
3410
+ killCrossDocVT();
3411
+ }
3325
3412
  } catch {
3326
3413
  /* no-op */
3327
3414
  }
@@ -4391,6 +4478,16 @@ async function runPrerender(config) {
4391
4478
  });
4392
4479
  });
4393
4480
 
4481
+ // (render.icons: "lazy" is applied on the serialized HTML string — see
4482
+ // stripLazyIconSvgs. Doing it in the live DOM re-triggers the icons
4483
+ // plugin's observer, which re-injects every svg before serialization.)
4484
+
4485
+ // x-virtual recomputes spacer geometry at boot; measured heights vary
4486
+ // run to run — zero them so identical content serializes identically.
4487
+ await page.evaluate(() => {
4488
+ document.querySelectorAll('[data-virtual-spacer]').forEach((el) => { el.style.height = '0px'; });
4489
+ });
4490
+
4394
4491
  // Deterministic output: randomized CSS anchor-positioning names
4395
4492
  // (--dropdown-x7f2, --popover-…, --anchor-…) are regenerated per run and
4396
4493
  // made every render a phantom diff. Renumber them sequentially in DOM
@@ -4491,6 +4588,7 @@ async function runPrerender(config) {
4491
4588
  html = stripRedundantImgSrcBindings(html);
4492
4589
  html = stripEmptyInlineMaskStyles(html);
4493
4590
  html = stripResolvedXIconDirectives(html);
4591
+ if (config.lazyIcons) html = stripLazyIconSvgs(html);
4494
4592
  html = markPrerenderedManifestComponents(html);
4495
4593
 
4496
4594
  // Prefix internal <a> links with the locale for non-default locales so
@@ -4734,9 +4832,18 @@ async function runPrerender(config) {
4734
4832
  if (bundleUtilities) {
4735
4833
  const utilMerged = mergeUtilityCssBlocks(utilityBlocks);
4736
4834
  if (utilMerged.trim()) {
4737
- writeFileSync(join(outputResolved, 'prerender.utilities.css'), `${utilMerged}\n`, 'utf8');
4738
- process.stdout.write('prerender: wrote prerender.utilities.css (Manifest custom utilities)\n');
4739
- postProcessInjectStylesheetLink(outputResolved, 'prerender.utilities.css', routerBasePath || '');
4835
+ // Append to prerender.tailwind.css when it exists — one render-blocking
4836
+ // stylesheet instead of two, same cascade order (utilities last). The
4837
+ // separate file remains only for projects without a Tailwind build.
4838
+ const twPath = join(outputResolved, 'prerender.tailwind.css');
4839
+ if (existsSync(twPath)) {
4840
+ appendFileSync(twPath, `\n/* Manifest custom utilities */\n${utilMerged}\n`, 'utf8');
4841
+ process.stdout.write('prerender: merged custom utilities into prerender.tailwind.css\n');
4842
+ } else {
4843
+ writeFileSync(join(outputResolved, 'prerender.utilities.css'), `${utilMerged}\n`, 'utf8');
4844
+ process.stdout.write('prerender: wrote prerender.utilities.css (Manifest custom utilities)\n');
4845
+ postProcessInjectStylesheetLink(outputResolved, 'prerender.utilities.css', routerBasePath || '');
4846
+ }
4740
4847
  }
4741
4848
  }
4742
4849
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mnfst-render",
3
- "version": "0.5.36",
3
+ "version": "0.5.38",
4
4
  "description": "Render Manifest sites to static HTML for SEO",
5
5
  "type": "module",
6
6
  "bin": {