mnfst-render 0.4.9 → 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 +139 -4
  2. package/package.json +1 -1
@@ -947,6 +947,13 @@ function stripPrerenderHydrateMarkers(html) {
947
947
  return html.replace(/\sdata-prerender-hydrate(?:=(?:"[^"]*"|'[^']*'|[^\s>]+))?/gi, '');
948
948
  }
949
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
+
950
957
  function markPrerenderedManifestComponents(html) {
951
958
  return html.replace(/<(x-[a-z][\w-]*)([^>]*)>/gi, (full, tag, attrs) => {
952
959
  const a = attrs || '';
@@ -1335,6 +1342,7 @@ function generateLocaleVariantHtml({
1335
1342
  // detect data-prerender-hydrate markers and skip components inside hydrate islands.
1336
1343
  html = markPrerenderedManifestComponents(html);
1337
1344
  html = stripPrerenderHydrateMarkers(html);
1345
+ html = stripPrerenderHydrateSnapshotIds(html);
1338
1346
 
1339
1347
  const fileSegments = pathToFileSegments(pathSeg ? '/' + pathSeg : '/');
1340
1348
  html = rewriteHtmlAssetPaths(html, fileSegments.length);
@@ -1857,6 +1865,104 @@ async function runPrerender(config) {
1857
1865
  }
1858
1866
  }, currentLocale);
1859
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
+
1860
1966
  pushDebug({ path: displayPath, stage: 'start' });
1861
1967
  await page.goto(url, {
1862
1968
  waitUntil: 'domcontentloaded',
@@ -2077,11 +2183,39 @@ async function runPrerender(config) {
2077
2183
  });
2078
2184
  });
2079
2185
 
2080
- // 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.
2081
2190
  await page.evaluate(() => {
2082
- document.querySelectorAll('[data-hydrate]').forEach((root) => {
2083
- root.setAttribute('data-prerender-hydrate', '1');
2084
- root.querySelectorAll('*').forEach((el) => el.setAttribute('data-prerender-hydrate', '1'));
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.
2205
+ await page.evaluate(() => {
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');
2085
2219
  });
2086
2220
  });
2087
2221
 
@@ -2428,6 +2562,7 @@ async function runPrerender(config) {
2428
2562
  // detect data-prerender-hydrate markers and skip components inside hydrate islands.
2429
2563
  html = markPrerenderedManifestComponents(html);
2430
2564
  html = stripPrerenderHydrateMarkers(html);
2565
+ html = stripPrerenderHydrateSnapshotIds(html);
2431
2566
  html = rewriteHtmlAssetPaths(html, fileSegments.length);
2432
2567
  const liveBase = config.liveUrl.replace(/\/$/, '');
2433
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.9",
3
+ "version": "0.5.0",
4
4
  "description": "Render Manifest sites to static HTML for SEO",
5
5
  "type": "module",
6
6
  "bin": {