mnfst-render 0.4.8 → 0.5.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.
Files changed (2) hide show
  1. package/manifest.render.mjs +150 -25
  2. package/package.json +1 -1
@@ -229,23 +229,18 @@ function resolveConfig() {
229
229
  locales: pre.locales,
230
230
  redirects: Array.isArray(pre.redirects) ? pre.redirects : [],
231
231
  wait: cli.wait ?? pre.wait ?? null,
232
- waitAfterIdle: Math.max(0, cli.waitAfterIdle ?? pre.waitAfterIdle ?? 0),
232
+ waitAfterIdle: 0,
233
233
  concurrency: Math.max(1, cli.concurrency ?? pre.concurrency ?? Math.max(4, cpus().length - 1)),
234
- /** Default: generate locale variant pages via Node.js text substitution rather than Puppeteer.
235
- * Set manifest.prerender.localeSubstitution=false to always use Puppeteer for every locale. */
236
- localeSubstitution: pre.localeSubstitution !== false,
237
- /** Locales to always render with Puppeteer even when localeSubstitution is enabled (e.g. RTL). */
238
- localeSubstitutionExclude: Array.isArray(pre.localeSubstitutionExclude)
239
- ? pre.localeSubstitutionExclude.map(String)
240
- : [],
234
+ localeSubstitution: true,
235
+ localeSubstitutionExclude: [],
241
236
  /** Explicit locale-neutral paths to render in addition to those discovered automatically.
242
237
  * Each entry is expanded to all locale variants (e.g. "legal/privacy" → "cs/legal/privacy", ...) */
243
238
  paths: Array.isArray(pre.paths)
244
239
  ? pre.paths.map((p) => String(p).replace(/^\/+|\/+$/g, '')).filter(Boolean)
245
240
  : [],
246
241
  dryRun: !!cli.dryRun,
247
- debugPrerender: !!(cli.debugPrerender ?? pre.debugPrerender),
248
- pipelineTimeout: Math.max(3000, Number(pre.pipelineTimeout) || 25000),
242
+ debugPrerender: !!cli.debugPrerender,
243
+ pipelineTimeout: 25000,
249
244
  };
250
245
  }
251
246
 
