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.
- package/manifest.render.mjs +333 -56
- package/package.json +1 -1
package/manifest.render.mjs
CHANGED
|
@@ -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));
|