From b554e92e9fb5e33d1da2214c2f776ed2fa81394e Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Thu, 28 May 2026 16:20:12 +0100 Subject: [PATCH] Update content & design (#75) Reviewed-on: https://home.schmelczer.dev/git/git/andras/schmelczer-dev/pulls/75 --- .forgejo/workflows/deploy.yml | 9 +- README.md | 8 +- astro.config.mjs | 2 + package.json | 9 +- public/site.webmanifest | 4 +- scripts/check-no-em-dashes.mjs | 95 +++ scripts/check-no-js.mjs | 6 +- scripts/check-preview-cropping.mjs | 536 +++++++++++++ scripts/export-astro-audit.mjs | 484 ++++++++++++ src/components/ArticleList.astro | 23 +- src/components/EntryThumbnail.astro | 1 + src/components/Footer.astro | 24 +- src/components/Header.astro | 34 +- src/components/PostMediaFigure.astro | 1 + src/components/PostThumbnail.astro | 119 +++ src/components/ProjectList.astro | 20 +- src/content.config.ts | 86 +- src/content/posts/_assets/backup.png | Bin 0 -> 48041 bytes src/content/posts/_assets/fizika.jpg | Bin 0 -> 141347 bytes src/content/posts/_assets/frame.jpg | Bin 0 -> 3582085 bytes .../posts/_assets/perfect-postcode.jpg | Bin 0 -> 385956 bytes src/content/posts/_assets/vault-link.svg | 47 ++ .../posts/ad-astra-attiny85-game-engine.md | 55 +- src/content/posts/avoid-early-web-game.md | 8 +- .../posts/backup-container-btrfs-borg.md | 97 +++ .../posts/city-simulation-unity-traffic.md | 16 +- .../posts/declared-shared-simulation-code.md | 52 +- .../posts/fizika-erettsegi-practice-app.md | 33 + .../posts/fleeting-garden-webgpu-drawing.md | 93 +-- .../foreign-exchange-prediction-experiment.md | 20 +- src/content/posts/frame-eink-photo-display.md | 80 ++ .../graph-editor-javafx-simulation-input.md | 12 +- .../posts/greatai-ai-deployment-api.md | 58 +- .../posts/life-towers-immutable-tries.md | 46 +- .../posts/lights-synchronized-to-music.md | 16 +- .../posts/my-notes-android-markdown-app.md | 12 +- .../posts/nuclear-cooling-simulation.md | 48 +- .../perfect-postcode-rust-property-server.md | 122 +++ src/content/posts/photo-colour-grader.md | 14 +- src/content/posts/photo-site-generator.md | 12 +- src/content/posts/platform-game-c-sdl.md | 15 +- .../posts/reconcile-text-3-way-merge.md | 63 +- src/content/posts/sdf-2d-ray-tracing.md | 59 +- src/content/posts/vault-link-obsidian-sync.md | 106 +++ src/content/projects/_assets/backup.png | Bin 0 -> 48041 bytes src/content/projects/_assets/fizika.jpg | Bin 0 -> 141347 bytes src/content/projects/_assets/frame.jpg | Bin 0 -> 3582085 bytes .../projects/_assets/perfect-postcode.jpg | Bin 0 -> 385956 bytes src/content/projects/_assets/vault-link.svg | 47 ++ src/content/projects/ad-astra.md | 2 +- src/content/projects/avoid.md | 2 +- src/content/projects/backup-container.md | 17 + src/content/projects/city-simulation.md | 2 +- src/content/projects/colors.md | 2 +- src/content/projects/declared.md | 2 +- src/content/projects/fizika.md | 17 + src/content/projects/fleeting-garden.md | 4 +- src/content/projects/forex.md | 2 +- src/content/projects/frame.md | 24 + src/content/projects/great-ai.md | 2 +- src/content/projects/leds.md | 4 +- src/content/projects/my-notes.md | 4 +- src/content/projects/nuclear-editor.md | 2 +- src/content/projects/nuclear-simulation.md | 2 +- src/content/projects/perfect-postcode.md | 28 + src/content/projects/photos.md | 2 +- src/content/projects/platform-game.md | 2 +- src/content/projects/reconcile.md | 4 +- src/content/projects/sdf-2d.md | 4 +- src/content/projects/towers.md | 4 +- src/content/projects/vault-link.md | 29 + src/layouts/Base.astro | 4 +- src/layouts/Page.astro | 9 +- src/layouts/Post.astro | 24 +- src/lib/site.ts | 14 +- src/pages/about.astro | 142 +++- src/pages/articles/index.astro | 5 +- src/pages/index.astro | 16 +- src/pages/projects/index.astro | 4 +- src/pages/tags/[tag].astro | 2 +- src/pages/tags/index.astro | 2 +- src/scripts/theme-init.js | 5 +- src/styles/global.css | 738 +++++++++++++----- 83 files changed, 2995 insertions(+), 723 deletions(-) create mode 100644 scripts/check-no-em-dashes.mjs create mode 100644 scripts/check-preview-cropping.mjs create mode 100644 scripts/export-astro-audit.mjs create mode 100644 src/components/PostThumbnail.astro create mode 100644 src/content/posts/_assets/backup.png create mode 100644 src/content/posts/_assets/fizika.jpg create mode 100644 src/content/posts/_assets/frame.jpg create mode 100644 src/content/posts/_assets/perfect-postcode.jpg create mode 100644 src/content/posts/_assets/vault-link.svg create mode 100644 src/content/posts/backup-container-btrfs-borg.md create mode 100644 src/content/posts/fizika-erettsegi-practice-app.md create mode 100644 src/content/posts/frame-eink-photo-display.md create mode 100644 src/content/posts/perfect-postcode-rust-property-server.md create mode 100644 src/content/posts/vault-link-obsidian-sync.md create mode 100644 src/content/projects/_assets/backup.png create mode 100644 src/content/projects/_assets/fizika.jpg create mode 100644 src/content/projects/_assets/frame.jpg create mode 100644 src/content/projects/_assets/perfect-postcode.jpg create mode 100644 src/content/projects/_assets/vault-link.svg create mode 100644 src/content/projects/backup-container.md create mode 100644 src/content/projects/fizika.md create mode 100644 src/content/projects/frame.md create mode 100644 src/content/projects/perfect-postcode.md create mode 100644 src/content/projects/vault-link.md diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 07a7185..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 @@ -49,3 +49,10 @@ jobs: apt update && apt install -y rsync mkdir -p /pages rsync -a --delete dist/ /pages/schmelczer-dev + + - name: Copy build to staging pages mount + if: github.event_name == 'pull_request' + run: | + apt update && apt install -y rsync + mkdir -p /pages + rsync -a --delete dist/ /pages/schmelczer-dev-staging 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/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; --- -
    +
      { posts.map((post, index) => { const href = articlePath(post); + const eager = index < eagerThumbnailCount; return (
    1. @@ -43,8 +52,8 @@ const { posts, showYear = true, tagLimit = 3, eagerFirstThumbnail = false } = As widths={ARTICLE_THUMBNAIL.widths} sizes={ARTICLE_THUMBNAIL.sizes} ariaLabel={`Open article: ${post.data.title}`} - loading={eagerFirstThumbnail && index === 0 ? 'eager' : 'lazy'} - fetchpriority={eagerFirstThumbnail && index === 0 ? 'high' : undefined} + loading={eager ? 'eager' : 'lazy'} + fetchpriority={eager && index === 0 ? 'high' : undefined} />
    2. ); diff --git a/src/components/EntryThumbnail.astro b/src/components/EntryThumbnail.astro index 90eae9e..750de48 100644 --- a/src/components/EntryThumbnail.astro +++ b/src/components/EntryThumbnail.astro @@ -49,6 +49,7 @@ const isDecorativeLink = Boolean(href) && decorative; fallbackFormat="jpg" widths={widths} sizes={sizes} + quality="high" loading={loading} decoding="async" fetchpriority={fetchpriority} diff --git a/src/components/Footer.astro b/src/components/Footer.astro index 45f2c8f..8834892 100644 --- a/src/components/Footer.astro +++ b/src/components/Footer.astro @@ -1,27 +1,17 @@ --- -import { navItems, site } from '../lib/site'; +import { site } from '../lib/site'; const year = new Date().getFullYear(); - -// Footer shows all nav items except Home (which is implicit via the site title). -const footerNavItems = navItems.filter((item) => item.href !== '/'); ---