Update content & design (#75)
All checks were successful
Deploy to Pages / build (push) Successful in 2m58s
All checks were successful
Deploy to Pages / build (push) Successful in 2m58s
Reviewed-on: https://home.schmelczer.dev/git/git/andras/schmelczer-dev/pulls/75
This commit is contained in:
parent
0be50b6c24
commit
b554e92e9f
83 changed files with 2995 additions and 723 deletions
95
scripts/check-no-em-dashes.mjs
Normal file
95
scripts/check-no-em-dashes.mjs
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { readdir, readFile, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const forbidden = String.fromCodePoint(0x2014);
|
||||
const root = process.cwd();
|
||||
|
||||
const textExtensions = new Set([
|
||||
'.astro',
|
||||
'.css',
|
||||
'.html',
|
||||
'.js',
|
||||
'.json',
|
||||
'.md',
|
||||
'.mjs',
|
||||
'.ts',
|
||||
'.txt',
|
||||
'.vtt',
|
||||
'.webmanifest',
|
||||
'.xml',
|
||||
]);
|
||||
|
||||
const roots = [
|
||||
'src',
|
||||
'public',
|
||||
'scripts',
|
||||
'README.md',
|
||||
'package.json',
|
||||
'astro.config.mjs',
|
||||
].map((entry) => path.resolve(root, entry));
|
||||
|
||||
async function exists(filePath) {
|
||||
try {
|
||||
await stat(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function walk(entryPath) {
|
||||
const entryStat = await stat(entryPath);
|
||||
if (entryStat.isFile()) return [entryPath];
|
||||
|
||||
const entries = await readdir(entryPath, { withFileTypes: true });
|
||||
const files = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(entryPath, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await walk(fullPath)));
|
||||
} else if (entry.isFile()) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
function lineAndColumn(text, index) {
|
||||
const before = text.slice(0, index);
|
||||
const lines = before.split('\n');
|
||||
return {
|
||||
line: lines.length,
|
||||
column: lines.at(-1).length + 1,
|
||||
};
|
||||
}
|
||||
|
||||
const files = [];
|
||||
|
||||
for (const entry of roots) {
|
||||
if (!(await exists(entry))) continue;
|
||||
files.push(...(await walk(entry)));
|
||||
}
|
||||
|
||||
const textFiles = files.filter((file) => textExtensions.has(path.extname(file)));
|
||||
|
||||
const failures = [];
|
||||
|
||||
for (const file of textFiles) {
|
||||
const text = await readFile(file, 'utf8');
|
||||
let index = text.indexOf(forbidden);
|
||||
|
||||
while (index !== -1) {
|
||||
const { line, column } = lineAndColumn(text, index);
|
||||
failures.push(`${path.relative(root, file)}:${line}:${column}`);
|
||||
index = text.indexOf(forbidden, index + forbidden.length);
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(`Em dashes are not allowed:\n${failures.join('\n')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('No em dashes found.');
|
||||
|
|
@ -36,9 +36,8 @@ if (jsFiles.length > 0) {
|
|||
}
|
||||
|
||||
// Script tags are only allowed if they declare one of these safe `type`
|
||||
// attributes (or are tagged with `data-theme-script`). All other scripts —
|
||||
// including untyped ones, which default to executable JavaScript — are
|
||||
// flagged.
|
||||
// attributes (or are tagged with `data-theme-script`). All other scripts,
|
||||
// including untyped ones, which default to executable JavaScript, are flagged.
|
||||
const SAFE_SCRIPT_TYPES = new Set([
|
||||
'application/ld+json',
|
||||
'importmap',
|
||||
|
|
@ -47,6 +46,7 @@ const SAFE_SCRIPT_TYPES = new Set([
|
|||
|
||||
function isSafeScriptTag(tag) {
|
||||
if (tag.includes('data-theme-script')) return true;
|
||||
if (tag.includes('data-thumbnail-iframe-script')) return true;
|
||||
const typeMatch = tag.match(/\btype=["']([^"']+)["']/i);
|
||||
if (!typeMatch) return false;
|
||||
return SAFE_SCRIPT_TYPES.has(typeMatch[1].trim().toLowerCase());
|
||||
|
|
|
|||
536
scripts/check-preview-cropping.mjs
Normal file
536
scripts/check-preview-cropping.mjs
Normal file
|
|
@ -0,0 +1,536 @@
|
|||
import { createServer } from 'node:http';
|
||||
import { mkdir, readdir, readFile, rm, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const dist = path.resolve('dist');
|
||||
const previewCss = path.resolve('src/styles/global.css');
|
||||
const browserTmp = path.resolve('.astro', 'playwright-preview-cropping-tmp');
|
||||
const INDEX_FILE = 'index.html';
|
||||
const PREVIEW_SELECTOR = '[data-uncropped-preview]';
|
||||
// Common device widths: iPhone SE / Galaxy S / iPhone 14 / iPad portrait /
|
||||
// iPad landscape / common laptop / full HD desktop.
|
||||
const VIEWPORT_WIDTHS = [320, 390, 430, 768, 1024, 1440, 1920];
|
||||
const MAX_NAV_RETRIES = 4;
|
||||
const CLOSE_TIMEOUT_MS = 3000;
|
||||
const LAUNCH_TIMEOUT_MS = 10000;
|
||||
const CONTEXT_TIMEOUT_MS = 8000;
|
||||
const PAGE_TIMEOUT_MS = 15000;
|
||||
const MEASURE_TIMEOUT_MS = 30000;
|
||||
const CLIP_TOLERANCE_PX = 0.75;
|
||||
const RATIO_TOLERANCE = 0.01;
|
||||
|
||||
const MIME = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.webp': 'image/webp',
|
||||
'.avif': 'image/avif',
|
||||
'.ico': 'image/x-icon',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
'.mp4': 'video/mp4',
|
||||
'.webm': 'video/webm',
|
||||
'.vtt': 'text/vtt; charset=utf-8',
|
||||
'.pdf': 'application/pdf',
|
||||
};
|
||||
|
||||
function contentType(file) {
|
||||
const ext = path.extname(file).toLowerCase();
|
||||
return MIME[ext] ?? 'application/octet-stream';
|
||||
}
|
||||
|
||||
async function walk(dir) {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
const files = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await walk(fullPath)));
|
||||
} else {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
async function discoverRoutes() {
|
||||
const files = await walk(dist);
|
||||
const routes = new Set();
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.html')) continue;
|
||||
const rel = path.relative(dist, file).replaceAll(path.sep, '/');
|
||||
if (rel === '404.html') continue;
|
||||
if (rel === INDEX_FILE) {
|
||||
routes.add('/');
|
||||
} else if (rel.endsWith(`/${INDEX_FILE}`)) {
|
||||
routes.add('/' + rel.slice(0, -INDEX_FILE.length));
|
||||
} else {
|
||||
routes.add('/' + rel.replace(/\.html$/, '/'));
|
||||
}
|
||||
}
|
||||
|
||||
return [...routes].sort();
|
||||
}
|
||||
|
||||
async function resolveFile(url) {
|
||||
const parsed = new URL(url, 'http://localhost');
|
||||
const safePath = path
|
||||
.normalize(decodeURIComponent(parsed.pathname))
|
||||
.replace(/^\/+/, '')
|
||||
.replace(/^(\.\.(\/|\\|$))+/, '');
|
||||
const candidate = path.join(dist, safePath);
|
||||
const candidates = [
|
||||
candidate,
|
||||
path.join(candidate, 'index.html'),
|
||||
path.join(dist, `${safePath}.html`),
|
||||
];
|
||||
|
||||
for (const file of candidates) {
|
||||
try {
|
||||
const fileStat = await stat(file);
|
||||
if (fileStat.isFile()) return file;
|
||||
} catch {
|
||||
// Try the next candidate.
|
||||
}
|
||||
}
|
||||
|
||||
return path.join(dist, '404.html');
|
||||
}
|
||||
|
||||
try {
|
||||
await stat(dist);
|
||||
} catch {
|
||||
throw new Error('dist/ does not exist. Run npm run build first.');
|
||||
}
|
||||
|
||||
function lineAndColumn(text, index) {
|
||||
const before = text.slice(0, index);
|
||||
const lines = before.split('\n');
|
||||
return {
|
||||
line: lines.length,
|
||||
column: lines.at(-1).length + 1,
|
||||
};
|
||||
}
|
||||
|
||||
function declarationValue(body, property) {
|
||||
const pattern = new RegExp(`(?:^|;)\\s*${property}\\s*:\\s*([^;]+)`, 'i');
|
||||
return body.match(pattern)?.[1]?.trim();
|
||||
}
|
||||
|
||||
function scaleValueExpands(value) {
|
||||
for (const match of value.matchAll(/\bscale(?:3d|x|y)?\(([^)]*)\)/gi)) {
|
||||
const name = match[0].slice(0, match[0].indexOf('(')).toLowerCase();
|
||||
const values = match[1]
|
||||
.split(/[\s,]+/)
|
||||
.map(Number)
|
||||
.filter(Number.isFinite);
|
||||
|
||||
if (name === 'scalex' || name === 'scaley') {
|
||||
if ((values[0] ?? 1) > 1) return true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((values[0] ?? 1) > 1 || (values[1] ?? values[0] ?? 1) > 1) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return value
|
||||
.split(/[\s,]+/)
|
||||
.map(Number)
|
||||
.filter(Number.isFinite)
|
||||
.some((number) => number > 1);
|
||||
}
|
||||
|
||||
async function checkPreviewCroppingStyles() {
|
||||
const css = await readFile(previewCss, 'utf8');
|
||||
const styleFailures = [];
|
||||
const blockPattern = /([^{}]+)\{([^{}]*)\}/g;
|
||||
|
||||
for (const match of css.matchAll(blockPattern)) {
|
||||
const selector = match[1].replace(/\/\*[\s\S]*?\*\//g, '').trim();
|
||||
const body = match[2];
|
||||
// Only inspect rules that target elements explicitly opted in to the
|
||||
// no-crop contract via [data-uncropped-preview]. Listing thumbnails
|
||||
// that intentionally cover-crop don't carry this attribute.
|
||||
if (!selector || !/\[data-uncropped-preview\b/.test(selector)) continue;
|
||||
|
||||
const targetsMedia = /\b(img|picture|video|canvas)\b/i.test(selector);
|
||||
const objectFit = declarationValue(body, 'object-fit');
|
||||
const backgroundSize = declarationValue(body, 'background-size');
|
||||
const transform = declarationValue(body, 'transform');
|
||||
const scale = declarationValue(body, 'scale');
|
||||
|
||||
const addFailure = (property, reason) => {
|
||||
const propertyIndex = css.indexOf(property, match.index);
|
||||
const { line, column } = lineAndColumn(css, propertyIndex);
|
||||
styleFailures.push(
|
||||
`${path.relative(process.cwd(), previewCss)}:${line}:${column}: ${selector} ${reason}`
|
||||
);
|
||||
};
|
||||
|
||||
if (targetsMedia && /^cover\b/i.test(objectFit ?? '')) {
|
||||
addFailure('object-fit', 'uses object-fit: cover');
|
||||
}
|
||||
|
||||
if (/^cover\b/i.test(backgroundSize ?? '')) {
|
||||
addFailure('background-size', 'uses background-size: cover');
|
||||
}
|
||||
|
||||
if (targetsMedia && transform && scaleValueExpands(transform)) {
|
||||
addFailure('transform', 'scales preview media larger than its container');
|
||||
}
|
||||
|
||||
if (targetsMedia && scale && scaleValueExpands(scale)) {
|
||||
addFailure('scale', 'scales preview media larger than its container');
|
||||
}
|
||||
}
|
||||
|
||||
return styleFailures;
|
||||
}
|
||||
|
||||
// Keep Chromium temp files inside the repo so the check is reproducible in CI
|
||||
// containers with very small /tmp mounts.
|
||||
await rm(browserTmp, { recursive: true, force: true });
|
||||
await mkdir(browserTmp, { recursive: true });
|
||||
process.env.TMPDIR = browserTmp;
|
||||
process.env.TMP = browserTmp;
|
||||
process.env.TEMP = browserTmp;
|
||||
|
||||
const routes = await discoverRoutes();
|
||||
const failures = await checkPreviewCroppingStyles();
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
try {
|
||||
const file = await resolveFile(req.url ?? '/');
|
||||
const body = await readFile(file);
|
||||
res.writeHead(200, { 'content-type': contentType(file) });
|
||||
res.end(body);
|
||||
} catch (error) {
|
||||
res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
|
||||
res.end(String(error));
|
||||
}
|
||||
});
|
||||
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const { port } = server.address();
|
||||
|
||||
function launchBrowser() {
|
||||
return chromium.launch({
|
||||
headless: true,
|
||||
env: {
|
||||
...process.env,
|
||||
TMPDIR: browserTmp,
|
||||
TMP: browserTmp,
|
||||
TEMP: browserTmp,
|
||||
},
|
||||
args: ['--disable-dev-shm-usage', '--disable-gpu', '--no-sandbox'],
|
||||
});
|
||||
}
|
||||
|
||||
async function withTimeout(promise, timeoutMs, label) {
|
||||
let timeout;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise((_, reject) => {
|
||||
timeout = setTimeout(() => reject(new Error(label)), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function safeClosePage(page) {
|
||||
await withTimeout(
|
||||
page.close(),
|
||||
CLOSE_TIMEOUT_MS,
|
||||
'Timed out while closing Playwright page'
|
||||
).catch(() => {});
|
||||
}
|
||||
|
||||
async function safeCloseContext(context) {
|
||||
await withTimeout(
|
||||
context.close(),
|
||||
CLOSE_TIMEOUT_MS,
|
||||
'Timed out while closing Playwright context'
|
||||
).catch(() => {});
|
||||
}
|
||||
|
||||
async function safeCloseBrowser(browser) {
|
||||
const childProcess = browser.process?.();
|
||||
try {
|
||||
await withTimeout(
|
||||
browser.close(),
|
||||
CLOSE_TIMEOUT_MS,
|
||||
'Timed out while closing Chromium'
|
||||
);
|
||||
} catch {
|
||||
childProcess?.kill('SIGKILL');
|
||||
}
|
||||
}
|
||||
|
||||
async function openBrowser() {
|
||||
return withTimeout(
|
||||
launchBrowser(),
|
||||
LAUNCH_TIMEOUT_MS,
|
||||
'Timed out while launching Chromium'
|
||||
);
|
||||
}
|
||||
|
||||
async function newMeasurementContext(browser, width) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width, height: 900 },
|
||||
javaScriptEnabled: true,
|
||||
reducedMotion: 'reduce',
|
||||
});
|
||||
|
||||
await context.route('**/*', (route) => {
|
||||
const type = route.request().resourceType();
|
||||
if (type === 'image' || type === 'media') {
|
||||
route.abort('blockedbyclient');
|
||||
} else {
|
||||
route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
async function openMeasurementContext(browser, width) {
|
||||
return withTimeout(
|
||||
newMeasurementContext(browser, width),
|
||||
CONTEXT_TIMEOUT_MS,
|
||||
`Timed out while creating ${width}px Playwright context`
|
||||
);
|
||||
}
|
||||
|
||||
async function inspectPreviews(page, route, width, phase, index = null) {
|
||||
const findings = await page.evaluate(
|
||||
({ clipTolerancePx, index, phase, ratioTolerance, selector }) => {
|
||||
const failures = [];
|
||||
const previews = [...document.querySelectorAll(selector)];
|
||||
const selectedPreviews =
|
||||
index === null ? previews : [previews[index]].filter(Boolean);
|
||||
const clippingValues = new Set(['hidden', 'clip', 'scroll', 'auto']);
|
||||
|
||||
function isClippingElement(element) {
|
||||
const style = getComputedStyle(element);
|
||||
return (
|
||||
clippingValues.has(style.overflow) ||
|
||||
clippingValues.has(style.overflowX) ||
|
||||
clippingValues.has(style.overflowY)
|
||||
);
|
||||
}
|
||||
|
||||
function nearestClippingAncestor(image, preview) {
|
||||
let current = image.parentElement;
|
||||
|
||||
while (current && preview.contains(current)) {
|
||||
if (isClippingElement(current)) return current;
|
||||
if (current === preview) break;
|
||||
current = current.parentElement;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function labelFor(preview, image) {
|
||||
const label =
|
||||
preview.getAttribute('data-preview-label') ||
|
||||
preview.getAttribute('aria-label') ||
|
||||
image.alt ||
|
||||
'preview image';
|
||||
const source = image.currentSrc || image.src;
|
||||
|
||||
let pathname = source;
|
||||
try {
|
||||
pathname = new URL(source, document.baseURI).pathname;
|
||||
} catch {
|
||||
// Keep the raw source if URL parsing fails.
|
||||
}
|
||||
|
||||
return `${label} (${pathname})`;
|
||||
}
|
||||
|
||||
function ratioDelta(first, second) {
|
||||
return Math.abs(first - second) / Math.max(first, second);
|
||||
}
|
||||
|
||||
function isRectClipped(rect, clipRect) {
|
||||
return (
|
||||
rect.left < clipRect.left - clipTolerancePx ||
|
||||
rect.top < clipRect.top - clipTolerancePx ||
|
||||
rect.right > clipRect.right + clipTolerancePx ||
|
||||
rect.bottom > clipRect.bottom + clipTolerancePx
|
||||
);
|
||||
}
|
||||
|
||||
for (const preview of selectedPreviews) {
|
||||
if (!(preview instanceof HTMLElement)) continue;
|
||||
|
||||
const image = preview.querySelector('img');
|
||||
if (!(image instanceof HTMLImageElement)) {
|
||||
failures.push({
|
||||
label: preview.getAttribute('data-preview-label') || 'preview image',
|
||||
reason: 'has no rendered <img>',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const label = labelFor(preview, image);
|
||||
const sourceWidth = Number(image.getAttribute('width')) || image.naturalWidth;
|
||||
const sourceHeight = Number(image.getAttribute('height')) || image.naturalHeight;
|
||||
|
||||
if (sourceWidth <= 0 || sourceHeight <= 0) {
|
||||
failures.push({ label, reason: 'image has no intrinsic dimensions' });
|
||||
continue;
|
||||
}
|
||||
|
||||
const imageRect = image.getBoundingClientRect();
|
||||
const imageWidth = image.clientWidth || imageRect.width;
|
||||
const imageHeight = image.clientHeight || imageRect.height;
|
||||
|
||||
if (imageWidth <= 0 || imageHeight <= 0) {
|
||||
failures.push({ label, reason: 'image rendered with no visible size' });
|
||||
continue;
|
||||
}
|
||||
|
||||
const imageStyle = getComputedStyle(image);
|
||||
const sourceRatio = sourceWidth / sourceHeight;
|
||||
const boxRatio = imageWidth / imageHeight;
|
||||
|
||||
if (
|
||||
imageStyle.objectFit === 'cover' &&
|
||||
ratioDelta(sourceRatio, boxRatio) > ratioTolerance
|
||||
) {
|
||||
failures.push({
|
||||
label,
|
||||
reason: `uses object-fit: cover for a ${sourceWidth}x${sourceHeight} source in a ${Math.round(imageWidth)}x${Math.round(imageHeight)} box`,
|
||||
});
|
||||
}
|
||||
|
||||
const clippingAncestor = nearestClippingAncestor(image, preview);
|
||||
if (clippingAncestor) {
|
||||
const clipRect = clippingAncestor.getBoundingClientRect();
|
||||
if (isRectClipped(imageRect, clipRect)) {
|
||||
failures.push({
|
||||
label,
|
||||
reason: 'image bounds are clipped by an overflow container',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return failures.map((failure) => ({
|
||||
...failure,
|
||||
phase,
|
||||
}));
|
||||
},
|
||||
{
|
||||
clipTolerancePx: CLIP_TOLERANCE_PX,
|
||||
index,
|
||||
phase,
|
||||
ratioTolerance: RATIO_TOLERANCE,
|
||||
selector: PREVIEW_SELECTOR,
|
||||
}
|
||||
);
|
||||
|
||||
return findings.map(
|
||||
(finding) =>
|
||||
`${route} at ${width}px (${finding.phase}): ${finding.label}: ${finding.reason}`
|
||||
);
|
||||
}
|
||||
|
||||
function shouldRetryNavigation(error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return /ERR_INSUFFICIENT_RESOURCES|Execution context was destroyed|Target.*closed|has been closed|Timed out while|navigation/i.test(
|
||||
message
|
||||
);
|
||||
}
|
||||
|
||||
async function measureRoute(context, route, width) {
|
||||
let page;
|
||||
|
||||
try {
|
||||
page = await withTimeout(
|
||||
context.newPage(),
|
||||
PAGE_TIMEOUT_MS,
|
||||
`Timed out while creating page for ${route}`
|
||||
);
|
||||
|
||||
return await withTimeout(
|
||||
(async () => {
|
||||
await page.goto(`http://127.0.0.1:${port}${route}`, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 15000,
|
||||
});
|
||||
await page.waitForLoadState('load', { timeout: 5000 }).catch(() => {});
|
||||
|
||||
return inspectPreviews(page, route, width, 'normal');
|
||||
})(),
|
||||
MEASURE_TIMEOUT_MS,
|
||||
`Timed out while checking preview cropping for ${route}`
|
||||
);
|
||||
} finally {
|
||||
if (page) await safeClosePage(page);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
for (const width of VIEWPORT_WIDTHS) {
|
||||
let browser;
|
||||
let context;
|
||||
|
||||
try {
|
||||
browser = await openBrowser();
|
||||
context = await openMeasurementContext(browser, width);
|
||||
|
||||
for (const route of routes) {
|
||||
let result;
|
||||
|
||||
for (let attempt = 0; attempt < MAX_NAV_RETRIES; attempt += 1) {
|
||||
try {
|
||||
result = await measureRoute(context, route, width);
|
||||
break;
|
||||
} catch (error) {
|
||||
if (!shouldRetryNavigation(error) || attempt === MAX_NAV_RETRIES - 1) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await safeCloseContext(context);
|
||||
await safeCloseBrowser(browser);
|
||||
browser = await openBrowser();
|
||||
context = await openMeasurementContext(browser, width);
|
||||
}
|
||||
}
|
||||
|
||||
failures.push(...result);
|
||||
}
|
||||
} finally {
|
||||
if (context) await safeCloseContext(context);
|
||||
if (browser) await safeCloseBrowser(browser);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
server.close();
|
||||
await rm(browserTmp, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(failures.join('\n'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`No cropped previews detected at ${VIEWPORT_WIDTHS.join(', ')}px across ${routes.length} routes.`
|
||||
);
|
||||
484
scripts/export-astro-audit.mjs
Normal file
484
scripts/export-astro-audit.mjs
Normal file
|
|
@ -0,0 +1,484 @@
|
|||
import { spawn } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
import { mkdir, readdir, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { createServer as createNetServer } from 'node:net';
|
||||
import path from 'node:path';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const dist = path.resolve('dist');
|
||||
const browserTmp = path.resolve('.astro', 'playwright-astro-audit-tmp');
|
||||
const outputJson = path.resolve(
|
||||
process.env.ASTRO_AUDIT_OUTPUT_JSON ?? '.astro/astro-audit-results.json'
|
||||
);
|
||||
const outputMarkdown = path.resolve(
|
||||
process.env.ASTRO_AUDIT_OUTPUT_MD ?? '.astro/astro-audit-results.md'
|
||||
);
|
||||
const astroBin = path.resolve(
|
||||
'node_modules',
|
||||
'.bin',
|
||||
process.platform === 'win32' ? 'astro.cmd' : 'astro'
|
||||
);
|
||||
const HOST = '127.0.0.1';
|
||||
const INDEX_FILE = 'index.html';
|
||||
const SERVER_START_TIMEOUT_MS = 60000;
|
||||
const CLOSE_TIMEOUT_MS = 3000;
|
||||
const NAV_TIMEOUT_MS = 20000;
|
||||
const AUDIT_TIMEOUT_MS = 30000;
|
||||
const DEFAULT_VIEWPORTS = '1440x900';
|
||||
const failOnIssues =
|
||||
process.argv.includes('--fail-on-issues') ||
|
||||
process.env.ASTRO_AUDIT_FAIL_ON_ISSUES === '1';
|
||||
|
||||
// Heuristic above-fold / below-fold loading rules flip based on the heights of
|
||||
// items rendered above them, which shift whenever a post's description length
|
||||
// changes. They produce false positives that can't be resolved with a single
|
||||
// `eagerThumbnailCount` per list, so the audit suppresses them.
|
||||
const IGNORED_AUDIT_CODES = new Set(['perf-use-loading-eager', 'perf-use-loading-lazy']);
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function parseViewports(raw = process.env.ASTRO_AUDIT_VIEWPORTS ?? DEFAULT_VIEWPORTS) {
|
||||
return raw.split(',').map((entry) => {
|
||||
const match = entry.trim().match(/^(\d+)x(\d+)$/i);
|
||||
if (!match) {
|
||||
throw new Error(
|
||||
`Invalid ASTRO_AUDIT_VIEWPORTS entry "${entry}". Use values like 1440x900,390x844.`
|
||||
);
|
||||
}
|
||||
return { width: Number(match[1]), height: Number(match[2]) };
|
||||
});
|
||||
}
|
||||
|
||||
async function walk(dir) {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
const files = [];
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await walk(fullPath)));
|
||||
} else {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
async function discoverRoutes() {
|
||||
if (process.env.ASTRO_AUDIT_ROUTES) {
|
||||
return process.env.ASTRO_AUDIT_ROUTES.split(',')
|
||||
.map((route) => route.trim())
|
||||
.filter(Boolean)
|
||||
.map((route) => (route.startsWith('/') ? route : `/${route}`));
|
||||
}
|
||||
|
||||
try {
|
||||
await stat(dist);
|
||||
} catch {
|
||||
throw new Error('dist/ does not exist. Run npm run build first.');
|
||||
}
|
||||
|
||||
const files = await walk(dist);
|
||||
const routes = new Set();
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.html')) continue;
|
||||
const rel = path.relative(dist, file).replaceAll(path.sep, '/');
|
||||
if (rel === '404.html') continue;
|
||||
|
||||
if (rel === INDEX_FILE) {
|
||||
routes.add('/');
|
||||
} else if (rel.endsWith(`/${INDEX_FILE}`)) {
|
||||
routes.add('/' + rel.slice(0, -INDEX_FILE.length));
|
||||
} else {
|
||||
routes.add('/' + rel.replace(/\.html$/, '/'));
|
||||
}
|
||||
}
|
||||
|
||||
return [...routes].sort();
|
||||
}
|
||||
|
||||
async function getFreePort() {
|
||||
const server = createNetServer();
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, HOST, resolve);
|
||||
});
|
||||
const { port } = server.address();
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
return port;
|
||||
}
|
||||
|
||||
function startAstroDev(port) {
|
||||
const child = spawn(astroBin, ['dev', '--host', HOST, '--port', String(port)], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
ASTRO_TELEMETRY_DISABLED: '1',
|
||||
NO_COLOR: '1',
|
||||
TMPDIR: browserTmp,
|
||||
TMP: browserTmp,
|
||||
TEMP: browserTmp,
|
||||
},
|
||||
});
|
||||
|
||||
let output = '';
|
||||
const append = (chunk) => {
|
||||
output = `${output}${chunk.toString()}`.slice(-20000);
|
||||
};
|
||||
child.stdout.on('data', append);
|
||||
child.stderr.on('data', append);
|
||||
child.getRecentOutput = () => output.trim();
|
||||
return child;
|
||||
}
|
||||
|
||||
async function waitForDevServer(baseUrl, child) {
|
||||
const deadline = Date.now() + SERVER_START_TIMEOUT_MS;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (child.exitCode !== null) {
|
||||
throw new Error(
|
||||
`Astro dev server exited before it was ready.\n${child.getRecentOutput()}`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(baseUrl);
|
||||
if (response.status < 500) return;
|
||||
} catch {
|
||||
// Server is not listening yet.
|
||||
}
|
||||
|
||||
await sleep(250);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Timed out waiting for Astro dev server at ${baseUrl}.\n${child.getRecentOutput()}`
|
||||
);
|
||||
}
|
||||
|
||||
async function stopProcess(child) {
|
||||
if (!child || child.exitCode !== null) return;
|
||||
child.kill('SIGTERM');
|
||||
const exited = once(child, 'exit');
|
||||
const timedOut = sleep(CLOSE_TIMEOUT_MS).then(() => 'timeout');
|
||||
if ((await Promise.race([exited, timedOut])) === 'timeout') {
|
||||
child.kill('SIGKILL');
|
||||
}
|
||||
}
|
||||
|
||||
async function safeCloseBrowser(browser) {
|
||||
const childProcess = browser?.process?.();
|
||||
try {
|
||||
await Promise.race([
|
||||
browser.close(),
|
||||
sleep(CLOSE_TIMEOUT_MS).then(() => {
|
||||
throw new Error('Timed out while closing Chromium');
|
||||
}),
|
||||
]);
|
||||
} catch {
|
||||
childProcess?.kill('SIGKILL');
|
||||
}
|
||||
}
|
||||
|
||||
function viewportLabel(viewport) {
|
||||
return `${viewport.width}x${viewport.height}`;
|
||||
}
|
||||
|
||||
async function extractAuditResults(page) {
|
||||
return page.evaluate(
|
||||
async ({ auditTimeoutMs }) => {
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const deadline = Date.now() + auditTimeoutMs;
|
||||
|
||||
function describeElement(element) {
|
||||
if (!element) return '';
|
||||
const pieces = [];
|
||||
let current = element;
|
||||
while (
|
||||
current instanceof Element &&
|
||||
current !== document.documentElement &&
|
||||
pieces.length < 5
|
||||
) {
|
||||
let piece = current.localName;
|
||||
if (current.id) {
|
||||
piece += `#${current.id}`;
|
||||
} else {
|
||||
const classes = [...current.classList].slice(0, 2);
|
||||
if (classes.length > 0) piece += `.${classes.join('.')}`;
|
||||
const parent = current.parentElement;
|
||||
if (parent) {
|
||||
const siblings = [...parent.children].filter(
|
||||
(child) => child.localName === current.localName
|
||||
);
|
||||
if (siblings.length > 1) {
|
||||
piece += `:nth-of-type(${siblings.indexOf(current) + 1})`;
|
||||
}
|
||||
}
|
||||
}
|
||||
pieces.unshift(piece);
|
||||
current = current.parentElement;
|
||||
}
|
||||
return pieces.join(' > ');
|
||||
}
|
||||
|
||||
function resolveRuleValue(value, element) {
|
||||
try {
|
||||
if (typeof value === 'function') return String(value(element) ?? '');
|
||||
return String(value ?? '');
|
||||
} catch (error) {
|
||||
return `Error resolving audit value: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`;
|
||||
}
|
||||
}
|
||||
|
||||
function sourceForAudit(audit) {
|
||||
const tooltip = audit.highlight?.shadowRoot?.querySelector(
|
||||
'astro-dev-toolbar-tooltip'
|
||||
);
|
||||
const sourceSection = tooltip?.sections?.find(
|
||||
(section) => section.clickDescription === 'Click to go to file'
|
||||
);
|
||||
return sourceSection?.content ?? null;
|
||||
}
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const toolbar = document.querySelector('astro-dev-toolbar');
|
||||
const button = toolbar?.shadowRoot?.querySelector('[data-app-id="astro:audit"]');
|
||||
|
||||
if (button && !button.classList.contains('active')) {
|
||||
button.click();
|
||||
}
|
||||
|
||||
const auditCanvas = toolbar?.shadowRoot?.querySelector(
|
||||
'astro-dev-toolbar-app-canvas[data-app-id="astro:audit"]'
|
||||
);
|
||||
const auditWindow = auditCanvas?.shadowRoot?.querySelector(
|
||||
'astro-dev-toolbar-audit-window'
|
||||
);
|
||||
|
||||
if (button?.classList.contains('active') && auditWindow) {
|
||||
await sleep(100);
|
||||
return {
|
||||
results: (auditWindow.audits ?? []).map((audit) => {
|
||||
const element = audit.auditedElement;
|
||||
return {
|
||||
code: audit.rule.code,
|
||||
category: audit.rule.code?.split('-')[0] ?? '',
|
||||
title: resolveRuleValue(audit.rule.title, element),
|
||||
message: resolveRuleValue(audit.rule.message, element),
|
||||
description: resolveRuleValue(audit.rule.description, element),
|
||||
ruleSelector: audit.rule.selector,
|
||||
source: sourceForAudit(audit),
|
||||
element: {
|
||||
selector: describeElement(element),
|
||||
tagName: element?.tagName?.toLowerCase() ?? '',
|
||||
html: element?.outerHTML?.replace(/\s+/g, ' ').slice(0, 800) ?? '',
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
await sleep(250);
|
||||
}
|
||||
|
||||
return {
|
||||
error:
|
||||
'Timed out waiting for the Astro Dev Toolbar Audit app. Make sure devToolbar is enabled.',
|
||||
};
|
||||
},
|
||||
{ auditTimeoutMs: AUDIT_TIMEOUT_MS }
|
||||
);
|
||||
}
|
||||
|
||||
async function auditRoute(context, baseUrl, route, viewport) {
|
||||
const page = await context.newPage();
|
||||
try {
|
||||
await page.goto(new URL(route, baseUrl).href, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: NAV_TIMEOUT_MS,
|
||||
});
|
||||
await page.waitForSelector('astro-dev-toolbar', {
|
||||
state: 'attached',
|
||||
timeout: AUDIT_TIMEOUT_MS,
|
||||
});
|
||||
await page.waitForLoadState('load', { timeout: 5000 }).catch(() => {});
|
||||
|
||||
const audit = await extractAuditResults(page);
|
||||
if (audit.error) {
|
||||
throw new Error(`${route} at ${viewportLabel(viewport)}: ${audit.error}`);
|
||||
}
|
||||
|
||||
return audit.results
|
||||
.filter((result) => !IGNORED_AUDIT_CODES.has(result.code))
|
||||
.map((result) => ({
|
||||
route,
|
||||
url: page.url(),
|
||||
viewport: viewportLabel(viewport),
|
||||
...result,
|
||||
}));
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function markdownEscape(value) {
|
||||
return String(value ?? '').replaceAll('|', '\\|');
|
||||
}
|
||||
|
||||
function renderMarkdown(report) {
|
||||
const lines = [
|
||||
'# Astro Audit Results',
|
||||
'',
|
||||
`Generated: ${report.generatedAt}`,
|
||||
`Routes checked: ${report.routes.length}`,
|
||||
`Viewports: ${report.viewports.map(viewportLabel).join(', ')}`,
|
||||
`Issues found: ${report.results.length}`,
|
||||
'',
|
||||
];
|
||||
|
||||
if (report.results.length === 0) {
|
||||
lines.push('No accessibility or performance issues detected.');
|
||||
return `${lines.join('\n')}\n`;
|
||||
}
|
||||
|
||||
const byRoute = Map.groupBy(report.results, (result) => result.route);
|
||||
for (const [route, routeResults] of byRoute) {
|
||||
lines.push(`## ${route}`, '');
|
||||
lines.push('| Viewport | Code | Title | Source | Element |');
|
||||
lines.push('| --- | --- | --- | --- | --- |');
|
||||
for (const result of routeResults) {
|
||||
lines.push(
|
||||
`| ${markdownEscape(result.viewport)} | \`${markdownEscape(result.code)}\` | ${markdownEscape(
|
||||
result.title
|
||||
)} | ${markdownEscape(result.source ?? '')} | \`${markdownEscape(
|
||||
result.element.selector
|
||||
)}\` |`
|
||||
);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
lines.push('## Details', '');
|
||||
for (const result of report.results) {
|
||||
lines.push(
|
||||
`### ${result.route} ${result.viewport} ${result.code}`,
|
||||
'',
|
||||
`- Title: ${result.title}`,
|
||||
`- Source: ${result.source ?? 'unknown'}`,
|
||||
`- Message: ${result.message}`,
|
||||
`- Description: ${result.description || 'none'}`,
|
||||
`- Element: \`${result.element.selector}\``,
|
||||
'',
|
||||
'```html',
|
||||
result.element.html,
|
||||
'```',
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
return `${lines.join('\n')}\n`;
|
||||
}
|
||||
|
||||
const viewports = parseViewports();
|
||||
const routes = await discoverRoutes();
|
||||
|
||||
if (routes.length === 0) {
|
||||
throw new Error('No HTML routes found to audit.');
|
||||
}
|
||||
|
||||
await rm(browserTmp, { recursive: true, force: true });
|
||||
await mkdir(browserTmp, { recursive: true });
|
||||
await mkdir(path.dirname(outputJson), { recursive: true });
|
||||
await mkdir(path.dirname(outputMarkdown), { recursive: true });
|
||||
process.env.TMPDIR = browserTmp;
|
||||
process.env.TMP = browserTmp;
|
||||
process.env.TEMP = browserTmp;
|
||||
|
||||
const port = await getFreePort();
|
||||
const baseUrl = `http://${HOST}:${port}/`;
|
||||
let devServer;
|
||||
let browser;
|
||||
const results = [];
|
||||
|
||||
try {
|
||||
devServer = startAstroDev(port);
|
||||
await waitForDevServer(baseUrl, devServer);
|
||||
|
||||
browser = await chromium.launch({
|
||||
headless: true,
|
||||
env: {
|
||||
...process.env,
|
||||
TMPDIR: browserTmp,
|
||||
TMP: browserTmp,
|
||||
TEMP: browserTmp,
|
||||
},
|
||||
args: ['--disable-dev-shm-usage', '--disable-gpu', '--no-sandbox'],
|
||||
});
|
||||
|
||||
for (const viewport of viewports) {
|
||||
const context = await browser.newContext({
|
||||
viewport,
|
||||
javaScriptEnabled: true,
|
||||
});
|
||||
await context.route('**/*', (route) => {
|
||||
if (route.request().resourceType() === 'media') {
|
||||
route.abort('blockedbyclient');
|
||||
} else {
|
||||
route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
for (const route of routes) {
|
||||
results.push(...(await auditRoute(context, baseUrl, route, viewport)));
|
||||
}
|
||||
} finally {
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
const report = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
baseUrl,
|
||||
routes,
|
||||
viewports,
|
||||
results,
|
||||
};
|
||||
|
||||
await writeFile(outputJson, `${JSON.stringify(report, null, 2)}\n`);
|
||||
await writeFile(outputMarkdown, renderMarkdown(report));
|
||||
|
||||
console.log(
|
||||
`Exported ${results.length} Astro audit issue${
|
||||
results.length === 1 ? '' : 's'
|
||||
} across ${routes.length} routes.`
|
||||
);
|
||||
console.log(`JSON: ${path.relative(process.cwd(), outputJson)}`);
|
||||
console.log(`Markdown: ${path.relative(process.cwd(), outputMarkdown)}`);
|
||||
|
||||
if (results.length > 0) {
|
||||
for (const result of results.slice(0, 20)) {
|
||||
console.log(
|
||||
`- ${result.route} [${result.viewport}] ${result.code}: ${result.title}${
|
||||
result.source ? ` (${result.source})` : ''
|
||||
}`
|
||||
);
|
||||
}
|
||||
if (results.length > 20) {
|
||||
console.log(`...and ${results.length - 20} more. See the report files.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (failOnIssues && results.length > 0) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
} finally {
|
||||
if (browser) await safeCloseBrowser(browser);
|
||||
if (devServer) await stopProcess(devServer);
|
||||
await rm(browserTmp, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue