mnfst-render 0.2.9 → 0.3.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 +130 -61
  2. package/package.json +1 -1
@@ -76,6 +76,25 @@ async function flushAlpineEffects(page) {
76
76
  .catch(() => {});
77
77
  }
78
78
 
79
+ /**
80
+ * Same logical path → normalizedPath as waitForManifestPrerenderPipeline and
81
+ * manifest.router.visibility initialize (matchesCondition first argument).
82
+ */
83
+ function logicalPathToVisibilityNormalizedPath(pathSeg, locales) {
84
+ const pathname = pathSeg ? `/${pathSeg}` : '/';
85
+ const clean = String(pathname || '/').replace(/^\/+|\/+$/g, '');
86
+ const parts = clean ? clean.split('/') : [];
87
+ const localeList = Array.isArray(locales) ? locales : [];
88
+ const logical =
89
+ parts.length > 0 && localeList.includes(parts[0])
90
+ ? `/${parts.slice(1).join('/')}`
91
+ : clean
92
+ ? `/${clean}`
93
+ : '/';
94
+ const to = logical === '//' ? '/' : logical;
95
+ return typeof to === 'string' && to !== '/' ? to.replace(/^\/|\/$/g, '') : '/';
96
+ }
97
+
79
98
  /**
80
99
  * After locale + route sync: run component swapping explicitly, then wait until Alpine data stores
81
100
  * are settled. We call ManifestComponentsSwapping.processAll() directly because swapping only
@@ -1645,6 +1664,8 @@ async function runPrerender(config) {
1645
1664
  return {
1646
1665
  templateCount: templates.length,
1647
1666
  nonCollapsedTemplateCount: templates.filter((t) => t.getAttribute('data-prerender-collapsed') !== '1').length,
1667
+ hint:
1668
+ 'entries.staticGenerated is read before the x-for mark pass and is always false; use stage post-xfor-mark for data-prerender-static-generated.',
1648
1669
  entries,
1649
1670
  listDiagnostics,
1650
1671
  };
@@ -1739,37 +1760,69 @@ async function runPrerender(config) {
1739
1760
  });
1740
1761
  });
1741
1762
 
1742
- // Remove static x-for templates once static clones are generated.
1743
- // This prevents Alpine from rendering duplicate lists at runtime.
1744
- await page.evaluate(() => {
1745
- document.querySelectorAll('template[x-for][data-prerender-static-generated="1"]').forEach((tpl) => {
1746
- tpl.remove();
1747
- });
1748
- });
1763
+ if (config.debugPrerender) {
1764
+ const afterMark = await page.evaluate(() => {
1765
+ const rows = [];
1766
+ for (const tpl of document.querySelectorAll('template[x-for]')) {
1767
+ rows.push({
1768
+ xFor: (tpl.getAttribute('x-for') || '').slice(0, 140),
1769
+ collapsed: tpl.getAttribute('data-prerender-collapsed') === '1',
1770
+ staticGenerated: tpl.getAttribute('data-prerender-static-generated') === '1',
1771
+ });
1772
+ }
1773
+ return {
1774
+ templateCount: rows.length,
1775
+ staticMarkedCount: rows.filter((r) => r.staticGenerated).length,
1776
+ collapsedCount: rows.filter((r) => r.collapsed).length,
1777
+ entries: rows.slice(0, 60),
1778
+ };
1779
+ }).catch(() => null);
1780
+ pushDebug({ path: displayPath, stage: 'post-xfor-mark', metrics: afterMark });
1781
+ }
1749
1782
 
1750
- // Remove orphan x-for clones that still reference loop-scope vars (e.g. image/index)
1751
- // outside their template scope. These throw Alpine errors in live static hosting.
1783
+ // Strip loop-scope bindings from x-for clones while <template> nodes still exist.
1784
+ // (If we remove static templates first, querySelectorAll('template[x-for]') misses them and clones
1785
+ // keep x-text/x-bind referencing card/item — Alpine then mutates or errors on the static HTML.)
1752
1786
  await page.evaluate(() => {
1753
1787
  const loopVarRegex = /^\s*(?:\(\s*([A-Za-z_$][\w$]*)(?:\s*,\s*([A-Za-z_$][\w$]*))?\s*\)|([A-Za-z_$][\w$]*))\s+in\s+/;
1754
1788
  const bindingAttrRegex = /^(?:x-bind:|:|x-text|x-html|x-show|x-if|x-model|x-effect|x-on:|@)/;
1755
1789
  const hasVar = (expr, varName) => varName && new RegExp(`\\b${varName}\\b`).test(expr || '');
1756
- const elementReferencesLoopScope = (el, itemVar, indexVar) => {
1757
- if (!el) return false;
1790
+ const stripLoopBindings = (el, itemVar, indexVar) => {
1758
1791
  const nodes = [el, ...Array.from(el.querySelectorAll('*'))];
1759
1792
  for (const node of nodes) {
1760
1793
  const attrs = node.attributes ? Array.from(node.attributes) : [];
1761
1794
  for (const attr of attrs) {
1762
1795
  if (!bindingAttrRegex.test(attr.name)) continue;
1763
1796
  const expr = attr.value || '';
1764
- if (hasVar(expr, itemVar) || hasVar(expr, indexVar)) return true;
1797
+ if (hasVar(expr, itemVar) || hasVar(expr, indexVar)) {
1798
+ const name = attr.name;
1799
+ if (name === 'x-text' || name === 'x-html') {
1800
+ if ((node.textContent || '').trim() || (node.innerHTML || '').trim()) {
1801
+ node.removeAttribute(name);
1802
+ }
1803
+ continue;
1804
+ }
1805
+ if (name === 'x-show' || name === 'x-if') {
1806
+ node.removeAttribute(name);
1807
+ continue;
1808
+ }
1809
+ let boundAttr = '';
1810
+ if (name.startsWith(':')) boundAttr = name.slice(1);
1811
+ else if (name.startsWith('x-bind:')) boundAttr = name.slice('x-bind:'.length);
1812
+ if (boundAttr) {
1813
+ const concrete = node.getAttribute(boundAttr);
1814
+ if (concrete != null && String(concrete).trim() !== '') {
1815
+ node.removeAttribute(name);
1816
+ }
1817
+ continue;
1818
+ }
1819
+ node.removeAttribute(name);
1820
+ }
1765
1821
  }
1766
1822
  }
1767
- return false;
1768
1823
  };
1769
1824
 
1770
- // Only clean up templates we intentionally collapsed above.
1771
- // Running this on all x-for templates can remove valid prerendered list items.
1772
- document.querySelectorAll('template[x-for][data-prerender-collapsed="1"]').forEach((tpl) => {
1825
+ document.querySelectorAll('template[x-for]').forEach((tpl) => {
1773
1826
  const xFor = (tpl.getAttribute('x-for') || '').trim();
1774
1827
  const m = xFor.match(loopVarRegex);
1775
1828
  const itemVar = m ? (m[1] || m[3] || '') : '';
@@ -1782,68 +1835,69 @@ async function runPrerender(config) {
1782
1835
 
1783
1836
  let next = tpl.nextElementSibling;
1784
1837
  while (next) {
1785
- const sameTag = next.tagName === tag;
1786
- if (!sameTag) break;
1787
-
1788
- const referencesLoopScope = elementReferencesLoopScope(next, itemVar, indexVar);
1789
-
1790
- const toRemove = next;
1838
+ if (next.tagName !== tag) break;
1839
+ stripLoopBindings(next, itemVar, indexVar);
1791
1840
  next = next.nextElementSibling;
1792
- if (referencesLoopScope) toRemove.remove();
1793
- else break;
1794
1841
  }
1795
1842
  });
1796
1843
  });
1797
1844
 
1798
- // For static clones kept from x-for templates, remove loop-scope bindings (card/title/etc)
1799
- // so Alpine doesn't re-evaluate them outside template scope in production.
1845
+ // Remove static x-for templates once static clones are generated.
1846
+ // Alpine registers a cleanup on <template x-for> that removes every node in _x_lookup when the
1847
+ // template is detached — so tpl.remove() alone deletes all sibling clones (empty grids in output).
1848
+ // Replace each clone with a deep cloneNode first so teardown targets detached nodes; copies stay in DOM.
1849
+ await page.evaluate(() => {
1850
+ const A = window.Alpine;
1851
+ const runBatch = typeof A?.mutateDom === 'function' ? (fn) => A.mutateDom(fn) : (fn) => fn();
1852
+ runBatch(() => {
1853
+ document.querySelectorAll('template[x-for][data-prerender-static-generated="1"]').forEach((tpl) => {
1854
+ const parent = tpl.parentNode;
1855
+ if (!parent) {
1856
+ tpl.remove();
1857
+ return;
1858
+ }
1859
+ const first = tpl.content?.firstElementChild;
1860
+ if (!first) {
1861
+ tpl.remove();
1862
+ return;
1863
+ }
1864
+ const tag = first.tagName;
1865
+ const cls = first.getAttribute('class') || '';
1866
+ let n = tpl.nextElementSibling;
1867
+ while (n && n.tagName === tag) {
1868
+ if ((n.getAttribute('class') || '') !== cls) break;
1869
+ const next = n.nextElementSibling;
1870
+ n.replaceWith(n.cloneNode(true));
1871
+ n = next;
1872
+ }
1873
+ tpl.remove();
1874
+ });
1875
+ });
1876
+ });
1877
+
1878
+ // Remove orphan x-for clones that still reference loop-scope vars (e.g. image/index)
1879
+ // outside their template scope. These throw Alpine errors in live static hosting.
1800
1880
  await page.evaluate(() => {
1801
1881
  const loopVarRegex = /^\s*(?:\(\s*([A-Za-z_$][\w$]*)(?:\s*,\s*([A-Za-z_$][\w$]*))?\s*\)|([A-Za-z_$][\w$]*))\s+in\s+/;
1802
1882
  const bindingAttrRegex = /^(?:x-bind:|:|x-text|x-html|x-show|x-if|x-model|x-effect|x-on:|@)/;
1803
1883
  const hasVar = (expr, varName) => varName && new RegExp(`\\b${varName}\\b`).test(expr || '');
1804
- const stripLoopBindings = (el, itemVar, indexVar) => {
1884
+ const elementReferencesLoopScope = (el, itemVar, indexVar) => {
1885
+ if (!el) return false;
1805
1886
  const nodes = [el, ...Array.from(el.querySelectorAll('*'))];
1806
1887
  for (const node of nodes) {
1807
1888
  const attrs = node.attributes ? Array.from(node.attributes) : [];
1808
1889
  for (const attr of attrs) {
1809
1890
  if (!bindingAttrRegex.test(attr.name)) continue;
1810
1891
  const expr = attr.value || '';
1811
- if (hasVar(expr, itemVar) || hasVar(expr, indexVar)) {
1812
- const name = attr.name;
1813
- // Remove text/html bindings only when static content already exists.
1814
- if (name === 'x-text' || name === 'x-html') {
1815
- if ((node.textContent || '').trim() || (node.innerHTML || '').trim()) {
1816
- node.removeAttribute(name);
1817
- }
1818
- continue;
1819
- }
1820
-
1821
- // Remove x-show/x-if if they reference loop vars; cloned node is now static.
1822
- if (name === 'x-show' || name === 'x-if') {
1823
- node.removeAttribute(name);
1824
- continue;
1825
- }
1826
-
1827
- // For :attr / x-bind:attr, only remove binding if a concrete attr is present.
1828
- let boundAttr = '';
1829
- if (name.startsWith(':')) boundAttr = name.slice(1);
1830
- else if (name.startsWith('x-bind:')) boundAttr = name.slice('x-bind:'.length);
1831
- if (boundAttr) {
1832
- const concrete = node.getAttribute(boundAttr);
1833
- if (concrete != null && String(concrete).trim() !== '') {
1834
- node.removeAttribute(name);
1835
- }
1836
- continue;
1837
- }
1838
-
1839
- // Event/other loop-scoped bindings are unsafe on static clones.
1840
- node.removeAttribute(name);
1841
- }
1892
+ if (hasVar(expr, itemVar) || hasVar(expr, indexVar)) return true;
1842
1893
  }
1843
1894
  }
1895
+ return false;
1844
1896
  };
1845
1897
 
1846
- document.querySelectorAll('template[x-for]').forEach((tpl) => {
1898
+ // Only clean up templates we intentionally collapsed above.
1899
+ // Running this on all x-for templates can remove valid prerendered list items.
1900
+ document.querySelectorAll('template[x-for][data-prerender-collapsed="1"]').forEach((tpl) => {
1847
1901
  const xFor = (tpl.getAttribute('x-for') || '').trim();
1848
1902
  const m = xFor.match(loopVarRegex);
1849
1903
  const itemVar = m ? (m[1] || m[3] || '') : '';
@@ -1856,9 +1910,15 @@ async function runPrerender(config) {
1856
1910
 
1857
1911
  let next = tpl.nextElementSibling;
1858
1912
  while (next) {
1859
- if (next.tagName !== tag) break;
1860
- stripLoopBindings(next, itemVar, indexVar);
1913
+ const sameTag = next.tagName === tag;
1914
+ if (!sameTag) break;
1915
+
1916
+ const referencesLoopScope = elementReferencesLoopScope(next, itemVar, indexVar);
1917
+
1918
+ const toRemove = next;
1861
1919
  next = next.nextElementSibling;
1920
+ if (referencesLoopScope) toRemove.remove();
1921
+ else break;
1862
1922
  }
1863
1923
  });
1864
1924
  });
@@ -1872,6 +1932,15 @@ async function runPrerender(config) {
1872
1932
  toRemove.forEach((el) => { if (document.contains(el)) el.remove(); });
1873
1933
  });
1874
1934
 
1935
+ const visibilityNormalizedPath = logicalPathToVisibilityNormalizedPath(pathSeg, locales);
1936
+ await page.evaluate((np) => {
1937
+ try {
1938
+ window.ManifestRoutingVisibility?.processRouteVisibility?.(np);
1939
+ } catch {
1940
+ /* no-op */
1941
+ }
1942
+ }, visibilityNormalizedPath);
1943
+
1875
1944
  // Remove route-hidden content ([x-route] with inline style display:none) so each prerendered page contains only that route's HTML.
1876
1945
  await page.evaluate(() => {
1877
1946
  const reDisplayNone = /\bdisplay\s*:\s*none\b/i;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mnfst-render",
3
- "version": "0.2.9",
3
+ "version": "0.3.1",
4
4
  "description": "Render Manifest sites to static HTML for SEO",
5
5
  "type": "module",
6
6
  "bin": {