228 lines
9.5 KiB
JavaScript
228 lines
9.5 KiB
JavaScript
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
|