mnfst-render 0.2.7 → 0.2.9

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 +309 -56
  2. package/package.json +1 -1
@@ -23,6 +23,180 @@ 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: run component swapping explicitly, then wait until Alpine data stores
81
+ * are settled. We call ManifestComponentsSwapping.processAll() directly because swapping only
82
+ * subscribes to manifest:route-change if window.ManifestRouting already exists at initialize() time;
83
+ * when the router loads after components, that listener is never registered and no second
84
+ * manifest:components-processed fires.
85
+ */
86
+ async function waitForManifestPrerenderPipeline(page, { allLocales, currentLocale, timeoutMs }) {
87
+ const result = await page
88
+ .evaluate(
89
+ async ({ localeList, loc, ms }) => {
90
+ const sleep = (n) => new Promise((r) => setTimeout(r, n));
91
+ const deadline = Date.now() + ms;
92
+
93
+ const checkData = () => {
94
+ try {
95
+ const Alpine = window.Alpine;
96
+ if (!Alpine?.store) return true;
97
+ const d = Alpine.store('data');
98
+ if (!d) return true;
99
+ if (d._localeChanging) return false;
100
+ for (const k of Object.keys(d)) {
101
+ if (!k.startsWith('_') || !k.endsWith('_state')) continue;
102
+ const s = d[k];
103
+ if (s && typeof s === 'object' && s.loading) return false;
104
+ }
105
+ return true;
106
+ } catch {
107
+ return true;
108
+ }
109
+ };
110
+
111
+ try {
112
+ const locales = Array.isArray(localeList) ? localeList : [];
113
+ if (loc && typeof loc === 'string') {
114
+ try {
115
+ document.documentElement.lang = loc;
116
+ } catch {
117
+ /* no-op */
118
+ }
119
+ }
120
+ const store = typeof Alpine !== 'undefined' && Alpine.store ? Alpine.store('locale') : null;
121
+ if (store) {
122
+ if (!Array.isArray(store.available) || store.available.length === 0) {
123
+ store.available = locales.slice();
124
+ } else {
125
+ store.available = Array.from(new Set([...store.available, ...locales]));
126
+ }
127
+ if (loc && typeof loc === 'string') {
128
+ store.current = loc;
129
+ }
130
+ }
131
+
132
+ if (loc && typeof loc === 'string') {
133
+ window.dispatchEvent(new CustomEvent('localechange', { detail: { locale: loc } }));
134
+ }
135
+
136
+ const rawRoute = window.ManifestRoutingNavigation?.getCurrentRoute?.() ?? window.location.pathname;
137
+ const clean = String(rawRoute || '/').replace(/^\/+|\/+$/g, '');
138
+ const parts = clean ? clean.split('/') : [];
139
+ const logical =
140
+ parts.length > 0 && locales.includes(parts[0])
141
+ ? '/' + parts.slice(1).join('/')
142
+ : clean
143
+ ? '/' + clean
144
+ : '/';
145
+ const to = logical === '//' ? '/' : logical;
146
+ const normalizedPath =
147
+ typeof to === 'string' && to !== '/' ? to.replace(/^\/|\/$/g, '') : '/';
148
+
149
+ window.dispatchEvent(
150
+ new CustomEvent('manifest:route-change', {
151
+ detail: {
152
+ from: to,
153
+ to,
154
+ normalizedPath,
155
+ },
156
+ })
157
+ );
158
+ window.dispatchEvent(new PopStateEvent('popstate'));
159
+
160
+ if (window.ManifestComponentsSwapping?.processAll) {
161
+ try {
162
+ await window.ManifestComponentsSwapping.processAll(normalizedPath);
163
+ } catch (e) {
164
+ return { ok: false, reason: 'processAll-error', message: String(e?.message || e) };
165
+ }
166
+ }
167
+
168
+ while (Date.now() < deadline) {
169
+ if (checkData()) {
170
+ return { ok: true };
171
+ }
172
+ await sleep(50);
173
+ }
174
+ return {
175
+ ok: false,
176
+ reason: 'timeout',
177
+ dataOk: checkData(),
178
+ hadSwapping: !!window.ManifestComponentsSwapping?.processAll,
179
+ };
180
+ } catch (err) {
181
+ return { ok: false, reason: 'error', message: String(err?.message || err) };
182
+ }
183
+ },
184
+ {
185
+ localeList: allLocales,
186
+ loc: currentLocale,
187
+ ms: timeoutMs,
188
+ }
189
+ )
190
+ .catch((e) => ({ ok: false, reason: 'evaluate', message: String(e) }));
191
+
192
+ if (!result?.ok) {
193
+ const parts = [`prerender: pipeline wait incomplete (${result?.reason ?? 'unknown'})`];
194
+ if (result?.dataOk === false) parts.push('data still loading');
195
+ if (result?.message) parts.push(result.message);
196
+ process.stdout.write(`${parts.join('; ')}\n`);
197
+ }
198
+ }
199
+
26
200
  // --- Config ------------------------------------------------------------------
