diff --git a/.claude/settings.local.json b/.claude/settings.local.json
new file mode 100644
index 0000000..00888a6
--- /dev/null
+++ b/.claude/settings.local.json
@@ -0,0 +1,7 @@
+{
+ "permissions": {
+ "allow": [
+ "Read(//volumes/projects/vault-link/**)"
+ ]
+ }
+}
diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml
index d32842b..a83df4a 100644
--- a/.forgejo/workflows/deploy.yml
+++ b/.forgejo/workflows/deploy.yml
@@ -38,7 +38,7 @@ jobs:
- name: Typecheck
run: npm run typecheck
- - name: Build & QA
+ - name: Build, Astro Audit & QA
run: |
npx playwright install chromium
npm run qa
diff --git a/README.md b/README.md
index 26b3a2e..64893ae 100644
--- a/README.md
+++ b/README.md
@@ -1,16 +1,14 @@
# schmelczer.dev
-A static personal blog for Andras Schmelczer, built with Astro.
+Engineering writeups by Andras Schmelczer: finished projects with the design constraints left in. Built with Astro, no required client JavaScript.
-The site is article-first: articles live in `src/content/posts`, project index entries
-live in `src/content/projects`, and normal pages are rendered as static HTML with no
-required client JavaScript.
+Articles live in `src/content/posts`, project index entries in `src/content/projects`, and normal pages are rendered as static HTML.
## Setup
```sh
npm ci
-npx playwright install --with-deps chromium # required before `npm run qa:overflow`
+npx playwright install --with-deps chromium # required before Playwright QA checks
```
## Commands
diff --git a/astro.config.mjs b/astro.config.mjs
index 56c2b91..5b5267d 100644
--- a/astro.config.mjs
+++ b/astro.config.mjs
@@ -62,6 +62,8 @@ export default defineConfig({
],
image: {
service: { entrypoint: 'astro/assets/services/sharp' },
+ // SVG sources in src/content/**/_assets are author-controlled.
+ dangerouslyProcessSVG: true,
},
vite: {
server: {
diff --git a/package.json b/package.json
index 66171b0..6ff283a 100644
--- a/package.json
+++ b/package.json
@@ -8,17 +8,20 @@
"node": ">=22.13.0"
},
"scripts": {
- "dev": "astro dev",
- "start": "astro dev",
+ "dev": "astro dev --host 0.0.0.0 --port 5173",
"typecheck": "astro check",
"lint": "prettier --check \"astro.config.mjs\" \"src/**/*.{astro,ts,md,css}\" \"scripts/*.mjs\" \"*.md\" \"*.json\"",
"format": "prettier --write \"astro.config.mjs\" \"src/**/*.{astro,ts,md,css}\" \"scripts/*.mjs\" \"*.md\" \"*.json\"",
"build": "astro build",
"preview": "astro preview",
+ "audit:astro": "npm run build && node scripts/install-playwright-deps.mjs && node scripts/export-astro-audit.mjs",
+ "qa:astro-audit": "node scripts/install-playwright-deps.mjs && node scripts/export-astro-audit.mjs --fail-on-issues",
+ "qa:no-em-dashes": "node scripts/check-no-em-dashes.mjs",
"qa:links": "node scripts/check-links.mjs",
"qa:no-js": "node scripts/check-no-js.mjs",
"qa:overflow": "node scripts/install-playwright-deps.mjs && node scripts/check-overflow.mjs",
- "qa": "npm run typecheck && npm run lint && npm run build && npm run qa:links && npm run qa:no-js && npm run qa:overflow"
+ "qa:preview-cropping": "node scripts/install-playwright-deps.mjs && node scripts/check-preview-cropping.mjs",
+ "qa": "npm run typecheck && npm run lint && npm run qa:no-em-dashes && npm run build && npm run qa:astro-audit && npm run qa:links && npm run qa:no-js && npm run qa:overflow && npm run qa:preview-cropping"
},
"repository": {
"type": "git",
diff --git a/public/site.webmanifest b/public/site.webmanifest
index 4a2a981..2a4b86b 100644
--- a/public/site.webmanifest
+++ b/public/site.webmanifest
@@ -15,8 +15,8 @@
"purpose": "maskable"
}
],
- "theme_color": "#fbfaf7",
- "background_color": "#fbfaf7",
+ "theme_color": "#201f1d",
+ "background_color": "#201f1d",
"display": "standalone",
"start_url": "/",
"scope": "/"
diff --git a/screenshots/homepage-1440.png b/screenshots/homepage-1440.png
new file mode 100644
index 0000000..33e8708
Binary files /dev/null and b/screenshots/homepage-1440.png differ
diff --git a/screenshots/timeline-crop-right-origin.png b/screenshots/timeline-crop-right-origin.png
new file mode 100644
index 0000000..a1815f7
Binary files /dev/null and b/screenshots/timeline-crop-right-origin.png differ
diff --git a/screenshots/timeline-crop.png b/screenshots/timeline-crop.png
new file mode 100644
index 0000000..d354809
Binary files /dev/null and b/screenshots/timeline-crop.png differ
diff --git a/scripts/check-no-em-dashes.mjs b/scripts/check-no-em-dashes.mjs
new file mode 100644
index 0000000..edb1606
--- /dev/null
+++ b/scripts/check-no-em-dashes.mjs
@@ -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.');
diff --git a/scripts/check-no-js.mjs b/scripts/check-no-js.mjs
index 00cd759..a19fc72 100644
--- a/scripts/check-no-js.mjs
+++ b/scripts/check-no-js.mjs
@@ -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());
diff --git a/scripts/check-preview-cropping.mjs b/scripts/check-preview-cropping.mjs
new file mode 100644
index 0000000..7804e41
--- /dev/null
+++ b/scripts/check-preview-cropping.mjs
@@ -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 ',
+ });
+ 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.`
+);
diff --git a/scripts/export-astro-audit.mjs b/scripts/export-astro-audit.mjs
new file mode 100644
index 0000000..62c4113
--- /dev/null
+++ b/scripts/export-astro-audit.mjs
@@ -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(() => {});
+}
diff --git a/src/components/ArticleList.astro b/src/components/ArticleList.astro
index 791686e..d2d3f9e 100644
--- a/src/components/ArticleList.astro
+++ b/src/components/ArticleList.astro
@@ -8,19 +8,28 @@ interface Props {
posts: CollectionEntry<'posts'>[];
showYear?: boolean;
tagLimit?: number;
- // Opt-in: eagerly load the first thumbnail. Only set when the list is
- // reliably above the fold (home, tag pages). Lists below substantial
- // content (related, archives by year, 404) should leave this off.
+ timeline?: boolean;
+ // Opt-in: eagerly load thumbnails that are reliably above the fold. Lists
+ // below substantial content (related, about, 404) should leave this at zero.
eagerFirstThumbnail?: boolean;
+ eagerThumbnailCount?: number;
}
-const { posts, showYear = true, tagLimit = 3, eagerFirstThumbnail = false } = Astro.props;
+const {
+ posts,
+ showYear = true,
+ tagLimit = 3,
+ timeline = false,
+ eagerFirstThumbnail = false,
+ eagerThumbnailCount = eagerFirstThumbnail ? 1 : 0,
+} = Astro.props;
---
-