mnfst-render 0.2.7 → 0.2.8

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 +333 -56
  2. package/package.json +1 -1
@@ -23,6 +23,204 @@ async function importFromProject(moduleName) {
23
23
  }
24
24
  }
25
25
 
26
+ /**
27
+ * localechange reloads localized sources asynchronously; waitForNetworkIdle can finish before
28
+ * Alpine.store('data') updates. Without this, x-for / $modify('items') lists often snapshot empty.
29
+ */
30
+ async function waitForAlpineDataStoresSettled(page, timeoutMs = 12000) {
31
+ const interval = 100;
32
+ const deadline = Date.now() + timeoutMs;
33
+ while (Date.now() < deadline) {
34
+ const ok = await page
35
+ .evaluate(() => {
36
+ try {
37
+ const Alpine = window.Alpine;
38
+ if (!Alpine?.store) return true;
39
+ const d = Alpine.store('data');
40
+ if (!d) return true;
41
+ if (d._localeChanging) return false;
42
+ for (const k of Object.keys(d)) {
43
+ if (!k.startsWith('_') || !k.endsWith('_state')) continue;
44
+ const s = d[k];
45
+ if (s && typeof s === 'object' && s.loading) return false;
46
+ }
47
+ return true;
48
+ } catch {
49
+ return true;
50
+ }
51
+ })
52
+ .catch(() => true);
53
+ if (ok) return;
54
+ await new Promise((r) => setTimeout(r, interval));
55
+ }
56
+ }
57
+
58
+ async function flushAlpineEffects(page) {
59
+ await page
60
+ .evaluate(() => {
61
+ return new Promise((resolve) => {
62
+ try {
63
+ if (typeof Alpine !== 'undefined' && typeof Alpine.nextTick === 'function') {
64
+ Alpine.nextTick(() => {
65
+ if (typeof Alpine.nextTick === 'function') Alpine.nextTick(resolve);
66
+ else resolve();
67
+ });
68
+ } else {
69
+ queueMicrotask(resolve);
70
+ }
71
+ } catch {
72
+ resolve();
73
+ }
74
+ });
75
+ })
76
+ .catch(() => {});
77
+ }
78
+
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.
83
+ */
84
+ async function waitForManifestPrerenderPipeline(page, { allLocales, currentLocale, timeoutMs }) {
85
+ const result = await page
86
+ .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;
93
+
94
+ const checkData = () => {
95
+ 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;
107
+ } catch {
108
+ return true;
109
+ }
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;
124
+ }
125
+ if (sawComponents && checkData()) {
126
+ cleanup();
127
+ resolve({ ok: true });
128
+ }
129
+ };
130
+
131
+ function onComponents() {
132
+ if (!dispatched) return;
133
+ sawComponents = true;
134
+ tryFinish();
135
+ }
136
+
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);
153
+
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
+ }
173
+ }
174
+
175
+ if (loc && typeof loc === 'string') {
176
+ window.dispatchEvent(new CustomEvent('localechange', { detail: { locale: loc } }));
177
+ }
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) });
205
+ }
206
+ });
207
+ },
208
+ {
209
+ localeList: allLocales,
210
+ loc: currentLocale,
211
+ ms: timeoutMs,
212
+ }
213
+ )
214
+ .catch((e) => ({ ok: false, reason: 'evaluate', message: String(e) }));
215
+
216
+ if (!result?.ok) {
217
+ const parts = [`prerender: pipeline wait incomplete (${result?.reason ?? 'unknown'})`];
218
+ if (result?.dataOk === false) parts.push('data still loading');
219
+ if (result?.sawComponents === false && !result?.skippedComponents) parts.push('no manifest:components-processed');
220
+ process.stdout.write(`${parts.join('; ')}\n`);
221
+ }
222
+ }
223
+
26
224
  // --- Config ------------------------------------------------------------------
27
225
 
28
226
  function parseArgs() {
@@ -88,6 +286,7 @@ function resolveConfig() {
88
286
  concurrency: Math.max(1, cli.concurrency ?? pre.concurrency ?? 6),
89
287
  dryRun: !!cli.dryRun,
90
288
  debugPrerender: !!(cli.debugPrerender ?? pre.debugPrerender),
289
+ pipelineTimeout: Math.max(3000, Number(pre.pipelineTimeout) || 25000),
91
290
  };
92
291
  }
93
292
 
