mnfst-render 0.2.6 → 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 +401 -34
  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() {
@@ -39,6 +237,7 @@ function parseArgs() {
39
237
  if (args[i] === '--wait-after-idle' && args[i + 1]) { out.waitAfterIdle = parseInt(args[++i], 10); continue; }
40
238
  if (args[i] === '--concurrency' && args[i + 1]) { out.concurrency = parseInt(args[++i], 10); continue; }
41
239
  if (args[i] === '--dry-run') { out.dryRun = true; continue; }
240
+ if (args[i] === '--debug-prerender') { out.debugPrerender = true; continue; }
42
241
  }
43
242
  return out;
44
243
  }
@@ -86,6 +285,8 @@ function resolveConfig() {
86
285
  waitAfterIdle: Math.max(0, cli.waitAfterIdle ?? pre.waitAfterIdle ?? 0),
87
286
  concurrency: Math.max(1, cli.concurrency ?? pre.concurrency ?? 6),
88
287
  dryRun: !!cli.dryRun,
288
+ debugPrerender: !!(cli.debugPrerender ?? pre.debugPrerender),
289
+ pipelineTimeout: Math.max(3000, Number(pre.pipelineTimeout) || 25000),
89
290
  };
90
291
  }
91
292
 
@@ -729,6 +930,29 @@ function stripPrerenderDynamicBindings(html) {
729
930
  });
730
931
  }
731
932
 
