import { createReadStream } from 'node:fs'; import { stat } from 'node:fs/promises'; import { createServer } from 'node:http'; import path from 'node:path'; import { chromium } from 'playwright-chromium'; const overflowTolerance = 2; export class OverflowAuditor { browser; page; async audit(targets) { const page = await this.ensurePage(); const result = { errors: [], issues: [] }; for (const target of targets) { try { result.issues.push(...await auditDeck(page, target)); } catch (error) { result.errors.push({ deck: target.deck, message: error instanceof Error ? error.message : String(error), }); } } return result; } async close() { await this.browser?.close(); this.browser = undefined; this.page = undefined; } async ensurePage() { if (this.page && !this.page.isClosed()) return this.page; await this.browser?.close().catch(() => undefined); this.browser = await chromium.launch({ headless: true }); const context = await this.browser.newContext({ viewport: { height: 900, width: 1200 } }); await context.addInitScript(() => { Object.defineProperty(navigator, 'wakeLock', { configurable: true, value: { async request(type) { const sentinel = new EventTarget(); sentinel.released = false; sentinel.type = type; sentinel.release = async () => { if (sentinel.released) return; sentinel.released = true; sentinel.dispatchEvent(new Event('release')); }; return sentinel; }, }, }); }); this.page = await context.newPage(); return this.page; } } export async function auditBuiltDecks(options) { const server = await startStaticServer(options.root, options.siteBase); const address = server.address(); const origin = `http://127.0.0.1:${address.port}`; const auditor = new OverflowAuditor(); try { return await auditor.audit(options.decks.map(deck => ({ deck, url: new URL(`${options.siteBase}${deck.slug}/`.replace(/\/{2,}/g, '/'), origin).href, }))); } finally { await auditor.close(); await new Promise(resolve => server.close(() => resolve())); } } async function auditDeck(page, target) { const issues = []; const overviewUrl = auditRouteUrl(target, 'overview'); await page.goto(overviewUrl.href, { timeout: 30_000, waitUntil: 'networkidle' }); await page.locator('.slidev-layout').first().waitFor({ state: 'attached', timeout: 15_000 }); await waitForPageAssets(page); const slideNumbers = await page.locator('[class*="slidev-page-"]').evaluateAll((pages) => { const numbers = pages.flatMap((page) => { const pageClass = Array.from(page.classList).find(className => /^slidev-page-\d+$/.test(className)); const number = Number.parseInt(pageClass?.replace('slidev-page-', '') ?? '', 10); return Number.isFinite(number) ? [number] : []; }); return Array.from(new Set(numbers)).sort((a, b) => a - b); }); if (slideNumbers.length && target.routerMode !== 'history') { await page.evaluate((number) => { window.location.hash = `#/${number}`; }, slideNumbers[0]); await page.reload({ timeout: 30_000, waitUntil: 'networkidle' }); await page.evaluate(async () => { await document.fonts?.ready; }); } for (const slide of slideNumbers) { if (target.routerMode === 'history') await page.goto(auditRouteUrl(target, String(slide)).href, { timeout: 30_000, waitUntil: 'networkidle' }); else await page.evaluate((number) => { window.location.hash = `#/${number}`; }, slide); await page.waitForFunction((number) => { const layouts = Array.from(document.querySelectorAll(`.slidev-page-${number} .slidev-layout`)); return layouts.some(layout => layout.getBoundingClientRect().width > 0); }, slide, { timeout: 10_000 }); await waitForSlideAssets(page, slide); await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))); const measurement = await page.evaluate((number) => { const layouts = Array.from(document.querySelectorAll(`.slidev-page-${number} .slidev-layout`)); const layout = layouts.find(item => item.getBoundingClientRect().width > 0) ?? layouts[0]; return { heading: layout?.querySelector('h1, h2, h3')?.textContent?.trim() ?? '', horizontal: layout ? Math.max(0, Math.ceil(layout.scrollWidth - layout.clientWidth)) : 0, slide: number, vertical: layout ? Math.max(0, Math.ceil(layout.scrollHeight - layout.clientHeight)) : 0, }; }, slide); if (measurement.vertical <= overflowTolerance && measurement.horizontal <= overflowTolerance) continue; issues.push({ deck: target.deck, ...measurement }); } return issues; } function auditRouteUrl(target, route) { const url = target.routerMode === 'history' ? new URL(`${route.replace(/^\/+/, '')}${route === 'overview' ? '/' : ''}`, ensureTrailingSlash(target.url)) : new URL(target.url); url.searchParams.set('easy-slides-audit', '1'); if (target.routerMode !== 'history') url.hash = `#/${route}`; return url; } function ensureTrailingSlash(value) { return value.endsWith('/') ? value : `${value}/`; } async function waitForPageAssets(page) { await page.evaluate(async () => { await document.fonts?.ready; const images = Array.from(document.images); await Promise.race([ Promise.all(images.map(image => image.complete ? Promise.resolve() : new Promise((resolve) => { image.addEventListener('load', () => resolve(), { once: true }); image.addEventListener('error', () => resolve(), { once: true }); }))), new Promise(resolve => setTimeout(resolve, 5_000)), ]); }); } async function waitForSlideAssets(page, slide) { await page.evaluate(async (number) => { const layout = Array.from(document.querySelectorAll(`.slidev-page-${number} .slidev-layout`)) .find(item => item.getBoundingClientRect().width > 0); const images = Array.from(layout?.querySelectorAll('img') ?? []); await Promise.race([ Promise.all(images.map(image => image.complete ? Promise.resolve() : new Promise((resolve) => { image.addEventListener('load', () => resolve(), { once: true }); image.addEventListener('error', () => resolve(), { once: true }); }))), new Promise(resolve => setTimeout(resolve, 5_000)), ]); }, slide); } async function startStaticServer(root, siteBase) { const server = createServer(async (request, response) => { if (request.method !== 'GET' && request.method !== 'HEAD') { response.writeHead(405).end(); return; } try { const requestUrl = new URL(request.url ?? '/', 'http://127.0.0.1'); const file = await resolveStaticFile(root, requestUrl.pathname, siteBase); response.statusCode = 200; response.setHeader('Content-Type', contentType(file)); if (request.method === 'HEAD') { response.end(); return; } createReadStream(file).pipe(response); } catch { response.writeHead(404).end('Not found'); } }); await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', () => resolve()); }); return server; } async function resolveStaticFile(root, requestPath, siteBase) { let pathname = decodeURIComponent(requestPath); const basePrefix = siteBase === '/' ? '' : siteBase.replace(/\/$/, ''); if (basePrefix && pathname.startsWith(`${basePrefix}/`)) pathname = pathname.slice(basePrefix.length); const candidate = path.resolve(root, pathname.replace(/^\/+/, '')); const relative = path.relative(root, candidate); if (relative.startsWith('..') || path.isAbsolute(relative)) throw new Error('Path outside static root'); const details = await stat(candidate); return details.isDirectory() ? path.join(candidate, 'index.html') : candidate; } function contentType(file) { const extension = path.extname(file).toLowerCase(); return { '.css': 'text/css; charset=utf-8', '.gif': 'image/gif', '.html': 'text/html; charset=utf-8', '.jpeg': 'image/jpeg', '.jpg': 'image/jpeg', '.js': 'text/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8', '.png': 'image/png', '.svg': 'image/svg+xml', '.ttf': 'font/ttf', '.webp': 'image/webp', '.woff': 'font/woff', '.woff2': 'font/woff2', }[extension] ?? 'application/octet-stream'; } //# sourceMappingURL=overflow-audit.js.map