@@ -1273,6 +1472,25 @@ async function runPrerender(config) {
1273
1472
 
1274
1473
  const page = await browser.newPage();
1275
1474
  try {
1475
+ // Align <html lang> with the URL being prerendered before any app script runs.
1476
+ // initializeDataSourcesPlugin picks locale from document.documentElement.lang first; a mismatch
1477
+ // (e.g. headless default vs /en/...) leaves $x.* empty while x-route sections still render.
1478
+ await page.evaluateOnNewDocument((locale) => {
1479
+ const apply = () => {
1480
+ try {
1481
+ if (locale && typeof locale === 'string') document.documentElement.lang = locale;
1482
+ } catch {
1483
+ /* no-op */
1484
+ }
1485
+ };
1486
+ if (typeof document !== 'undefined') {
1487
+ if (document.readyState === 'loading') {
1488
+ document.addEventListener('DOMContentLoaded', apply, { once: true });
1489
+ }
1490
+ apply();
1491
+ }
1492
+ }, currentLocale);
1493
+
1276
1494
  pushDebug({ path: displayPath, stage: 'start' });
1277
1495
  await page.goto(url, {
1278
1496
  waitUntil: 'domcontentloaded',
@@ -1313,6 +1531,34 @@ async function runPrerender(config) {
1313
1531
  });
1314
1532
  }).catch(() => { });
1315
1533
 
1534
+ await page.waitForNetworkIdle({ idleTime: 1500, timeout: 10000 }).catch(() => { });
1535
+
1536
+ await page.evaluate(() => {
1537
+ return new Promise((resolve) => {
1538
+ const observer = new MutationObserver(() => {
1539
+ clearTimeout(stable);
1540
+ stable = setTimeout(resolve, 800);
1541
+ });
1542
+ observer.observe(document.documentElement, { childList: true, subtree: true, characterData: true });
1543
+ let stable = setTimeout(() => {
1544
+ observer.disconnect();
1545
+ resolve();
1546
+ }, 800);
1547
+ });
1548
+ }).catch(() => { });
1549
+
1550
+ // Locale + route sync, then wait for manifest:components-processed (swapping) and settled data stores
1551
+ // before snapshot. Listener is installed inside the page before dispatch (see waitForManifestPrerenderPipeline).
1552
+ await waitForManifestPrerenderPipeline(page, {
1553
+ allLocales: locales,
1554
+ currentLocale,
1555
+ timeoutMs: config.pipelineTimeout,
1556
+ });
1557
+
1558
+ await page.waitForNetworkIdle({ idleTime: 800, timeout: 12000 }).catch(() => { });
1559
+ await waitForAlpineDataStoresSettled(page, 10000);
1560
+ await flushAlpineEffects(page);
1561
+
1316
1562
  if (config.debugPrerender) {
1317
1563
  const before = await page.evaluate(() => {
1318
1564
  const templates = Array.from(document.querySelectorAll('template[x-for]'));
@@ -1334,71 +1580,102 @@ async function runPrerender(config) {
1334
1580
  cloneCount,
1335
1581
  };
1336
1582
  });
1583
+
1584
+ const listDiagnostics = {
1585
+ htmlLang: '',
1586
+ localeCurrent: null,
1587
+ dataLocaleChanging: null,
1588
+ dataStates: {},
1589
+ topLevelArrayLengths: {},
1590
+ nestedContentCards: null,
1591
+ emptyStaticXFors: [],
1592
+ };
1593
+
1594
+ try {
1595
+ listDiagnostics.htmlLang = document.documentElement.lang || '';
1596
+ const Alpine = window.Alpine;
1597
+ if (Alpine?.store) {
1598
+ const loc = Alpine.store('locale');
1599
+ listDiagnostics.localeCurrent = loc?.current ?? null;
1600
+ const d = Alpine.store('data');
1601
+ if (d) {
1602
+ listDiagnostics.dataLocaleChanging = !!d._localeChanging;
1603
+ for (const k of Object.keys(d)) {
1604
+ if (k.startsWith('_') && k.endsWith('_state')) {
1605
+ const short = k.slice(1, -'_state'.length);
1606
+ const s = d[k];
1607
+ if (s && typeof s === 'object') {
1608
+ listDiagnostics.dataStates[short] = {
1609
+ loading: !!s.loading,
1610
+ ready: !!s.ready,
1611
+ hasError: s.error != null,
1612
+ };
1613
+ }
1614
+ } else if (!k.startsWith('_') && Array.isArray(d[k])) {
1615
+ listDiagnostics.topLevelArrayLengths[k] = d[k].length;
1616
+ }
1617
+ }
1618
+ try {
1619
+ const cards = d.content?.home?.differentiators?.cards;
1620
+ if (Array.isArray(cards)) listDiagnostics.nestedContentCards = cards.length;
1621
+ else if (cards && typeof cards === 'object') listDiagnostics.nestedContentCards = Object.keys(cards).length;
1622
+ else listDiagnostics.nestedContentCards = cards == null ? null : 'non-iterable';
1623
+ } catch {
1624
+ listDiagnostics.nestedContentCards = 'error';
1625
+ }
1626
+ }
1627
+ }
1628
+ } catch (e) {
1629
+ listDiagnostics.probeError = String(e?.message || e);
1630
+ }
1631
+
1632
+ for (const tpl of templates) {
1633
+ if (tpl.getAttribute('data-prerender-collapsed') === '1') continue;
1634
+ const first = tpl.content?.firstElementChild;
1635
+ const tag = first ? first.tagName : null;
1636
+ const cls = first ? (first.getAttribute('class') || '') : '';
1637
+ let cloneCount = 0;
1638
+ let next = tpl.nextElementSibling;
1639
+ while (next && (!tag || next.tagName === tag)) {
1640
+ if (tag && (next.getAttribute('class') || '') !== cls) break;
1641
+ cloneCount++;
1642
+ next = next.nextElementSibling;
1643
+ }
1644
+ if (cloneCount > 0) continue;
1645
+ const routeAnc = tpl.closest('[x-route]');
1646
+ let hiddenReason = null;
1647
+ let el = tpl.parentElement;
1648
+ while (el) {
1649
+ if (el.hasAttribute('hidden')) {
1650
+ hiddenReason = 'ancestor-hidden';
1651
+ break;
1652
+ }
1653
+ const st = el.getAttribute('style') || '';
1654
+ if (/\bdisplay\s*:\s*none\b/i.test(st)) {
1655
+ hiddenReason = 'ancestor-display-none';
1656
+ break;
1657
+ }
1658
+ el = el.parentElement;
1659
+ }
1660
+ const itemsHost = tpl.closest('[items]');
1661
+ listDiagnostics.emptyStaticXFors.push({
1662
+ xFor: (tpl.getAttribute('x-for') || '').slice(0, 160),
1663
+ nearestXRoute: routeAnc ? (routeAnc.getAttribute('x-route') || '').slice(0, 100) : null,
1664
+ hiddenReason,
1665
+ hostItemsAttr: itemsHost ? (itemsHost.getAttribute('items') || '').slice(0, 120) : null,
1666
+ });
1667
+ }
1668
+
1337
1669
  return {
1338
1670
  templateCount: templates.length,
1339
1671
  nonCollapsedTemplateCount: templates.filter((t) => t.getAttribute('data-prerender-collapsed') !== '1').length,
1340
1672
  entries,
1673
+ listDiagnostics,
1341
1674
  };
1342
1675
  }).catch(() => null);
