mnfst-render 0.4.9 → 0.5.1

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 -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,120 @@ 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. 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.
1952
+ const wrap = (alpine) => {
1953
+ if (!alpine || alpine.__manifestRenderWrapped) return;
1954
+ if (typeof alpine.initTree !== 'function') return;
1955
+ 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
+ };
1961
+ };
1962
+
1963
+ // Setter trap: as soon as Alpine is assigned to window, wrap it.
1964
+ let _Alpine;
1965
+ try {
1966
+ Object.defineProperty(window, 'Alpine', {
1967
+ configurable: true,
1968
+ enumerable: true,
1969
+ get() { return _Alpine; },
1970
+ set(v) { _Alpine = v; wrap(v); },
1971
+ });
1972
+ } catch (_) { /* defineProperty failed, fall back to event listeners */ }
1973
+
1974
+ // Belt-and-braces: also try to wrap on alpine:init events in case some
1975
+ // environment beats the setter. wrap() is idempotent.
1976
+ if (typeof document !== 'undefined') {
1977
+ document.addEventListener('alpine:init', () => wrap(window.Alpine));
1978
+ document.addEventListener('alpine:initialized', () => wrap(window.Alpine));
1979
+ }
1980
+ });
1981
+
1860
1982
  pushDebug({ path: displayPath, stage: 'start' });
1861
1983
  await page.goto(url, {
1862
1984
  waitUntil: 'domcontentloaded',
@@ -2077,11 +2199,39 @@ async function runPrerender(config) {
2077
2199
  });
2078
2200
  });
2079
2201
 
2080
- // Mark data-hydrate islands so static compile transforms skip them.
2202
+ // Strip x-markdown from elements that already have baked content.
2203
+ // The markdown plugin hides elements with opacity:0 on init, then re-fetches and re-renders.
2204
+ // For prerendered pages the content is already baked — removing x-markdown prevents the
2205
+ // runtime plugin from re-processing (and temporarily hiding) the static content.
2206
+ await page.evaluate(() => {
2207
+ document.querySelectorAll('[x-markdown]').forEach((el) => {
2208
+ if (!el.textContent.trim() && !el.innerHTML.trim()) return;
2209
+ el.removeAttribute('x-markdown');
2210
+ });
2211
+ });
2212
+
2213
+ // Restore hydrate-target elements to their pristine source attributes
2214
+ // (snapshotted via evaluateOnNewDocument before Alpine ran). This is true
2215
+ // hydration: every Alpine binding (`:class`, `:style`, `:value`, `x-text`,
2216
+ // `x-init`, custom plugin directives, …) is preserved exactly as authored,
2217
+ // and Alpine processes them at runtime in the prerendered MPA the same way
2218
+ // it would in the live SPA. After restoring source attributes we re-add the
2219
+ // `data-prerender-hydrate` marker so downstream Node.js stripping passes
2220
+ // continue to skip these elements.
2081
2221
  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'));
2222
+ const snapshots = window.__manifestHydrateSnapshots || [];
2223
+ snapshots.forEach(({ id, attrs }) => {
2224
+ 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');
2085
2235
  });
2086
2236
  });
2087
2237
 
@@ -2428,6 +2578,7 @@ async function runPrerender(config) {
2428
2578
  // detect data-prerender-hydrate markers and skip components inside hydrate islands.
2429
2579
  html = markPrerenderedManifestComponents(html);
2430
2580
  html = stripPrerenderHydrateMarkers(html);
2581
+ html = stripPrerenderHydrateSnapshotIds(html);
2431
2582
  html = rewriteHtmlAssetPaths(html, fileSegments.length);
2432
2583
  const liveBase = config.liveUrl.replace(/\/$/, '');
2433
2584
  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.1",
4
4
  "description": "Render Manifest sites to static HTML for SEO",
5
5
  "type": "module",
6
6
  "bin": {