From db8d4597df1a92914f10bcf53e338ca3cc7aba38 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Mon, 25 May 2026 09:49:09 +0100 Subject: [PATCH] Maybe clean up --- .github/dependabot.yml | 11 -- README.md | 2 +- astro.config.mjs | 2 +- package-lock.json | 166 ++++------------ package.json | 7 +- scripts/check-overflow.mjs | 185 +++++++++++++++--- src/components/ArticleList.astro | 17 +- src/components/EntryThumbnail.astro | 7 +- src/components/Header.astro | 7 + src/components/ProjectList.astro | 15 +- src/content.config.ts | 51 +++-- src/content/posts/avoid-early-web-game.md | 2 +- .../posts/fleeting-garden-webgpu-drawing.md | 4 +- .../posts/reconcile-text-3-way-merge.md | 2 +- src/content/projects/ad-astra.md | 1 - src/content/projects/avoid.md | 3 +- src/content/projects/city-simulation.md | 1 - src/content/projects/colors.md | 1 - src/content/projects/declared.md | 1 - src/content/projects/fleeting-garden.md | 5 +- src/content/projects/forex.md | 1 - src/content/projects/great-ai.md | 1 - src/content/projects/leds.md | 1 - src/content/projects/my-notes.md | 1 - src/content/projects/nuclear-editor.md | 1 - src/content/projects/nuclear-simulation.md | 1 - src/content/projects/photos.md | 1 - src/content/projects/platform-game.md | 1 - src/content/projects/reconcile.md | 3 +- src/content/projects/sdf-2d.md | 1 - src/content/projects/towers.md | 1 - src/layouts/Base.astro | 141 ++++++------- src/layouts/Page.astro | 5 +- src/lib/site.ts | 7 - src/pages/articles/index.astro | 2 +- src/pages/rss.xml.ts | 16 +- src/pages/tags/[tag].astro | 2 +- src/pages/tags/index.astro | 4 +- src/scripts/theme-init.js | 12 ++ src/styles/global.css | 44 ++++- 40 files changed, 404 insertions(+), 332 deletions(-) delete mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index c219e9b..0000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,11 +0,0 @@ -version: 2 -updates: - - package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "weekly" - - - package-ecosystem: "npm" - directory: "/" - schedule: - interval: "weekly" diff --git a/README.md b/README.md index ccb3b1f..26b3a2e 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ required client JavaScript. ## Setup ```sh -npm install +npm ci npx playwright install --with-deps chromium # required before `npm run qa:overflow` ``` diff --git a/astro.config.mjs b/astro.config.mjs index 5b6f436..10d5430 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -38,7 +38,7 @@ const postLastmodLookup = new Map( export default defineConfig({ site: 'https://schmelczer.dev', - trailingSlash: 'always', + trailingSlash: 'ignore', build: { inlineStylesheets: 'always' }, redirects: { '/writing/': '/articles/', diff --git a/package-lock.json b/package-lock.json index c5b1d84..359c598 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,9 @@ "rehype-autolink-headings": "^7.1.0", "rehype-slug": "^6.0.0", "typescript": "^5.9.3" + }, + "engines": { + "node": ">=22.13.0" } }, "node_modules/@astrojs/check": { @@ -1014,9 +1017,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1033,9 +1033,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1052,9 +1049,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1071,9 +1065,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1090,9 +1081,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1109,9 +1097,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1128,9 +1113,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1147,9 +1129,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1166,9 +1145,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1191,9 +1167,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1216,9 +1189,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1241,9 +1211,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1266,9 +1233,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1291,9 +1255,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1316,9 +1277,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1341,9 +1299,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1590,9 +1545,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1607,9 +1559,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1624,9 +1573,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1641,9 +1587,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1658,9 +1601,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1675,9 +1615,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1692,9 +1629,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1709,9 +1643,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1726,9 +1657,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1743,9 +1671,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1760,9 +1685,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1777,9 +1699,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1794,9 +1713,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2217,6 +2133,19 @@ "node": ">= 8" } }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/arg": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", @@ -2968,9 +2897,9 @@ } }, "node_modules/devalue": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.0.tgz", - "integrity": "sha512-2zA9pFEsnp7vWBZbXF5JAgAq0fsUIt/1XPbRiAmRV3lp/2C3upzH+sADiyy66aFCihoLEsrQHxNM5w1gIDfsBg==", + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", + "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", "dev": true, "license": "MIT" }, @@ -4798,18 +4727,6 @@ "dev": true, "license": "ISC" }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/playwright": { "version": "1.59.1", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", @@ -6434,19 +6351,6 @@ "dev": true, "license": "MIT" }, - "node_modules/yaml-language-server/node_modules/yaml": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.1.tgz", - "integrity": "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==", - "dev": true, - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", @@ -7692,7 +7596,15 @@ "dev": true, "requires": { "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" + "picomatch": "2.3.2" + }, + "dependencies": { + "picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true + } } }, "arg": { @@ -7739,7 +7651,7 @@ "clsx": "^2.1.1", "common-ancestor-path": "^2.0.0", "cookie": "^1.1.1", - "devalue": "^5.6.3", + "devalue": "5.8.1", "diff": "^8.0.3", "dset": "^3.1.4", "es-module-lexer": "^2.0.0", @@ -8143,9 +8055,9 @@ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==" }, "devalue": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.0.tgz", - "integrity": "sha512-2zA9pFEsnp7vWBZbXF5JAgAq0fsUIt/1XPbRiAmRV3lp/2C3upzH+sADiyy66aFCihoLEsrQHxNM5w1gIDfsBg==", + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", + "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", "dev": true }, "devlop": { @@ -9340,12 +9252,6 @@ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "dev": true }, - "picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true - }, "playwright": { "version": "1.59.1", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", @@ -10349,7 +10255,7 @@ "vscode-languageserver-textdocument": "^1.0.1", "vscode-languageserver-types": "^3.16.0", "vscode-uri": "^3.0.2", - "yaml": "2.7.1" + "yaml": "2.8.4" }, "dependencies": { "ajv": { @@ -10382,12 +10288,6 @@ "resolved": "https://registry.npmjs.org/request-light/-/request-light-0.5.8.tgz", "integrity": "sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg==", "dev": true - }, - "yaml": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.1.tgz", - "integrity": "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==", - "dev": true } } }, diff --git a/package.json b/package.json index 1f56552..f33781d 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,10 @@ "description": "A static personal blog for Andras Schmelczer.", "private": true, "type": "module", + "packageManager": "npm@10.9.2", + "engines": { + "node": ">=22.13.0" + }, "scripts": { "dev": "astro dev", "start": "astro dev", @@ -11,9 +15,10 @@ "format": "prettier --write \"astro.config.mjs\" \"src/**/*.{astro,ts,md,css}\" \"scripts/*.mjs\" \"*.md\" \"*.json\"", "build": "astro build", "preview": "astro preview", + "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:no-js && npm run qa:overflow" + "qa": "npm run typecheck && npm run lint && npm run build && npm run qa:links && npm run qa:no-js && npm run qa:overflow" }, "repository": { "type": "git", diff --git a/scripts/check-overflow.mjs b/scripts/check-overflow.mjs index f867e8d..aae620e 100644 --- a/scripts/check-overflow.mjs +++ b/scripts/check-overflow.mjs @@ -5,10 +5,15 @@ import { chromium } from 'playwright'; const dist = path.resolve('dist'); const INDEX_FILE = 'index.html'; -const MAX_NAV_RETRIES = 3; +const MAX_NAV_RETRIES = 4; // 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 CLOSE_TIMEOUT_MS = 3000; +const LAUNCH_TIMEOUT_MS = 10000; +const CONTEXT_TIMEOUT_MS = 8000; +const PAGE_TIMEOUT_MS = 15000; +const MEASURE_TIMEOUT_MS = 25000; const MIME = { '.html': 'text/html; charset=utf-8', @@ -55,7 +60,7 @@ async function discoverRoutes() { const rel = path.relative(dist, file).replaceAll(path.sep, '/'); if (rel === '404.html') continue; // /writing/* are meta-refresh redirect stubs to /articles/*, not real - // pages โ€” measuring them would just remeasure /articles/. + // pages; measuring them would just remeasure /articles/. if (rel.startsWith('writing/')) continue; if (rel === INDEX_FILE) { routes.add('/'); @@ -115,50 +120,166 @@ const server = createServer(async (req, res) => { await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); const { port } = server.address(); -const browser = await chromium.launch({ headless: true }); const failures = []; -async function measureViewport(page) { - for (let attempt = 0; attempt < MAX_NAV_RETRIES; attempt += 1) { - try { - await page.waitForLoadState('load'); - return await page.evaluate(() => ({ - scrollWidth: document.documentElement.scrollWidth, - clientWidth: document.documentElement.clientWidth, - })); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - const isLast = attempt === MAX_NAV_RETRIES - 1; - if (isLast || !/Execution context was destroyed|navigation/i.test(message)) { - throw error; - } - await page.waitForLoadState('load').catch(() => {}); +function launchBrowser() { + return chromium.launch({ + headless: true, + 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: false, + }); + await context.route('**/*', (route) => { + const type = route.request().resourceType(); + if (['font', 'image', 'media'].includes(type)) { + 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 measureViewport(page) { + await page.waitForLoadState('load', { timeout: 5000 }).catch(() => {}); + return page.evaluate(() => ({ + scrollWidth: document.documentElement.scrollWidth, + clientWidth: document.documentElement.clientWidth, + })); +} + +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) { + 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, + }); + return measureViewport(page); + })(), + MEASURE_TIMEOUT_MS, + `Timed out while measuring ${route}` + ); + } finally { + if (page) await safeClosePage(page); } } try { for (const width of VIEWPORT_WIDTHS) { - const page = await browser.newPage({ - viewport: { width, height: 900 }, - javaScriptEnabled: false, - }); + let browser; + let context; + try { + browser = await openBrowser(); + context = await openMeasurementContext(browser, width); + for (const route of routes) { + let result; - for (const route of routes) { - await page.goto(`http://127.0.0.1:${port}${route}`, { waitUntil: 'load' }); - const result = await measureViewport(page); + for (let attempt = 0; attempt < MAX_NAV_RETRIES; attempt += 1) { + try { + result = await measureRoute(context, route); + 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); + } + } - if (result.scrollWidth > result.clientWidth + 1) { - failures.push( - `${route} overflows at ${width}px: ${result.scrollWidth}px > ${result.clientWidth}px` - ); + if (result.scrollWidth > result.clientWidth + 1) { + failures.push( + `${route} overflows at ${width}px: ${result.scrollWidth}px > ${result.clientWidth}px` + ); + } } + } finally { + if (context) await safeCloseContext(context); + if (browser) await safeCloseBrowser(browser); } - - await page.close(); } } finally { - await browser.close(); server.close(); } diff --git a/src/components/ArticleList.astro b/src/components/ArticleList.astro index 1467019..0c0a8f3 100644 --- a/src/components/ArticleList.astro +++ b/src/components/ArticleList.astro @@ -9,9 +9,19 @@ interface Props { showYear?: boolean; currentTag?: string; 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. + eagerFirstThumbnail?: boolean; } -const { posts, showYear = true, currentTag, tagLimit = 3 } = Astro.props; +const { + posts, + showYear = true, + currentTag, + tagLimit = 3, + eagerFirstThumbnail = false, +} = Astro.props; ---
    @@ -37,8 +47,9 @@ const { posts, showYear = true, currentTag, tagLimit = 3 } = Astro.props; class="article-thumbnail" widths={ARTICLE_THUMBNAIL.widths} sizes={ARTICLE_THUMBNAIL.sizes} - loading={index === 0 ? 'eager' : 'lazy'} - fetchpriority={index === 0 ? 'high' : undefined} + ariaLabel={`Open article: ${post.data.title}`} + loading={eagerFirstThumbnail && index === 0 ? 'eager' : 'lazy'} + fetchpriority={eagerFirstThumbnail && index === 0 ? 'high' : undefined} /> ); diff --git a/src/components/EntryThumbnail.astro b/src/components/EntryThumbnail.astro index 4b9a51a..90eae9e 100644 --- a/src/components/EntryThumbnail.astro +++ b/src/components/EntryThumbnail.astro @@ -11,10 +11,11 @@ interface Props { sizes: string; loading?: 'lazy' | 'eager'; fetchpriority?: 'high' | 'low' | 'auto'; + ariaLabel?: string; // When the listing already has a focusable, screen-reader-visible title // link, the thumbnail link is visually duplicative. We keep it clickable - // for pointer users but drop it from the tab order and announce no alt - // text, so assistive tech doesn't read the same target twice. + // for pointer users but drop it from the tab order. The link still needs + // a name because some assistive tech exposes non-tabbable links. decorative?: boolean; } @@ -27,6 +28,7 @@ const { sizes, loading = 'lazy', fetchpriority, + ariaLabel, decorative = true, } = Astro.props; @@ -38,6 +40,7 @@ const isDecorativeLink = Boolean(href) && decorative; class:list={['entry-thumbnail', extraClass]} href={href} tabindex={isDecorativeLink ? -1 : undefined} + aria-label={isDecorativeLink ? (ariaLabel ?? alt) : undefined} > item.href !== '/' && !item.foot var switcher = document.getElementById('theme-switcher'); if (!switcher) return; + // Keep in sync with --color-bg in global.css and theme-init.js. + var THEME_BG = { light: '#fbfaf7', dark: '#151514' }; + var themeColorMetas = document.querySelectorAll('meta[name="theme-color"]'); + function sync(theme) { switcher.setAttribute('aria-pressed', String(theme === 'dark')); switcher.setAttribute( 'aria-label', theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme' ); + for (var i = 0; i < themeColorMetas.length; i += 1) { + themeColorMetas[i].setAttribute('content', THEME_BG[theme]); + } } sync(root.dataset.theme === 'dark' ? 'dark' : 'light'); diff --git a/src/components/ProjectList.astro b/src/components/ProjectList.astro index cf287c9..f738c60 100644 --- a/src/components/ProjectList.astro +++ b/src/components/ProjectList.astro @@ -3,13 +3,17 @@ import type { CollectionEntry } from 'astro:content'; import { getEntry } from 'astro:content'; import EntryThumbnail from './EntryThumbnail.astro'; import ProjectLinks from './ProjectLinks.astro'; -import { PROJECT_THUMBNAIL, articlePath, projectAnchor } from '../lib/site'; +import { PROJECT_THUMBNAIL, articlePath, entrySlug } from '../lib/site'; interface Props { projects: CollectionEntry<'projects'>[]; + // Opt-in: eagerly load the first thumbnail. Only set when the list is + // reliably above the fold. The home and projects-index lists sit below + // other sections, so leave this off there. + eagerFirstThumbnail?: boolean; } -const { projects } = Astro.props; +const { projects, eagerFirstThumbnail = false } = Astro.props; // The `essay` field is a `reference('posts')`, so when present it's always a // `{ collection, id }` shape that `getEntry` resolves to a CollectionEntry. @@ -25,7 +29,7 @@ for (const project of projects) {
      { projects.map((project, index) => { - const anchor = projectAnchor(project); + const anchor = entrySlug(project); const titleId = `${anchor}-title`; const essayHref = essayHrefs.get(project.id); const primaryHref = essayHref ?? project.data.links[0]?.url; @@ -39,8 +43,9 @@ for (const project of projects) { class="project-thumbnail" widths={PROJECT_THUMBNAIL.widths} sizes={PROJECT_THUMBNAIL.sizes} - loading={index === 0 ? 'eager' : 'lazy'} - fetchpriority={index === 0 ? 'high' : undefined} + ariaLabel={`Open project: ${project.data.title}`} + loading={eagerFirstThumbnail && index === 0 ? 'eager' : 'lazy'} + fetchpriority={eagerFirstThumbnail && index === 0 ? 'high' : undefined} />

      diff --git a/src/content.config.ts b/src/content.config.ts index 1e68fd3..1ddedb6 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -3,9 +3,22 @@ import type { SchemaContext } from 'astro:content'; import { glob } from 'astro/loaders'; import { z } from 'astro/zod'; +const safeUrl = z.string().refine( + (url) => { + if (url.startsWith('/')) return !url.startsWith('//'); + try { + const parsed = new URL(url); + return ['http:', 'https:', 'mailto:'].includes(parsed.protocol); + } catch { + return false; + } + }, + { message: 'URL must be an absolute http(s)/mailto URL or a root-relative path.' } +); + const linkSchema = z.object({ label: z.string(), - url: z.string(), + url: safeUrl, download: z.boolean().optional(), }); @@ -17,17 +30,30 @@ const thumbnailSchema = ({ image }: SchemaContext) => const mediaSchema = ({ image }: SchemaContext) => z - .object({ - type: z.enum(['image', 'video', 'diagram']), - src: image().optional(), - poster: image().optional(), - mp4: z.string().optional(), - webm: z.string().optional(), - alt: z.string().optional(), - decorative: z.boolean().optional(), - caption: z.string().optional(), - transcript: z.string().optional(), - }) + .discriminatedUnion('type', [ + z.object({ + type: z.enum(['image', 'diagram']), + src: image(), + alt: z.string().optional(), + decorative: z.boolean().optional(), + caption: z.string().optional(), + transcript: z.string().optional(), + }), + z + .object({ + type: z.literal('video'), + poster: image().optional(), + mp4: safeUrl.optional(), + webm: safeUrl.optional(), + alt: z.string().optional(), + decorative: z.boolean().optional(), + caption: z.string().optional(), + transcript: z.string().optional(), + }) + .refine((item) => Boolean(item.mp4) || Boolean(item.webm), { + message: 'Video media needs at least one mp4 or webm source.', + }), + ]) .refine((item) => item.decorative || (Boolean(item.alt) && Boolean(item.caption)), { message: 'Meaningful media needs both alt text and a caption.', }); @@ -72,7 +98,6 @@ const projects = defineCollection({ loader: glob({ pattern: '**/*.md', base: './src/content/projects' }), schema: ({ image }) => z.object({ - sourceProjectId: z.string(), title: z.string(), description: z.string().max(160), thumbnail: thumbnailSchema({ image }), diff --git a/src/content/posts/avoid-early-web-game.md b/src/content/posts/avoid-early-web-game.md index b411f5f..b69fcef 100644 --- a/src/content/posts/avoid-early-web-game.md +++ b/src/content/posts/avoid-early-web-game.md @@ -13,7 +13,7 @@ outcome: A small playable web game kept as an archive of early browser work audience: general links: - label: Demo - url: https://schmelczer.dev/avoid + url: /avoid/ --- I recently found my first web game. It is very simple, but I killed some time with it โ€” feel free to try it out, and do not judge too harshly. diff --git a/src/content/posts/fleeting-garden-webgpu-drawing.md b/src/content/posts/fleeting-garden-webgpu-drawing.md index aa9c858..3685455 100644 --- a/src/content/posts/fleeting-garden-webgpu-drawing.md +++ b/src/content/posts/fleeting-garden-webgpu-drawing.md @@ -14,9 +14,9 @@ outcome: A browser drawing toy where user input seeds an agent simulation that r audience: technical links: - label: Demo - url: https://schmelczer.dev/fleeting/ + url: /fleeting/ - label: Source - url: https://github.com/schmelczer/webgpu + url: https://home.schmelczer.dev/git/andras/webgpu media: - type: image src: ./_assets/fleeting-garden.jpg diff --git a/src/content/posts/reconcile-text-3-way-merge.md b/src/content/posts/reconcile-text-3-way-merge.md index 2652e38..0645ebe 100644 --- a/src/content/posts/reconcile-text-3-way-merge.md +++ b/src/content/posts/reconcile-text-3-way-merge.md @@ -15,7 +15,7 @@ outcome: A small, well-tested library that fills a gap between git, CRDTs, and p audience: recruiter-relevant links: - label: Demo - url: https://schmelczer.dev/reconcile + url: /reconcile/ - label: Source url: https://github.com/schmelczer/reconcile - label: crates.io diff --git a/src/content/projects/ad-astra.md b/src/content/projects/ad-astra.md index d488e22..6b30932 100644 --- a/src/content/projects/ad-astra.md +++ b/src/content/projects/ad-astra.md @@ -1,5 +1,4 @@ --- -sourceProjectId: ad-astra title: Ad Astra description: A tiny embedded game engine and custom PCB built around an ATtiny85V. thumbnail: diff --git a/src/content/projects/avoid.md b/src/content/projects/avoid.md index 46193de..0963c02 100644 --- a/src/content/projects/avoid.md +++ b/src/content/projects/avoid.md @@ -1,5 +1,4 @@ --- -sourceProjectId: avoid title: Avoid description: A small early web game, kept as an archive of first experiments on the web. thumbnail: @@ -12,5 +11,5 @@ selected: false essay: avoid-early-web-game links: - label: Demo - url: https://schmelczer.dev/avoid + url: /avoid/ --- diff --git a/src/content/projects/city-simulation.md b/src/content/projects/city-simulation.md index 07fbea3..40b4fb8 100644 --- a/src/content/projects/city-simulation.md +++ b/src/content/projects/city-simulation.md @@ -1,5 +1,4 @@ --- -sourceProjectId: city-simulation title: City Simulation description: A Unity traffic simulation where REST-controlled traffic lights could produce visible consequences for a cybersecurity challenge. thumbnail: diff --git a/src/content/projects/colors.md b/src/content/projects/colors.md index 3121e3c..2d51055 100644 --- a/src/content/projects/colors.md +++ b/src/content/projects/colors.md @@ -1,5 +1,4 @@ --- -sourceProjectId: colors title: Photo Colour Grader description: A proof-of-concept colour grading UI based on selecting colours and transforming nearby colour ranges. thumbnail: diff --git a/src/content/projects/declared.md b/src/content/projects/declared.md index 36f7893..afa9297 100644 --- a/src/content/projects/declared.md +++ b/src/content/projects/declared.md @@ -1,5 +1,4 @@ --- -sourceProjectId: declared title: decla.red description: A team-based mobile multiplayer browser game with shared client/server game logic. thumbnail: diff --git a/src/content/projects/fleeting-garden.md b/src/content/projects/fleeting-garden.md index 9945dcc..8e2385d 100644 --- a/src/content/projects/fleeting-garden.md +++ b/src/content/projects/fleeting-garden.md @@ -1,5 +1,4 @@ --- -sourceProjectId: fleeting-garden title: Fleeting Garden description: A WebGPU drawing toy where coloured strokes spawn agents that follow them, branch off, and slowly rewrite the patch you laid down. thumbnail: @@ -12,7 +11,7 @@ selected: true essay: fleeting-garden-webgpu-drawing links: - label: Demo - url: https://schmelczer.dev/fleeting/ + url: /fleeting/ - label: Source - url: https://github.com/schmelczer/webgpu + url: https://home.schmelczer.dev/git/andras/webgpu --- diff --git a/src/content/projects/forex.md b/src/content/projects/forex.md index 0631203..672c7d1 100644 --- a/src/content/projects/forex.md +++ b/src/content/projects/forex.md @@ -1,5 +1,4 @@ --- -sourceProjectId: forex title: Foreign Exchange Prediction Experiment description: A frequency-domain prediction experiment using smoothing, differentiation, STFT, extrapolation, and inverse transforms. thumbnail: diff --git a/src/content/projects/great-ai.md b/src/content/projects/great-ai.md index 0720c71..7d07a1c 100644 --- a/src/content/projects/great-ai.md +++ b/src/content/projects/great-ai.md @@ -1,5 +1,4 @@ --- -sourceProjectId: great-ai title: GreatAI description: A Python framework and research project for making AI deployment best practices easier to adopt. thumbnail: diff --git a/src/content/projects/leds.md b/src/content/projects/leds.md index 045703e..28349c4 100644 --- a/src/content/projects/leds.md +++ b/src/content/projects/leds.md @@ -1,5 +1,4 @@ --- -sourceProjectId: leds title: Lights Synchronized to Music description: A Raspberry Pi music player that drove RGB LED strips from audio analysis. thumbnail: diff --git a/src/content/projects/my-notes.md b/src/content/projects/my-notes.md index 95dd1c2..5a08ad3 100644 --- a/src/content/projects/my-notes.md +++ b/src/content/projects/my-notes.md @@ -1,5 +1,4 @@ --- -sourceProjectId: my-notes title: My Notes description: A minimalist Android markdown note organizer and editor powered by Markwon. thumbnail: diff --git a/src/content/projects/nuclear-editor.md b/src/content/projects/nuclear-editor.md index 0f886bf..6be1da3 100644 --- a/src/content/projects/nuclear-editor.md +++ b/src/content/projects/nuclear-editor.md @@ -1,5 +1,4 @@ --- -sourceProjectId: nuclear-editor title: Graph Editor description: A JavaFX editor for creating and editing input graphs for the cooling system simulator. thumbnail: diff --git a/src/content/projects/nuclear-simulation.md b/src/content/projects/nuclear-simulation.md index 2bec042..e34a72c 100644 --- a/src/content/projects/nuclear-simulation.md +++ b/src/content/projects/nuclear-simulation.md @@ -1,5 +1,4 @@ --- -sourceProjectId: nuclear-simulation title: Cooling System Simulation description: A graph-based process simulation with a monitoring client and JavaFX input editor. thumbnail: diff --git a/src/content/projects/photos.md b/src/content/projects/photos.md index da74af9..feb917f 100644 --- a/src/content/projects/photos.md +++ b/src/content/projects/photos.md @@ -1,5 +1,4 @@ --- -sourceProjectId: photos title: Photo Site Generator description: A static photo site generated from a directory of images, with automatic resizing to multiple quality settings. thumbnail: diff --git a/src/content/projects/platform-game.md b/src/content/projects/platform-game.md index 81dd071..e90790b 100644 --- a/src/content/projects/platform-game.md +++ b/src/content/projects/platform-game.md @@ -1,5 +1,4 @@ --- -sourceProjectId: platform-game title: Platform Game description: An early 3D game in C with SDL 1.2, random maps, destructible voxels, enemies, powerups, and time slowdown. thumbnail: diff --git a/src/content/projects/reconcile.md b/src/content/projects/reconcile.md index 33760c9..24fabae 100644 --- a/src/content/projects/reconcile.md +++ b/src/content/projects/reconcile.md @@ -1,5 +1,4 @@ --- -sourceProjectId: reconcile title: reconcile-text description: A Rust library that auto-resolves conflicting text edits without conflict markers, with WebAssembly and Python bindings. thumbnail: @@ -12,7 +11,7 @@ selected: true essay: reconcile-text-3-way-merge links: - label: Demo - url: https://schmelczer.dev/reconcile + url: /reconcile/ - label: Source url: https://github.com/schmelczer/reconcile - label: crates.io diff --git a/src/content/projects/sdf-2d.md b/src/content/projects/sdf-2d.md index b12a188..59890e6 100644 --- a/src/content/projects/sdf-2d.md +++ b/src/content/projects/sdf-2d.md @@ -1,5 +1,4 @@ --- -sourceProjectId: sdf-2d title: SDF-2D description: A browser rendering library for optimized 2D ray tracing with signed distance fields. thumbnail: diff --git a/src/content/projects/towers.md b/src/content/projects/towers.md index eea4e2b..57038d0 100644 --- a/src/content/projects/towers.md +++ b/src/content/projects/towers.md @@ -1,5 +1,4 @@ --- -sourceProjectId: towers title: Life Towers description: A multi-device goal tracker where the lasting idea was syncing state with immutable tries. thumbnail: diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro index 93ece4f..aac7dbd 100644 --- a/src/layouts/Base.astro +++ b/src/layouts/Base.astro @@ -30,7 +30,7 @@ interface Props { const { title = site.title, description = site.description, - canonicalPath = Astro.url.pathname, + canonicalPath: rawCanonicalPath = Astro.url.pathname, ogImage, ogImageAlt = "Andras Schmelczer's personal site", ogImageWidth, @@ -45,6 +45,12 @@ const { const isRoot = title === site.title; const pageTitle = isRoot ? site.title : `${title} ยท ${site.name}`; const ogTitle = isRoot ? site.title : title; +const canonicalPath = + rawCanonicalPath === '/' || + rawCanonicalPath.endsWith('/') || + /\.[^/]+$/.test(rawCanonicalPath) + ? rawCanonicalPath + : `${rawCanonicalPath}/`; const canonical = absoluteUrl(canonicalPath); let resolvedOgImage = ogImage; @@ -75,68 +81,13 @@ const ogImageType = ? 'image/svg+xml' : 'image/jpeg'; const jsonLdEntries = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : []; - -// Head meta tags built as a single HTML string so prettier-plugin-astro -// doesn't shuffle them outside `` when reformatting (it has trouble -// with mixed JSX-expression and raw element siblings inside ). -const attr = (value: string) => - value - .replace(/&/g, '&') - .replace(/"/g, '"') - .replace(//g, '>'); - -const articleMetaParts = article - ? [ - ``, - article.modifiedTime - ? `` - : '', - ``, - ...(article.tags ?? []).map( - (tag) => `` - ), - ] - : []; - -const monoPreloadHtml = preloadMono - ? '' - : ''; - -const headHtml = [ - monoPreloadHtml, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ...articleMetaParts, - ``, - ``, - ``, - ``, - ``, - ...jsonLdEntries.map( - (entry) => - `` - ), -].join(''); +const jsonLdStrings = jsonLdEntries.map((entry) => + JSON.stringify(entry).replace(/ - + - - + {noindex && } {!noindex && }