1343
1676
  pushDebug({ path: displayPath, stage: 'post-dom-settle', metrics: before });
1344
1677
  }
1345
1678
 
1346
- await page.waitForNetworkIdle({ idleTime: 1500, timeout: 10000 }).catch(() => { });
1347
-
1348
- await page.evaluate(() => {
1349
- return new Promise((resolve) => {
1350
- const observer = new MutationObserver(() => {
1351
- clearTimeout(stable);
1352
- stable = setTimeout(resolve, 800);
1353
- });
1354
- observer.observe(document.documentElement, { childList: true, subtree: true, characterData: true });
1355
- let stable = setTimeout(() => {
1356
- observer.disconnect();
1357
- resolve();
1358
- }, 800);
1359
- });
1360
- }).catch(() => { });
1361
-
1362
- // Ensure $route-dependent expressions are recalculated after locale/data stores settle.
1363
- // This helps localized dynamic pages (e.g. /ko/articles/slug) compute prev/next links correctly.
1364
- await page.evaluate(({ allLocales, currentLocale }) => {
1365
- try {
1366
- const localeList = Array.isArray(allLocales) ? allLocales : [];
1367
- const store = (typeof Alpine !== 'undefined' && Alpine.store) ? Alpine.store('locale') : null;
1368
- if (store) {
1369
- if (!Array.isArray(store.available) || store.available.length === 0) {
1370
- store.available = localeList.slice();
1371
- } else {
1372
- const merged = Array.from(new Set([...store.available, ...localeList]));
1373
- store.available = merged;
1374
- }
1375
- if (currentLocale && typeof currentLocale === 'string') {
1376
- store.current = currentLocale;
1377
- }
1378
- }
1379
-
1380
- const rawRoute = window.ManifestRoutingNavigation?.getCurrentRoute?.() ?? window.location.pathname;
1381
- const clean = String(rawRoute || '/').replace(/^\/+|\/+$/g, '');
1382
- const parts = clean ? clean.split('/') : [];
1383
- const logical = parts.length > 0 && localeList.includes(parts[0])
1384
- ? '/' + parts.slice(1).join('/')
1385
- : (clean ? '/' + clean : '/');
1386
- const to = logical === '//' ? '/' : logical;
1387
-
1388
- window.dispatchEvent(new CustomEvent('manifest:route-change', {
1389
- detail: {
1390
- from: to,
1391
- to,
1392
- normalizedPath: (typeof to === 'string' && to !== '/') ? to.replace(/^\/|\/$/g, '') : '/'
1393
- }
1394
- }));
1395
- window.dispatchEvent(new PopStateEvent('popstate'));
1396
- } catch {
1397
- // no-op
1398
- }
1399
- }, { allLocales: locales, currentLocale }).catch(() => { });
1400
- await new Promise((resolve) => setTimeout(resolve, 60));
1401
-
1402
1679
  // Optional extra delay so in-page async (e.g. fetch() in x-init for client logos) can complete before snapshot.
1403
1680
  if (config.waitAfterIdle > 0) {
1404
1681
  await new Promise((r) => setTimeout(r, config.waitAfterIdle));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mnfst-render",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
4
4
  "description": "Render Manifest sites to static HTML for SEO",
5
5
  "type": "module",
6
6
  "bin": {