933
+ // Remove empty inline mask-image styles emitted before data resolves
934
+ // (e.g. style="mask-image: url()"), while keeping any :style/x-bind:style bindings.
935
+ function stripEmptyInlineMaskStyles(html) {
936
+ return html.replace(/<(\w+)([^>]*)>/g, (full, tag, attrs) => {
937
+ const styleMatch = attrs.match(/\sstyle=(["'])([\s\S]*?)\1/i);
938
+ if (!styleMatch) return full;
939
+ const quote = styleMatch[1];
940
+ const rawStyle = styleMatch[2] || '';
941
+ const cleaned = rawStyle
942
+ .replace(/\bmask-image\s*:\s*url\(\s*(?:''|""|)\s*\)\s*;?/gi, '')
943
+ .replace(/\b-webkit-mask-image\s*:\s*url\(\s*(?:''|""|)\s*\)\s*;?/gi, '')
944
+ .trim()
945
+ .replace(/^\s*;\s*|\s*;\s*$/g, '');
946
+
947
+ if (!cleaned) {
948
+ const newAttrs = attrs.replace(/\sstyle=(["'])[\s\S]*?\1/i, '');
949
+ return `<${tag}${newAttrs}>`;
950
+ }
951
+ const rebuilt = attrs.replace(/\sstyle=(["'])[\s\S]*?\1/i, ` style=${quote}${cleaned}${quote}`);
952
+ return `<${tag}${rebuilt}>`;
953
+ });
954
+ }
955
+
732
956
  // --- Rewrite asset URLs: depth = segments from this HTML file up to output root (website). ----
733
957
  // All project assets are copied into output, so root-relative paths become relative within output.
734
958
  // Do NOT rewrite href on <a> tags (navigation links); only rewrite link/script/img so router gets clean paths.
@@ -1222,8 +1446,14 @@ async function runPrerender(config) {
1222
1446
  const concurrency = config.concurrency;
1223
1447
  const pathTotal = pathList.length;
1224
1448
  const failedPaths = [];
1449
+ const debugRows = [];
1225
1450
  process.stdout.write(`Prerendering ${pathTotal} path(s)...\n`);
1226
1451
 
1452
+ function pushDebug(row) {
1453
+ if (!config.debugPrerender) return;
1454
+ debugRows.push(row);
1455
+ }
1456
+
1227
1457
  async function processPath(pathSeg, pathIndex) {
1228
1458
  const is404 = pathSeg === NOT_FOUND_PATH;
1229
1459
  const pathname = is404 ? `/${NOT_FOUND_PATH}` : (pathSeg ? `/${pathSeg}` : '/');
@@ -1242,6 +1472,26 @@ async function runPrerender(config) {
1242
1472
 
1243
1473
  const page = await browser.newPage();
1244
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
+
1494
+ pushDebug({ path: displayPath, stage: 'start' });
1245
1495
  await page.goto(url, {
1246
1496
  waitUntil: 'domcontentloaded',
1247
1497
  timeout: Math.min(timeout, 30000),
@@ -1297,45 +1547,134 @@ async function runPrerender(config) {
1297
1547
  });
1298
1548
  }).catch(() => { });
1299
1549
 
1300
- // Ensure $route-dependent expressions are recalculated after locale/data stores settle.
1301
- // This helps localized dynamic pages (e.g. /ko/articles/slug) compute prev/next links correctly.
1302
- await page.evaluate(({ allLocales, currentLocale }) => {
1303
- try {
1304
- const localeList = Array.isArray(allLocales) ? allLocales : [];
1305
- const store = (typeof Alpine !== 'undefined' && Alpine.store) ? Alpine.store('locale') : null;
1306
- if (store) {
1307
- if (!Array.isArray(store.available) || store.available.length === 0) {
1308
- store.available = localeList.slice();
1309
- } else {
1310
- const merged = Array.from(new Set([...store.available, ...localeList]));
1311
- store.available = merged;
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
+
1562
+ if (config.debugPrerender) {
1563
+ const before = await page.evaluate(() => {
1564
+ const templates = Array.from(document.querySelectorAll('template[x-for]'));
1565
+ const entries = templates.slice(0, 60).map((tpl) => {
1566
+ const first = tpl.content?.firstElementChild;
1567
+ const tag = first ? first.tagName : null;
1568
+ const cls = first ? (first.getAttribute('class') || '') : '';
1569
+ let cloneCount = 0;
1570
+ let next = tpl.nextElementSibling;
1571
+ while (next && (!tag || next.tagName === tag)) {
1572
+ if (tag && (next.getAttribute('class') || '') !== cls) break;
1573
+ cloneCount++;
1574
+ next = next.nextElementSibling;
1312
1575
  }
1313
- if (currentLocale && typeof currentLocale === 'string') {
1314
- store.current = currentLocale;
1576
+ return {
1577
+ xFor: (tpl.getAttribute('x-for') || '').slice(0, 140),
1578
+ collapsed: tpl.getAttribute('data-prerender-collapsed') === '1',
1579
+ staticGenerated: tpl.getAttribute('data-prerender-static-generated') === '1',
1580
+ cloneCount,
1581
+ };
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
+ }
1315
1627
  }
1628
+ } catch (e) {
1629
+ listDiagnostics.probeError = String(e?.message || e);
1316
1630
  }
1317
1631
 
1318
- const rawRoute = window.ManifestRoutingNavigation?.getCurrentRoute?.() ?? window.location.pathname;
1319
- const clean = String(rawRoute || '/').replace(/^\/+|\/+$/g, '');
1320
- const parts = clean ? clean.split('/') : [];
1321
- const logical = parts.length > 0 && localeList.includes(parts[0])
1322
- ? '/' + parts.slice(1).join('/')
1323
- : (clean ? '/' + clean : '/');
1324
- const to = logical === '//' ? '/' : logical;
1325
-
1326
- window.dispatchEvent(new CustomEvent('manifest:route-change', {
1327
- detail: {
1328
- from: to,
1329
- to,
1330
- normalizedPath: (typeof to === 'string' && to !== '/') ? to.replace(/^\/|\/$/g, '') : '/'
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;
1331
1643
  }
1332
- }));
1333
- window.dispatchEvent(new PopStateEvent('popstate'));
1334
- } catch {
1335
- // no-op
1336
- }
1337
- }, { allLocales: locales, currentLocale }).catch(() => { });
1338
- await new Promise((resolve) => setTimeout(resolve, 60));
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
+
1669
+ return {
1670
+ templateCount: templates.length,
1671
+ nonCollapsedTemplateCount: templates.filter((t) => t.getAttribute('data-prerender-collapsed') !== '1').length,
1672
+ entries,
1673
+ listDiagnostics,
1674
+ };
1675
+ }).catch(() => null);
1676
+ pushDebug({ path: displayPath, stage: 'post-dom-settle', metrics: before });
1677
+ }
1339
1678
 
1340
1679
  // Optional extra delay so in-page async (e.g. fetch() in x-init for client logos) can complete before snapshot.
1341
1680
  if (config.waitAfterIdle > 0) {
@@ -1568,6 +1907,15 @@ async function runPrerender(config) {
1568
1907
  });
1569
1908
 
1570
1909
  let html = await page.evaluate(() => document.documentElement.outerHTML);
1910
+ if (config.debugPrerender) {
1911
+ const post = await page.evaluate(() => {
1912
+ const templates = document.querySelectorAll('template[x-for]').length;
1913
+ const links = document.querySelectorAll('a[href="#"]').length;
1914
+ const hidden = document.querySelectorAll('[style*="display: none"]').length;
1915
+ return { templateCountAfterCleanup: templates, hashHrefCount: links, displayNoneCount: hidden };
1916
+ }).catch(() => null);
1917
+ pushDebug({ path: displayPath, stage: 'pre-serialize', metrics: post });
1918
+ }
1571
1919
  html = stripDevOnlyContent(html);
1572
1920
  html = stripInjectedPluginScripts(html);
1573
1921
  if (tailwindBuilt) {
@@ -1587,6 +1935,7 @@ async function runPrerender(config) {
1587
1935
  const xData = { manifest, content };
1588
1936
  html = resolveHeadXBindings(html, xData);
1589
1937
  html = stripPrerenderDynamicBindings(html);
1938
+ html = stripEmptyInlineMaskStyles(html);
1590
1939
  html = rewriteHtmlAssetPaths(html, fileSegments.length);
1591
1940
  const liveBase = config.liveUrl.replace(/\/$/, '');
1592
1941
  const canonicalHreflang = buildCanonicalAndHreflang(is404 ? '' : pathSeg, locales, defaultLocale, liveBase);
@@ -1599,6 +1948,13 @@ async function runPrerender(config) {
1599
1948
  html = html.replace('</head>', `${canonicalHreflang}${injectOgLocale ? ogLocale : ''}${baseMeta}${prerenderedMeta}<meta name="manifest:router-base-depth" content="${routeDepth}">\n</head>`);
1600
1949
  mkdirSync(outDir, { recursive: true });
1601
1950
  writeFileSync(outFile, html, 'utf8');
1951
+ pushDebug({
1952
+ path: displayPath,
1953
+ stage: 'wrote',
1954
+ outFile,
1955
+ htmlBytes: Buffer.byteLength(html, 'utf8'),
1956
+ hasXForTemplate: html.includes('template x-for') || html.includes('template[x-for]'),
1957
+ });
1602
1958
  } catch (err) {
1603
1959
  failedPaths.push({
1604
1960
  path: displayPath,
@@ -1633,6 +1989,17 @@ async function runPrerender(config) {
1633
1989
  throw new Error(`prerender failed for ${failedPaths.length}/${pathTotal} paths. Sample: ${sample}`);
1634
1990
  }
1635
1991
 
1992
+ if (config.debugPrerender) {
1993
+ const reportPath = join(outputResolved, 'prerender.debug.json');
1994
+ writeFileSync(reportPath, JSON.stringify({
1995
+ generatedAt: new Date().toISOString(),
1996
+ totalPaths: pathTotal,
1997
+ failedPaths,
1998
+ rows: debugRows,
1999
+ }, null, 2), 'utf8');
2000
+ process.stdout.write(`prerender: debug report ${reportPath}\n`);
2001
+ }
2002
+
1636
2003
  if (bundleUtilities) {
1637
2004
  const utilMerged = mergeUtilityCssBlocks(utilityBlocks);
1638
2005
  if (utilMerged.trim()) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mnfst-render",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "description": "Render Manifest sites to static HTML for SEO",
5
5
  "type": "module",
6
6
  "bin": {