import { createReadStream } from 'node:fs' import { stat } from 'node:fs/promises' import { createServer } from 'node:http' import type { AddressInfo } from 'node:net' import path from 'node:path' import { chromium, type Browser, type Page } from 'playwright-chromium' export interface OverflowAuditDeck { entry: string slug: string } export interface SlideOverflowIssue { deck: OverflowAuditDeck heading: string horizontal: number slide: number vertical: number } export interface OverflowAuditResult { errors: Array<{ deck: OverflowAuditDeck, message: string }> issues: SlideOverflowIssue[] } interface OverflowAuditOptions { decks: OverflowAuditDeck[] root: string siteBase: string } export interface OverflowAuditTarget { deck: OverflowAuditDeck routerMode?: 'hash' | 'history' url: string } const overflowTolerance = 2 export class OverflowAuditor { private browser?: Browser private page?: Page async audit(targets: OverflowAuditTarget[]): Promise { const page = await this.ensurePage() const result: OverflowAuditResult = { 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 } private 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: string) { const sentinel = new EventTarget() as EventTarget & { release: () => Promise released: boolean type: string } 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: OverflowAuditOptions): Promise { const server = await startStaticServer(options.root, options.siteBase) const address = server.address() as AddressInfo 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: Page, target: OverflowAuditTarget) { const issues: SlideOverflowIssue[] = [] 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: OverflowAuditTarget, route: string) { 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: string) { return value.endsWith('/') ? value : `${value}/` } async function waitForPageAssets(page: 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: Page, slide: number) { 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: string, siteBase: string) { 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: string, requestPath: string, siteBase: string) { 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: string) { 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', } as Record)[extension] ?? 'application/octet-stream' }