mnfst-render 0.2.8 → 0.3.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 +170 -172
  2. package/package.json +1 -1
@@ -77,133 +77,128 @@ async function flushAlpineEffects(page) {
77
77
  }
78
78
 
79
79
  /**
80
- * After locale + route sync, wait until (1) Manifest fires manifest:components-processed for that pass
81
- * (component swapping finished) and (2) no Alpine data _*_state is still loading / _localeChanging.
82
- * Listener is registered before dispatch so the initial page load event is ignored.
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
+
98
+ /**
99
+ * After locale + route sync: run component swapping explicitly, then wait until Alpine data stores
100
+ * are settled. We call ManifestComponentsSwapping.processAll() directly because swapping only
101
+ * subscribes to manifest:route-change if window.ManifestRouting already exists at initialize() time;
102
+ * when the router loads after components, that listener is never registered and no second
103
+ * manifest:components-processed fires.
83
104
  */
84
105
  async function waitForManifestPrerenderPipeline(page, { allLocales, currentLocale, timeoutMs }) {
85
106
  const result = await page
86
107
  .evaluate(
87
- ({ localeList, loc, ms }) => {
88
- return new Promise((resolve) => {
89
- const deadline = Date.now() + ms;
90
- let dispatched = false;
91
- let sawComponents = false;
92
- let poll = 0;
108
+ async ({ localeList, loc, ms }) => {
109
+ const sleep = (n) => new Promise((r) => setTimeout(r, n));
110
+ const deadline = Date.now() + ms;
111
+
112
+ const checkData = () => {
113
+ try {
114
+ const Alpine = window.Alpine;
115
+ if (!Alpine?.store) return true;
116
+ const d = Alpine.store('data');
117
+ if (!d) return true;
118
+ if (d._localeChanging) return false;
119
+ for (const k of Object.keys(d)) {
120
+ if (!k.startsWith('_') || !k.endsWith('_state')) continue;
121
+ const s = d[k];
122
+ if (s && typeof s === 'object' && s.loading) return false;
123
+ }
124
+ return true;
125
+ } catch {
126
+ return true;
127
+ }
128
+ };
93
129
 
94
- const checkData = () => {
130
+ try {
131
+ const locales = Array.isArray(localeList) ? localeList : [];
132
+ if (loc && typeof loc === 'string') {
95
133
  try {
96
- const Alpine = window.Alpine;
97
- if (!Alpine?.store) return true;
98
- const d = Alpine.store('data');
99
- if (!d) return true;
100
- if (d._localeChanging) return false;
101
- for (const k of Object.keys(d)) {
102
- if (!k.startsWith('_') || !k.endsWith('_state')) continue;
103
- const s = d[k];
104
- if (s && typeof s === 'object' && s.loading) return false;
105
- }
106
- return true;
134
+ document.documentElement.lang = loc;
107
135
  } catch {
108
- return true;
136
+ /* no-op */
109
137
  }
110
- };
111
-
112
- const skipComponentsWait = () =>
113
- !window.__manifestComponentsInitialized || !window.ManifestComponentsSwapping;
114
-
115
- const cleanup = () => {
116
- if (poll) clearInterval(poll);
117
- window.removeEventListener('manifest:components-processed', onComponents);
118
- };
119
-
120
- const tryFinish = () => {
121
- if (!dispatched) return;
122
- if (skipComponentsWait()) {
123
- sawComponents = true;
138
+ }
139
+ const store = typeof Alpine !== 'undefined' && Alpine.store ? Alpine.store('locale') : null;
140
+ if (store) {
141
+ if (!Array.isArray(store.available) || store.available.length === 0) {
142
+ store.available = locales.slice();
143
+ } else {
144
+ store.available = Array.from(new Set([...store.available, ...locales]));
124
145
  }
125
- if (sawComponents && checkData()) {
126
- cleanup();
127
- resolve({ ok: true });
146
+ if (loc && typeof loc === 'string') {
147
+ store.current = loc;
128
148
  }
129
- };
149
+ }
130
150
 
131
- function onComponents() {
132
- if (!dispatched) return;
133
- sawComponents = true;
134
- tryFinish();
151
+ if (loc && typeof loc === 'string') {
152
+ window.dispatchEvent(new CustomEvent('localechange', { detail: { locale: loc } }));
135
153
  }
136
154
 
137
- window.addEventListener('manifest:components-processed', onComponents);
138
-
139
- poll = setInterval(() => {
140
- if (Date.now() > deadline) {
141
- cleanup();
142
- resolve({
143
- ok: false,
144
- reason: 'timeout',
145
- sawComponents,
146
- dataOk: checkData(),
147
- skippedComponents: skipComponentsWait(),
148
- });
149
- return;
150
- }
151
- tryFinish();
152
- }, 50);
155
+ const rawRoute = window.ManifestRoutingNavigation?.getCurrentRoute?.() ?? window.location.pathname;
156
+ const clean = String(rawRoute || '/').replace(/^\/+|\/+$/g, '');
157
+ const parts = clean ? clean.split('/') : [];
158
+ const logical =
159
+ parts.length > 0 && locales.includes(parts[0])
160
+ ? '/' + parts.slice(1).join('/')
161
+ : clean
162
+ ? '/' + clean
163
+ : '/';
164
+ const to = logical === '//' ? '/' : logical;
165
+ const normalizedPath =
166
+ typeof to === 'string' && to !== '/' ? to.replace(/^\/|\/$/g, '') : '/';
167
+
168
+ window.dispatchEvent(
169
+ new CustomEvent('manifest:route-change', {
170
+ detail: {
171
+ from: to,
172
+ to,
173
+ normalizedPath,
174
+ },
175
+ })
176
+ );
177
+ window.dispatchEvent(new PopStateEvent('popstate'));
153
178
 
154
- try {
155
- const locales = Array.isArray(localeList) ? localeList : [];
156
- if (loc && typeof loc === 'string') {
157
- try {
158
- document.documentElement.lang = loc;
159
- } catch {
160
- /* no-op */
161
- }
162
- }
163
- const store = typeof Alpine !== 'undefined' && Alpine.store ? Alpine.store('locale') : null;
164
- if (store) {
165
- if (!Array.isArray(store.available) || store.available.length === 0) {
166
- store.available = locales.slice();
167
- } else {
168
- store.available = Array.from(new Set([...store.available, ...locales]));
169
- }
170
- if (loc && typeof loc === 'string') {
171
- store.current = loc;
172
- }
179
+ if (window.ManifestComponentsSwapping?.processAll) {
180
+ try {
181
+ await window.ManifestComponentsSwapping.processAll(normalizedPath);
182
+ } catch (e) {
183
+ return { ok: false, reason: 'processAll-error', message: String(e?.message || e) };
173
184
  }
185
+ }
174
186
 
175
- if (loc && typeof loc === 'string') {
176
- window.dispatchEvent(new CustomEvent('localechange', { detail: { locale: loc } }));
187
+ while (Date.now() < deadline) {
188
+ if (checkData()) {
189
+ return { ok: true };
177
190
  }
178
-
179
- const rawRoute = window.ManifestRoutingNavigation?.getCurrentRoute?.() ?? window.location.pathname;
180
- const clean = String(rawRoute || '/').replace(/^\/+|\/+$/g, '');
181
- const parts = clean ? clean.split('/') : [];
182
- const logical =
183
- parts.length > 0 && locales.includes(parts[0])
184
- ? '/' + parts.slice(1).join('/')
185
- : clean
186
- ? '/' + clean
187
- : '/';
188
- const to = logical === '//' ? '/' : logical;
189
-
190
- dispatched = true;
191
- window.dispatchEvent(
192
- new CustomEvent('manifest:route-change', {
193
- detail: {
194
- from: to,
195
- to,
196
- normalizedPath: typeof to === 'string' && to !== '/' ? to.replace(/^\/|\/$/g, '') : '/',
197
- },
198
- })
199
- );
200
- window.dispatchEvent(new PopStateEvent('popstate'));
201
- tryFinish();
202
- } catch (err) {
203
- cleanup();
204
- resolve({ ok: false, reason: 'error', message: String(err?.message || err) });
191
+ await sleep(50);
205
192
  }
206
- });
193
+ return {
194
+ ok: false,
195
+ reason: 'timeout',
196
+ dataOk: checkData(),
197
+ hadSwapping: !!window.ManifestComponentsSwapping?.processAll,
198
+ };
199
+ } catch (err) {
200
+ return { ok: false, reason: 'error', message: String(err?.message || err) };
201
+ }
207
202
  },
208
203
  {
209
204
  localeList: allLocales,
@@ -216,7 +211,7 @@ async function waitForManifestPrerenderPipeline(page, { allLocales, currentLocal
216
211
  if (!result?.ok) {
217
212
  const parts = [`prerender: pipeline wait incomplete (${result?.reason ?? 'unknown'})`];
218
213
  if (result?.dataOk === false) parts.push('data still loading');
219
- if (result?.sawComponents === false && !result?.skippedComponents) parts.push('no manifest:components-processed');
214
+ if (result?.message) parts.push(result.message);
220
215
  process.stdout.write(`${parts.join('; ')}\n`);
221
216
  }
222
217
  }
@@ -1763,37 +1758,49 @@ async function runPrerender(config) {
1763
1758
  });
1764
1759
  });
1765
1760
 
1766
- // Remove static x-for templates once static clones are generated.
1767
- // This prevents Alpine from rendering duplicate lists at runtime.
1768
- await page.evaluate(() => {
1769
- document.querySelectorAll('template[x-for][data-prerender-static-generated="1"]').forEach((tpl) => {
1770
- tpl.remove();
1771
- });
1772
- });
1773
-
1774
- // Remove orphan x-for clones that still reference loop-scope vars (e.g. image/index)
1775
- // outside their template scope. These throw Alpine errors in live static hosting.
1761
+ // Strip loop-scope bindings from x-for clones while <template> nodes still exist.
1762
+ // (If we remove static templates first, querySelectorAll('template[x-for]') misses them and clones
1763
+ // keep x-text/x-bind referencing card/item — Alpine then mutates or errors on the static HTML.)
1776
1764
  await page.evaluate(() => {
1777
1765
  const loopVarRegex = /^\s*(?:\(\s*([A-Za-z_$][\w$]*)(?:\s*,\s*([A-Za-z_$][\w$]*))?\s*\)|([A-Za-z_$][\w$]*))\s+in\s+/;
1778
1766
  const bindingAttrRegex = /^(?:x-bind:|:|x-text|x-html|x-show|x-if|x-model|x-effect|x-on:|@)/;
1779
1767
  const hasVar = (expr, varName) => varName && new RegExp(`\\b${varName}\\b`).test(expr || '');
1780
- const elementReferencesLoopScope = (el, itemVar, indexVar) => {
1781
- if (!el) return false;
1768
+ const stripLoopBindings = (el, itemVar, indexVar) => {
1782
1769
  const nodes = [el, ...Array.from(el.querySelectorAll('*'))];
1783
1770
  for (const node of nodes) {
1784
1771
  const attrs = node.attributes ? Array.from(node.attributes) : [];
1785
1772
  for (const attr of attrs) {
1786
1773
  if (!bindingAttrRegex.test(attr.name)) continue;
1787
1774
  const expr = attr.value || '';
1788
- if (hasVar(expr, itemVar) || hasVar(expr, indexVar)) return true;
1775
+ if (hasVar(expr, itemVar) || hasVar(expr, indexVar)) {
1776
+ const name = attr.name;
1777
+ if (name === 'x-text' || name === 'x-html') {
1778
+ if ((node.textContent || '').trim() || (node.innerHTML || '').trim()) {
1779
+ node.removeAttribute(name);
1780
+ }
1781
+ continue;
1782
+ }
1783
+ if (name === 'x-show' || name === 'x-if') {
1784
+ node.removeAttribute(name);
1785
+ continue;
1786
+ }
1787
+ let boundAttr = '';
1788
+ if (name.startsWith(':')) boundAttr = name.slice(1);
1789
+ else if (name.startsWith('x-bind:')) boundAttr = name.slice('x-bind:'.length);
1790
+ if (boundAttr) {
1791
+ const concrete = node.getAttribute(boundAttr);
1792
+ if (concrete != null && String(concrete).trim() !== '') {
1793
+ node.removeAttribute(name);
1794
+ }
1795
+ continue;
1796
+ }
1797
+ node.removeAttribute(name);
1798
+ }
1789
1799
  }
1790
1800
  }
1791
- return false;
1792
1801
  };
1793
1802
 
1794
- // Only clean up templates we intentionally collapsed above.
1795
- // Running this on all x-for templates can remove valid prerendered list items.
1796
- document.querySelectorAll('template[x-for][data-prerender-collapsed="1"]').forEach((tpl) => {
1803
+ document.querySelectorAll('template[x-for]').forEach((tpl) => {
1797
1804
  const xFor = (tpl.getAttribute('x-for') || '').trim();
1798
1805
  const m = xFor.match(loopVarRegex);
1799
1806
  const itemVar = m ? (m[1] || m[3] || '') : '';
@@ -1806,68 +1813,44 @@ async function runPrerender(config) {
1806
1813
 
1807
1814
  let next = tpl.nextElementSibling;
1808
1815
  while (next) {
1809
- const sameTag = next.tagName === tag;
1810
- if (!sameTag) break;
1811
-
1812
- const referencesLoopScope = elementReferencesLoopScope(next, itemVar, indexVar);
1813
-
1814
- const toRemove = next;
1816
+ if (next.tagName !== tag) break;
1817
+ stripLoopBindings(next, itemVar, indexVar);
1815
1818
  next = next.nextElementSibling;
1816
- if (referencesLoopScope) toRemove.remove();
1817
- else break;
1818
1819
  }
1819
1820
  });
1820
1821
  });
1821
1822
 
1822
- // For static clones kept from x-for templates, remove loop-scope bindings (card/title/etc)
1823
- // so Alpine doesn't re-evaluate them outside template scope in production.
1823
+ // Remove static x-for templates once static clones are generated.
1824
+ // This prevents Alpine from rendering duplicate lists at runtime.
1825
+ await page.evaluate(() => {
1826
+ document.querySelectorAll('template[x-for][data-prerender-static-generated="1"]').forEach((tpl) => {
1827
+ tpl.remove();
1828
+ });
1829
+ });
1830
+
1831
+ // Remove orphan x-for clones that still reference loop-scope vars (e.g. image/index)
1832
+ // outside their template scope. These throw Alpine errors in live static hosting.
1824
1833
  await page.evaluate(() => {
1825
1834
  const loopVarRegex = /^\s*(?:\(\s*([A-Za-z_$][\w$]*)(?:\s*,\s*([A-Za-z_$][\w$]*))?\s*\)|([A-Za-z_$][\w$]*))\s+in\s+/;
1826
1835
  const bindingAttrRegex = /^(?:x-bind:|:|x-text|x-html|x-show|x-if|x-model|x-effect|x-on:|@)/;
1827
1836
  const hasVar = (expr, varName) => varName && new RegExp(`\\b${varName}\\b`).test(expr || '');
1828
- const stripLoopBindings = (el, itemVar, indexVar) => {
1837
+ const elementReferencesLoopScope = (el, itemVar, indexVar) => {
1838
+ if (!el) return false;
1829
1839
  const nodes = [el, ...Array.from(el.querySelectorAll('*'))];
1830
1840
  for (const node of nodes) {
1831
1841
  const attrs = node.attributes ? Array.from(node.attributes) : [];
1832
1842
  for (const attr of attrs) {
1833
1843
  if (!bindingAttrRegex.test(attr.name)) continue;
1834
1844
  const expr = attr.value || '';
1835
- if (hasVar(expr, itemVar) || hasVar(expr, indexVar)) {
1836
- const name = attr.name;
1837
- // Remove text/html bindings only when static content already exists.
1838
- if (name === 'x-text' || name === 'x-html') {
1839
- if ((node.textContent || '').trim() || (node.innerHTML || '').trim()) {
1840
- node.removeAttribute(name);
1841
- }
1842
- continue;
1843
- }
1844
-
1845
- // Remove x-show/x-if if they reference loop vars; cloned node is now static.
1846
- if (name === 'x-show' || name === 'x-if') {
1847
- node.removeAttribute(name);
1848
- continue;
1849
- }
1850
-
1851
- // For :attr / x-bind:attr, only remove binding if a concrete attr is present.
1852
- let boundAttr = '';
1853
- if (name.startsWith(':')) boundAttr = name.slice(1);
1854
- else if (name.startsWith('x-bind:')) boundAttr = name.slice('x-bind:'.length);
1855
- if (boundAttr) {
1856
- const concrete = node.getAttribute(boundAttr);
1857
- if (concrete != null && String(concrete).trim() !== '') {
1858
- node.removeAttribute(name);
1859
- }
1860
- continue;
1861
- }
1862
-
1863
- // Event/other loop-scoped bindings are unsafe on static clones.
1864
- node.removeAttribute(name);
1865
- }
1845
+ if (hasVar(expr, itemVar) || hasVar(expr, indexVar)) return true;
1866
1846
  }
1867
1847
  }
1848
+ return false;
1868
1849
  };
1869
1850
 
1870
- document.querySelectorAll('template[x-for]').forEach((tpl) => {
1851
+ // Only clean up templates we intentionally collapsed above.
1852
+ // Running this on all x-for templates can remove valid prerendered list items.
1853
+ document.querySelectorAll('template[x-for][data-prerender-collapsed="1"]').forEach((tpl) => {
1871
1854
  const xFor = (tpl.getAttribute('x-for') || '').trim();
1872
1855
  const m = xFor.match(loopVarRegex);
1873
1856
  const itemVar = m ? (m[1] || m[3] || '') : '';
@@ -1880,9 +1863,15 @@ async function runPrerender(config) {
1880
1863
 
1881
1864
  let next = tpl.nextElementSibling;
1882
1865
  while (next) {
1883
- if (next.tagName !== tag) break;
1884
- stripLoopBindings(next, itemVar, indexVar);
1866
+ const sameTag = next.tagName === tag;
1867
+ if (!sameTag) break;
1868
+
1869
+ const referencesLoopScope = elementReferencesLoopScope(next, itemVar, indexVar);
1870
+
1871
+ const toRemove = next;
1885
1872
  next = next.nextElementSibling;
1873
+ if (referencesLoopScope) toRemove.remove();
1874
+ else break;
1886
1875
  }
1887
1876
  });
1888
1877
  });
@@ -1896,6 +1885,15 @@ async function runPrerender(config) {
1896
1885
  toRemove.forEach((el) => { if (document.contains(el)) el.remove(); });
1897
1886
  });
1898
1887
 
1888
+ const visibilityNormalizedPath = logicalPathToVisibilityNormalizedPath(pathSeg, locales);
1889
+ await page.evaluate((np) => {
1890
+ try {
1891
+ window.ManifestRoutingVisibility?.processRouteVisibility?.(np);
1892
+ } catch {
1893
+ /* no-op */
1894
+ }
1895
+ }, visibilityNormalizedPath);
1896
+
1899
1897
  // Remove route-hidden content ([x-route] with inline style display:none) so each prerendered page contains only that route's HTML.
1900
1898
  await page.evaluate(() => {
1901
1899
  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.8",
3
+ "version": "0.3.0",
4
4
  "description": "Render Manifest sites to static HTML for SEO",
5
5
  "type": "module",
6
6
  "bin": {