async function scrollCapture(page, maxCss) {
let { vh, vw, total, dpr } = await page.evaluate((max) => {
document.querySelectorAll('img[loading="lazy"]').forEach((img) => { img.loading = 'eager'; });
// Sticky elements flow normally while we stitch: a sticky header then shows once at the
// top, and a sticky section (whole heroes on some Webflow pages) isn't lost
for (const el of document.querySelectorAll('body *')) {
if (getComputedStyle(el).position === 'sticky') el.setAttribute('data-capture-unstick', '');
}
const style = document.createElement('style');
style.textContent = 'html{scroll-behavior:auto!important}[data-capture-stuck]{visibility:hidden!important}[data-capture-unstick]{position:relative!important;top:auto!important}';
document.head.appendChild(style);
return { vh: innerHeight, vw: innerWidth, total: Math.min(document.documentElement.scrollHeight, max), dpr: devicePixelRatio };
}, maxCss);
const shots = [];
let y = 0;
for (let i = 0; i < 30; i++) {
await page.evaluate((top) => window.scrollTo(0, top), y);
await page.waitForTimeout(500);
// Wait (briefly) for images now in view to finish loading
await page.evaluate(() => Promise.race([
Promise.all([...document.images].filter((img) => { const r = img.getBoundingClientRect(); return r.bottom > 0 && r.top < innerHeight && !img.complete; })
.map((img) => new Promise((res) => { img.addEventListener('load', res, { once: true }); img.addEventListener('error', res, { once: true }); }))),
new Promise((res) => setTimeout(res, 2000)),
]));
const actual = await page.evaluate(() => Math.round(window.scrollY));
if (shots.length) {
if (actual === shots[shots.length - 1].y) break; // scrolling is locked or we reached the end
await page.evaluate(() => {
for (const el of document.querySelectorAll('body *')) {
// Only bars fixed to the top (headers, announcement bars): they'd repeat on every screen
const r = el.getBoundingClientRect();
if (getComputedStyle(el).position === 'fixed' && r.top < 160 && r.height < 240) el.setAttribute('data-capture-stuck', '');
}
});
}
shots.push({ buf: await page.screenshot(), y: actual });
// Pages that render sections as you scroll grow while we go, so re-measure every step
total = await page.evaluate((max) => Math.min(document.documentElement.scrollHeight, max), maxCss);
if (actual + vh >= total - 2) break;
y = actual + vh;
}
await page.evaluate(() => {
document.querySelectorAll('[data-capture-stuck],[data-capture-unstick]').forEach((el) => { el.removeAttribute('data-capture-stuck'); el.removeAttribute('data-capture-unstick'); });
window.scrollTo(0, 0);
});
const height = Math.min(total, shots[shots.length - 1].y + vh);
return sharp({ create: { width: Math.round(vw * dpr), height: Math.round(height * dpr), channels: 3, background: '#ffffff' } })
.composite(shots.map((s) => ({ input: s.buf, left: 0, top: Math.round(s.y * dpr) })))
.png().toBuffer();
}
# Every project with a capture block in its frontmatter
npm run capture
# Only some of them
npm run capture -- winona alphalion
# Frontmatter that drives it
capture:
home: https://shop.example.com/
pdp: https://shop.example.com/products/hero # product page, desktop + iPhone
cart: true # opens the cart drawer from the PDP
# (or a CSS selector for odd themes)