27
201
 
28
202
  function parseArgs() {
@@ -88,6 +262,7 @@ function resolveConfig() {
88
262
  concurrency: Math.max(1, cli.concurrency ?? pre.concurrency ?? 6),
89
263
  dryRun: !!cli.dryRun,
90
264
  debugPrerender: !!(cli.debugPrerender ?? pre.debugPrerender),
265
+ pipelineTimeout: Math.max(3000, Number(pre.pipelineTimeout) || 25000),
91
266
  };
92
267
  }
93
268
 
@@ -1273,6 +1448,25 @@ async function runPrerender(config) {
1273
1448
 
1274
1449
  const page = await browser.newPage();
1275
1450
  try {
1451
+ // Align <html lang> with the URL being prerendered before any app script runs.
1452
+ // initializeDataSourcesPlugin picks locale from document.documentElement.lang first; a mismatch
1453
+ // (e.g. headless default vs /en/...) leaves $x.* empty while x-route sections still render.
1454
+ await page.evaluateOnNewDocument((locale) => {
1455
+ const apply = () => {
1456
+ try {
1457
+ if (locale && typeof locale === 'string') document.documentElement.lang = locale;
1458
+ } catch {
1459
+ /* no-op */
1460
+ }
1461
+ };
1462
+ if (typeof document !== 'undefined') {
1463
+ if (document.readyState === 'loading') {
1464
+ document.addEventListener('DOMContentLoaded', apply, { once: true });
1465
+ }
1466
+ apply();
1467
+ }
1468
+ }, currentLocale);
1469
+
1276
1470
  pushDebug({ path: displayPath, stage: 'start' });
1277
1471
  await page.goto(url, {
1278
1472
  waitUntil: 'domcontentloaded',
@@ -1313,6 +1507,34 @@ async function runPrerender(config) {
1313
1507
  });
1314
1508
  }).catch(() => { });
1315
1509
 
1510
+ await page.waitForNetworkIdle({ idleTime: 1500, timeout: 10000 }).catch(() => { });
1511
+
1512
+ await page.evaluate(() => {
1513
+ return new Promise((resolve) => {
1514
+ const observer = new MutationObserver(() => {
1515
+ clearTimeout(stable);
1516
+ stable = setTimeout(resolve, 800);
1517
+ });
1518
+ observer.observe(document.documentElement, { childList: true, subtree: true, characterData: true });
1519
+ let stable = setTimeout(() => {
1520
+ observer.disconnect();
1521
+ resolve();
1522
+ }, 800);
1523
+ });
1524
+ }).catch(() => { });
1525
+
1526
+ // Locale + route sync, then wait for manifest:components-processed (swapping) and settled data stores
1527
+ // before snapshot. Listener is installed inside the page before dispatch (see waitForManifestPrerenderPipeline).
1528
+ await waitForManifestPrerenderPipeline(page, {
1529
+ allLocales: locales,
1530
+ currentLocale,
1531
+ timeoutMs: config.pipelineTimeout,
1532
+ });
1533
+
1534
+ await page.waitForNetworkIdle({ idleTime: 800, timeout: 12000 }).catch(() => { });
1535
+ await waitForAlpineDataStoresSettled(page, 10000);
1536
+ await flushAlpineEffects(page);
1537
+
1316
1538
  if (config.debugPrerender) {
1317
1539
  const before = await page.evaluate(() => {
1318
1540
  const templates = Array.from(document.querySelectorAll('template[x-for]'));
@@ -1334,71 +1556,102 @@ async function runPrerender(config) {
1334
1556
  cloneCount,
1335
1557
  };
1336
1558
  });
1559
+
1560
+ const listDiagnostics = {
1561
+ htmlLang: '',
1562
+ localeCurrent: null,
1563
+ dataLocaleChanging: null,
1564
+ dataStates: {},
1565
+ topLevelArrayLengths: {},
1566
+ nestedContentCards: null,
1567
+ emptyStaticXFors: [],
1568
+ };
1569
+
1570
+ try {
1571
+ listDiagnostics.htmlLang = document.documentElement.lang || '';
1572
+ const Alpine = window.Alpine;
1573
+ if (Alpine?.store) {
1574
+ const loc = Alpine.store('locale');
1575
+ listDiagnostics.localeCurrent = loc?.current ?? null;
1576
+ const d = Alpine.store('data');
1577
+ if (d) {
1578
+ listDiagnostics.dataLocaleChanging = !!d._localeChanging;
1579
+ for (const k of Object.keys(d)) {
1580
+ if (k.startsWith('_') && k.endsWith('_state')) {
1581
+ const short = k.slice(1, -'_state'.length);
1582
+ const s = d[k];
1583
+ if (s && typeof s === 'object') {
1584
+ listDiagnostics.dataStates[short] = {
1585
+ loading: !!s.loading,
1586
+ ready: !!s.ready,
1587
+ hasError: s.error != null,
1588
+ };
1589
+ }
1590
+ } else if (!k.startsWith('_') && Array.isArray(d[k])) {
1591
+ listDiagnostics.topLevelArrayLengths[k] = d[k].length;
1592
+ }
1593
+ }
1594
+ try {
1595
+ const cards = d.content?.home?.differentiators?.cards;
1596
+ if (Array.isArray(cards)) listDiagnostics.nestedContentCards = cards.length;
1597
+ else if (cards && typeof cards === 'object') listDiagnostics.nestedContentCards = Object.keys(cards).length;
1598
+ else listDiagnostics.nestedContentCards = cards == null ? null : 'non-iterable';
1599
+ } catch {
1600
+ listDiagnostics.nestedContentCards = 'error';
1601
+ }
1602
+ }
1603
+ }
1604
+ } catch (e) {
1605
+ listDiagnostics.probeError = String(e?.message || e);
1606
+ }
1607
+
1608
+ for (const tpl of templates) {
1609
+ if (tpl.getAttribute('data-prerender-collapsed') === '1') continue;
1610
+ const first = tpl.content?.firstElementChild;
1611
+ const tag = first ? first.tagName : null;
1612
+ const cls = first ? (first.getAttribute('class') || '') : '';
1613
+ let cloneCount = 0;
1614
+ let next = tpl.nextElementSibling;
1615
+ while (next && (!tag || next.tagName === tag)) {
1616
+ if (tag && (next.getAttribute('class') || '') !== cls) break;
1617
+ cloneCount++;
1618
+ next = next.nextElementSibling;
1619
+ }
1620
+ if (cloneCount > 0) continue;
1621
+ const routeAnc = tpl.closest('[x-route]');
1622
+ let hiddenReason = null;
1623
+ let el = tpl.parentElement;
1624
+ while (el) {
1625
+ if (el.hasAttribute('hidden')) {
1626
+ hiddenReason = 'ancestor-hidden';
1627
+ break;
1628
+ }
1629
+ const st = el.getAttribute('style') || '';
1630
+ if (/\bdisplay\s*:\s*none\b/i.test(st)) {
1631
+ hiddenReason = 'ancestor-display-none';
1632
+ break;
1633
+ }
1634
+ el = el.parentElement;
1635
+ }
1636
+ const itemsHost = tpl.closest('[items]');
1637
+ listDiagnostics.emptyStaticXFors.push({
1638
+ xFor: (tpl.getAttribute('x-for') || '').slice(0, 160),
1639
+ nearestXRoute: routeAnc ? (routeAnc.getAttribute('x-route') || '').slice(0, 100) : null,
1640
+ hiddenReason,
1641
+ hostItemsAttr: itemsHost ? (itemsHost.getAttribute('items') || '').slice(0, 120) : null,
1642
+ });
1643
+ }
1644
+
1337
1645
  return {
1338
1646
  templateCount: templates.length,
1339
1647
  nonCollapsedTemplateCount: templates.filter((t) => t.getAttribute('data-prerender-collapsed') !== '1').length,
1340
1648
  entries,
1649
+ listDiagnostics,
1341
1650
  };
1342
1651
  }).catch(() => null);
1343
1652
  pushDebug({ path: displayPath, stage: 'post-dom-settle', metrics: before });
1344
1653
  }
1345
1654
 
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
1655
  // Optional extra delay so in-page async (e.g. fetch() in x-init for client logos) can complete before snapshot.
1403
1656
  if (config.waitAfterIdle > 0) {
1404
1657
  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.9",
4
4
  "description": "Render Manifest sites to static HTML for SEO",
5
5
  "type": "module",
6
6
  "bin": {