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/package.json b/package.json index 66171b0..8443e41 100644 --- a/package.json +++ b/package.json @@ -8,17 +8,19 @@ "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": "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" }, "repository": { "type": "git", 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/export-astro-audit.mjs b/scripts/export-astro-audit.mjs new file mode 100644 index 0000000..05a9701 --- /dev/null +++ b/scripts/export-astro-audit.mjs @@ -0,0 +1,476 @@ +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'; + +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.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/Footer.astro b/src/components/Footer.astro index 45f2c8f..0908daa 100644 --- a/src/components/Footer.astro +++ b/src/components/Footer.astro @@ -1,24 +1,10 @@ --- -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 !== '/'); ---