@@ -694,13 +689,11 @@ function promptContinueWithRuntimeTailwind(rootDir) {
694
689
 
695
690
  /**
696
691
  * Build a static Tailwind stylesheet via @tailwindcss/cli (v4+), scanning project sources.
697
- * Only runs when the project opts in (data-tailwind on manifest script) or manifest.prerender.tailwind === true.
692
+ * Only runs when the project uses data-tailwind on the manifest script tag (auto-detected).
693
+ * Set manifest.prerender.tailwindInput to a custom CSS entry file if needed.
698
694
  */
699
695
  function runTailwindCliForPrerender(rootDir, outputDir, pre) {
700
- const explicit = pre?.tailwind;
701
- if (explicit === false) return false;
702
- const usesTailwind = explicit === true || indexHtmlUsesTailwind(rootDir);
703
- if (!usesTailwind) return false;
696
+ if (!indexHtmlUsesTailwind(rootDir)) return false;
704
697
 
705
698
  const outCss = join(outputDir, 'prerender.tailwind.css');
706
699
  try {
@@ -708,7 +701,7 @@ function runTailwindCliForPrerender(rootDir, outputDir, pre) {
708
701
  } catch {
709
702
  const proceed = promptContinueWithRuntimeTailwind(rootDir);
710
703
  if (!proceed) {
711
- throw new Error('prerender aborted: install tailwindcss/@tailwindcss/cli or disable prerender.tailwind.');
704
+ throw new Error('prerender aborted: install tailwindcss/@tailwindcss/cli or remove data-tailwind from your manifest script tag.');
712
705
  }
713
706
  process.stdout.write('prerender: continuing with runtime data-tailwind behavior.\n');
714
707
  return false;
@@ -726,15 +719,12 @@ function runTailwindCliForPrerender(rootDir, outputDir, pre) {
726
719
  }
727
720
 
728
721
  const outputBasename = basename(outputDir);
729
- const defaultContent = [
722
+ const contentGlobs = [
730
723
  '**/*.html',
731
724
  '!**/node_modules/**',
732
725
  '!**/dist/**',
733
726
  `!**/${outputBasename}/**`,
734
727
  ];
735
- const contentGlobs = Array.isArray(pre?.tailwindContent) && pre.tailwindContent.length > 0
736
- ? pre.tailwindContent
737
- : defaultContent;
738
728
 
739
729
  const args = [
740
730
  '--yes',
@@ -762,7 +752,7 @@ function runTailwindCliForPrerender(rootDir, outputDir, pre) {
762
752
  }
763
753
  }
764
754
  if (r.status !== 0) {
765
- console.error('prerender: Tailwind CLI failed; install with `npm i -D tailwindcss @tailwindcss/cli` or fix tailwindInput/tailwindContent in manifest.prerender.');
755
+ console.error('prerender: Tailwind CLI failed; install with `npm i -D tailwindcss @tailwindcss/cli` or check tailwindInput in manifest.prerender.');
766
756
  if (r.stderr) console.error(r.stderr);
767
757
  if (r.stdout) console.error(r.stdout);
768
758
  return false;
@@ -957,6 +947,13 @@ function stripPrerenderHydrateMarkers(html) {
957
947
  return html.replace(/\sdata-prerender-hydrate(?:=(?:"[^"]*"|'[^']*'|[^\s>]+))?/gi, '');
958
948
  }
959
949
 
950
+ // Remove the snapshot id attribute used by the hydrate restore phase. These ids
951
+ // only exist to let the post-Alpine restore step in Puppeteer find each snapshotted
952
+ // element back; they have no purpose in the final output.
953
+ function stripPrerenderHydrateSnapshotIds(html) {
954
+ return html.replace(/\sdata-manifest-hyd-id(?:=(?:"[^"]*"|'[^']*'|[^\s>]+))?/gi, '');
955
+ }
956
+
960
957
  function markPrerenderedManifestComponents(html) {
961
958
  return html.replace(/<(x-[a-z][\w-]*)([^>]*)>/gi, (full, tag, attrs) => {
962
959
  const a = attrs || '';
@@ -1345,6 +1342,7 @@ function generateLocaleVariantHtml({
1345
1342
  // detect data-prerender-hydrate markers and skip components inside hydrate islands.
1346
1343
  html = markPrerenderedManifestComponents(html);
1347
1344
  html = stripPrerenderHydrateMarkers(html);
1345
+ html = stripPrerenderHydrateSnapshotIds(html);
1348
1346
 
1349
1347
  const fileSegments = pathToFileSegments(pathSeg ? '/' + pathSeg : '/');
1350
1348
  html = rewriteHtmlAssetPaths(html, fileSegments.length);
@@ -1867,6 +1865,104 @@ async function runPrerender(config) {
1867
1865
  }
1868
1866
  }, currentLocale);
1869
1867
 
1868
+ // Snapshot pristine source attributes of hydrate-target elements BEFORE Alpine
1869
+ // touches them. We do this by wrapping `Alpine.initTree` — Alpine calls this
1870
+ // for the initial tree walk AND every time the components plugin lazy-loads a
1871
+ // new <x-*> component. Right before Alpine processes a subtree, we walk it
1872
+ // and snapshot every hydrate target inside. This is the exact moment the
1873
+ // user's source HTML is sitting in the DOM with no Alpine mutations applied.
1874
+ //
1875
+ // The snapshots are restored in a later page.evaluate call after Alpine
1876
+ // settles. This is true hydration: Alpine never gets to bake state into
1877
+ // hydrate elements, so every directive (`:class`, `:style`, `x-text`, custom
1878
+ // plugin directives, etc.) works in the prerendered MPA exactly the way it
1879
+ // does in the live SPA — no per-binding strip logic, no cloak band-aids, no
1880
+ // edge cases to chase.
1881
+ await page.evaluateOnNewDocument(() => {
1882
+ const allSnapshots = [];
1883
+ let nextId = 0;
1884
+ const skipTags = new Set(['MAIN', 'BODY', 'HTML']);
1885
+
1886
+ const snapshotElement = (el) => {
1887
+ if (!el || el.nodeType !== 1) return;
1888
+ if (el.hasAttribute('data-manifest-hyd-id')) return; // already snapshotted
1889
+ const id = '__manifest-hyd-' + nextId++;
1890
+ el.setAttribute('data-manifest-hyd-id', id);
1891
+ const attrs = {};
1892
+ for (let i = 0; i < el.attributes.length; i++) {
1893
+ const a = el.attributes[i];
1894
+ if (a.name === 'data-manifest-hyd-id') continue;
1895
+ attrs[a.name] = a.value;
1896
+ }
1897
+ allSnapshots.push({ id, attrs });
1898
+ };
1899
+
1900
+ const snapshotElementAndDescendants = (el) => {
1901
+ snapshotElement(el);
1902
+ if (el && el.querySelectorAll) {
1903
+ el.querySelectorAll('*').forEach(snapshotElement);
1904
+ }
1905
+ };
1906
+
1907
+ const snapshotSubtree = (root) => {
1908
+ if (!root || root.nodeType !== 1) return;
1909
+
1910
+ // 1. Direct data-hydrate roots + descendants within this subtree.
1911
+ const hydrateRoots = [];
1912
+ if (root.matches && root.matches('[data-hydrate]')) hydrateRoots.push(root);
1913
+ if (root.querySelectorAll) {
1914
+ root.querySelectorAll('[data-hydrate]').forEach((el) => hydrateRoots.push(el));
1915
+ }
1916
+ hydrateRoots.forEach(snapshotElementAndDescendants);
1917
+
1918
+ // 2. x-theme elements (color mode plugin needs runtime click handler).
1919
+ if (root.matches && root.matches('[x-theme]')) snapshotElementAndDescendants(root);
1920
+ if (root.querySelectorAll) {
1921
+ root.querySelectorAll('[x-theme]').forEach(snapshotElementAndDescendants);
1922
+ }
1923
+
1924
+ // 3. Propagate from data-hydrate children to nearest LOCAL x-data ancestor
1925
+ // so the reactive controller, sibling event handlers (@click toggles
1926
+ // etc.) and all bindings inside the scope are preserved together.
1927
+ // Skip page-level scopes (main, body, [x-route]).
1928
+ hydrateRoots.forEach((el) => {
1929
+ let ancestor = el.parentElement;
1930
+ while (ancestor && ancestor !== document.body) {
1931
+ if (
1932
+ ancestor.hasAttribute('x-data') &&
1933
+ !skipTags.has(ancestor.tagName) &&
1934
+ !ancestor.hasAttribute('x-route')
1935
+ ) {
1936
+ snapshotElementAndDescendants(ancestor);
1937
+ break;
1938
+ }
1939
+ ancestor = ancestor.parentElement;
1940
+ }
1941
+ });
1942
+
1943
+ window.__manifestHydrateSnapshots = allSnapshots;
1944
+ };
1945
+
1946
+ // Wrap Alpine.initTree once Alpine is available. alpine:init fires before
1947
+ // Alpine walks the tree for the first time, giving us the perfect insertion
1948
+ // point. After wrapping, every initTree call (initial walk + every lazy
1949
+ // component load) snapshots its subtree before Alpine processes it.
1950
+ let installed = false;
1951
+ const installInterceptor = () => {
1952
+ if (installed || !window.Alpine || typeof window.Alpine.initTree !== 'function') return;
1953
+ installed = true;
1954
+ const original = window.Alpine.initTree.bind(window.Alpine);
1955
+ window.Alpine.initTree = function (root) {
1956
+ try { snapshotSubtree(root || document.body); } catch (_) { /* graceful */ }
1957
+ return original.apply(this, arguments);
1958
+ };
1959
+ };
1960
+ if (typeof document !== 'undefined') {
1961
+ document.addEventListener('alpine:init', installInterceptor);
1962
+ document.addEventListener('alpine:initialized', installInterceptor);
1963
+ }
1964
+ });
1965
+
1870
1966
  pushDebug({ path: displayPath, stage: 'start' });
1871
1967
  await page.goto(url, {
1872
1968
  waitUntil: 'domcontentloaded',
@@ -2087,11 +2183,39 @@ async function runPrerender(config) {
2087
2183
  });
2088
2184
  });
2089
2185
 
2090
- // Mark data-hydrate islands so static compile transforms skip them.
2186
+ // Strip x-markdown from elements that already have baked content.
2187
+ // The markdown plugin hides elements with opacity:0 on init, then re-fetches and re-renders.
2188
+ // For prerendered pages the content is already baked — removing x-markdown prevents the
2189
+ // runtime plugin from re-processing (and temporarily hiding) the static content.
2190
+ await page.evaluate(() => {
2191
+ document.querySelectorAll('[x-markdown]').forEach((el) => {
2192
+ if (!el.textContent.trim() && !el.innerHTML.trim()) return;
2193
+ el.removeAttribute('x-markdown');
2194
+ });
2195
+ });
2196
+
2197
+ // Restore hydrate-target elements to their pristine source attributes
2198
+ // (snapshotted via evaluateOnNewDocument before Alpine ran). This is true
2199
+ // hydration: every Alpine binding (`:class`, `:style`, `:value`, `x-text`,
2200
+ // `x-init`, custom plugin directives, …) is preserved exactly as authored,
2201
+ // and Alpine processes them at runtime in the prerendered MPA the same way
2202
+ // it would in the live SPA. After restoring source attributes we re-add the
2203
+ // `data-prerender-hydrate` marker so downstream Node.js stripping passes
2204
+ // continue to skip these elements.
2091
2205
  await page.evaluate(() => {
2092
- document.querySelectorAll('[data-hydrate]').forEach((root) => {
2093
- root.setAttribute('data-prerender-hydrate', '1');
2094
- root.querySelectorAll('*').forEach((el) => el.setAttribute('data-prerender-hydrate', '1'));
2206
+ const snapshots = window.__manifestHydrateSnapshots || [];
2207
+ snapshots.forEach(({ id, attrs }) => {
2208
+ const el = document.querySelector(`[data-manifest-hyd-id="${id}"]`);
2209
+ if (!el) return;
2210
+ // Wipe everything Alpine may have added or mutated.
2211
+ Array.from(el.attributes).map((a) => a.name).forEach((name) => el.removeAttribute(name));
2212
+ // Put the original source attributes back.
2213
+ Object.entries(attrs).forEach(([name, value]) => {
2214
+ el.setAttribute(name, value);
2215
+ });
2216
+ // Marker so downstream stripping (xfor processing, dynamic-binding strip,
2217
+ // component pre-render marking, etc.) skips this element.
2218
+ el.setAttribute('data-prerender-hydrate', '1');
2095
2219
  });
2096
2220
  });
2097
2221
 
@@ -2438,6 +2562,7 @@ async function runPrerender(config) {
2438
2562
  // detect data-prerender-hydrate markers and skip components inside hydrate islands.
2439
2563
  html = markPrerenderedManifestComponents(html);
2440
2564
  html = stripPrerenderHydrateMarkers(html);
2565
+ html = stripPrerenderHydrateSnapshotIds(html);
2441
2566
  html = rewriteHtmlAssetPaths(html, fileSegments.length);
2442
2567
  const liveBase = config.liveUrl.replace(/\/$/, '');
2443
2568
  const canonicalHreflang = buildCanonicalAndHreflang(is404 ? '' : pathSeg, locales, defaultLocale, liveBase);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mnfst-render",
3
- "version": "0.4.8",
3
+ "version": "0.5.0",
4
4
  "description": "Render Manifest sites to static HTML for SEO",
5
5
  "type": "module",
6
6
  "bin": {