Maybe clean up

This commit is contained in:
Andras Schmelczer 2026-05-25 09:49:09 +01:00
parent 2165ed0c33
commit db8d4597df
40 changed files with 404 additions and 332 deletions

View file

@ -1,11 +0,0 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"

View file

@ -9,7 +9,7 @@ required client JavaScript.
## Setup ## Setup
```sh ```sh
npm install npm ci
npx playwright install --with-deps chromium # required before `npm run qa:overflow` npx playwright install --with-deps chromium # required before `npm run qa:overflow`
``` ```

View file

@ -38,7 +38,7 @@ const postLastmodLookup = new Map(
export default defineConfig({ export default defineConfig({
site: 'https://schmelczer.dev', site: 'https://schmelczer.dev',
trailingSlash: 'always', trailingSlash: 'ignore',
build: { inlineStylesheets: 'always' }, build: { inlineStylesheets: 'always' },
redirects: { redirects: {
'/writing/': '/articles/', '/writing/': '/articles/',

166
package-lock.json generated
View file

@ -20,6 +20,9 @@
"rehype-autolink-headings": "^7.1.0", "rehype-autolink-headings": "^7.1.0",
"rehype-slug": "^6.0.0", "rehype-slug": "^6.0.0",
"typescript": "^5.9.3" "typescript": "^5.9.3"
},
"engines": {
"node": ">=22.13.0"
} }
}, },
"node_modules/@astrojs/check": { "node_modules/@astrojs/check": {
@ -1014,9 +1017,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@ -1033,9 +1033,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@ -1052,9 +1049,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@ -1071,9 +1065,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@ -1090,9 +1081,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@ -1109,9 +1097,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@ -1128,9 +1113,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@ -1147,9 +1129,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@ -1166,9 +1145,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -1191,9 +1167,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -1216,9 +1189,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -1241,9 +1211,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -1266,9 +1233,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -1291,9 +1255,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -1316,9 +1277,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"musl"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -1341,9 +1299,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"musl"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -1590,9 +1545,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -1607,9 +1559,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -1624,9 +1573,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -1641,9 +1587,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -1658,9 +1601,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -1675,9 +1615,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -1692,9 +1629,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -1709,9 +1643,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -1726,9 +1657,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -1743,9 +1671,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -1760,9 +1685,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -1777,9 +1699,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -1794,9 +1713,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -2217,6 +2133,19 @@
"node": ">= 8" "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": { "node_modules/arg": {
"version": "5.0.2", "version": "5.0.2",
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
@ -2968,9 +2897,9 @@
} }
}, },
"node_modules/devalue": { "node_modules/devalue": {
"version": "5.8.0", "version": "5.8.1",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.0.tgz", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz",
"integrity": "sha512-2zA9pFEsnp7vWBZbXF5JAgAq0fsUIt/1XPbRiAmRV3lp/2C3upzH+sADiyy66aFCihoLEsrQHxNM5w1gIDfsBg==", "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@ -4798,18 +4727,6 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/playwright": {
"version": "1.59.1", "version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
@ -6434,19 +6351,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/yargs": {
"version": "17.7.2", "version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
@ -7692,7 +7596,15 @@
"dev": true, "dev": true,
"requires": { "requires": {
"normalize-path": "^3.0.0", "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": { "arg": {
@ -7739,7 +7651,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"common-ancestor-path": "^2.0.0", "common-ancestor-path": "^2.0.0",
"cookie": "^1.1.1", "cookie": "^1.1.1",
"devalue": "^5.6.3", "devalue": "5.8.1",
"diff": "^8.0.3", "diff": "^8.0.3",
"dset": "^3.1.4", "dset": "^3.1.4",
"es-module-lexer": "^2.0.0", "es-module-lexer": "^2.0.0",
@ -8143,9 +8055,9 @@
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==" "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="
}, },
"devalue": { "devalue": {
"version": "5.8.0", "version": "5.8.1",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.0.tgz", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz",
"integrity": "sha512-2zA9pFEsnp7vWBZbXF5JAgAq0fsUIt/1XPbRiAmRV3lp/2C3upzH+sADiyy66aFCihoLEsrQHxNM5w1gIDfsBg==", "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==",
"dev": true "dev": true
}, },
"devlop": { "devlop": {
@ -9340,12 +9252,6 @@
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true "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": { "playwright": {
"version": "1.59.1", "version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
@ -10349,7 +10255,7 @@
"vscode-languageserver-textdocument": "^1.0.1", "vscode-languageserver-textdocument": "^1.0.1",
"vscode-languageserver-types": "^3.16.0", "vscode-languageserver-types": "^3.16.0",
"vscode-uri": "^3.0.2", "vscode-uri": "^3.0.2",
"yaml": "2.7.1" "yaml": "2.8.4"
}, },
"dependencies": { "dependencies": {
"ajv": { "ajv": {
@ -10382,12 +10288,6 @@
"resolved": "https://registry.npmjs.org/request-light/-/request-light-0.5.8.tgz", "resolved": "https://registry.npmjs.org/request-light/-/request-light-0.5.8.tgz",
"integrity": "sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg==", "integrity": "sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg==",
"dev": true "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
} }
} }
}, },

View file

@ -3,6 +3,10 @@
"description": "A static personal blog for Andras Schmelczer.", "description": "A static personal blog for Andras Schmelczer.",
"private": true, "private": true,
"type": "module", "type": "module",
"packageManager": "npm@10.9.2",
"engines": {
"node": ">=22.13.0"
},
"scripts": { "scripts": {
"dev": "astro dev", "dev": "astro dev",
"start": "astro dev", "start": "astro dev",
@ -11,9 +15,10 @@
"format": "prettier --write \"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", "build": "astro build",
"preview": "astro preview", "preview": "astro preview",
"qa:links": "node scripts/check-links.mjs",
"qa:no-js": "node scripts/check-no-js.mjs", "qa:no-js": "node scripts/check-no-js.mjs",
"qa:overflow": "node scripts/install-playwright-deps.mjs && node scripts/check-overflow.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": { "repository": {
"type": "git", "type": "git",

View file

@ -5,10 +5,15 @@ import { chromium } from 'playwright';
const dist = path.resolve('dist'); const dist = path.resolve('dist');
const INDEX_FILE = 'index.html'; 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 / // Common device widths: iPhone SE / Galaxy S / iPhone 14 / iPad portrait /
// iPad landscape / common laptop / full HD desktop. // iPad landscape / common laptop / full HD desktop.
const VIEWPORT_WIDTHS = [320, 390, 430, 768, 1024, 1440, 1920]; 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 = { const MIME = {
'.html': 'text/html; charset=utf-8', '.html': 'text/html; charset=utf-8',
@ -55,7 +60,7 @@ async function discoverRoutes() {
const rel = path.relative(dist, file).replaceAll(path.sep, '/'); const rel = path.relative(dist, file).replaceAll(path.sep, '/');
if (rel === '404.html') continue; if (rel === '404.html') continue;
// /writing/* are meta-refresh redirect stubs to /articles/*, not real // /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.startsWith('writing/')) continue;
if (rel === INDEX_FILE) { if (rel === INDEX_FILE) {
routes.add('/'); routes.add('/');
@ -115,50 +120,166 @@ const server = createServer(async (req, res) => {
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address(); const { port } = server.address();
const browser = await chromium.launch({ headless: true });
const failures = []; const failures = [];
async function measureViewport(page) { function launchBrowser() {
for (let attempt = 0; attempt < MAX_NAV_RETRIES; attempt += 1) { return chromium.launch({
try { headless: true,
await page.waitForLoadState('load'); args: ['--disable-dev-shm-usage', '--disable-gpu', '--no-sandbox'],
return await page.evaluate(() => ({ });
scrollWidth: document.documentElement.scrollWidth, }
clientWidth: document.documentElement.clientWidth,
})); async function withTimeout(promise, timeoutMs, label) {
} catch (error) { let timeout;
const message = error instanceof Error ? error.message : String(error); try {
const isLast = attempt === MAX_NAV_RETRIES - 1; return await Promise.race([
if (isLast || !/Execution context was destroyed|navigation/i.test(message)) { promise,
throw error; new Promise((_, reject) => {
} timeout = setTimeout(() => reject(new Error(label)), timeoutMs);
await page.waitForLoadState('load').catch(() => {}); }),
]);
} 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 { try {
for (const width of VIEWPORT_WIDTHS) { for (const width of VIEWPORT_WIDTHS) {
const page = await browser.newPage({ let browser;
viewport: { width, height: 900 }, let context;
javaScriptEnabled: false, try {
}); browser = await openBrowser();
context = await openMeasurementContext(browser, width);
for (const route of routes) {
let result;
for (const route of routes) { for (let attempt = 0; attempt < MAX_NAV_RETRIES; attempt += 1) {
await page.goto(`http://127.0.0.1:${port}${route}`, { waitUntil: 'load' }); try {
const result = await measureViewport(page); 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) { if (result.scrollWidth > result.clientWidth + 1) {
failures.push( failures.push(
`${route} overflows at ${width}px: ${result.scrollWidth}px > ${result.clientWidth}px` `${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 { } finally {
await browser.close();
server.close(); server.close();
} }

View file

@ -9,9 +9,19 @@ interface Props {
showYear?: boolean; showYear?: boolean;
currentTag?: string; currentTag?: string;
tagLimit?: number; 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;
--- ---
<ol class="article-list"> <ol class="article-list">
@ -37,8 +47,9 @@ const { posts, showYear = true, currentTag, tagLimit = 3 } = Astro.props;
class="article-thumbnail" class="article-thumbnail"
widths={ARTICLE_THUMBNAIL.widths} widths={ARTICLE_THUMBNAIL.widths}
sizes={ARTICLE_THUMBNAIL.sizes} sizes={ARTICLE_THUMBNAIL.sizes}
loading={index === 0 ? 'eager' : 'lazy'} ariaLabel={`Open article: ${post.data.title}`}
fetchpriority={index === 0 ? 'high' : undefined} loading={eagerFirstThumbnail && index === 0 ? 'eager' : 'lazy'}
fetchpriority={eagerFirstThumbnail && index === 0 ? 'high' : undefined}
/> />
</li> </li>
); );

View file

@ -11,10 +11,11 @@ interface Props {
sizes: string; sizes: string;
loading?: 'lazy' | 'eager'; loading?: 'lazy' | 'eager';
fetchpriority?: 'high' | 'low' | 'auto'; fetchpriority?: 'high' | 'low' | 'auto';
ariaLabel?: string;
// When the listing already has a focusable, screen-reader-visible title // When the listing already has a focusable, screen-reader-visible title
// link, the thumbnail link is visually duplicative. We keep it clickable // 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 // for pointer users but drop it from the tab order. The link still needs
// text, so assistive tech doesn't read the same target twice. // a name because some assistive tech exposes non-tabbable links.
decorative?: boolean; decorative?: boolean;
} }
@ -27,6 +28,7 @@ const {
sizes, sizes,
loading = 'lazy', loading = 'lazy',
fetchpriority, fetchpriority,
ariaLabel,
decorative = true, decorative = true,
} = Astro.props; } = Astro.props;
@ -38,6 +40,7 @@ const isDecorativeLink = Boolean(href) && decorative;
class:list={['entry-thumbnail', extraClass]} class:list={['entry-thumbnail', extraClass]}
href={href} href={href}
tabindex={isDecorativeLink ? -1 : undefined} tabindex={isDecorativeLink ? -1 : undefined}
aria-label={isDecorativeLink ? (ariaLabel ?? alt) : undefined}
> >
<Picture <Picture
src={src} src={src}

View file

@ -61,12 +61,19 @@ const headerNavItems = navItems.filter((item) => item.href !== '/' && !item.foot
var switcher = document.getElementById('theme-switcher'); var switcher = document.getElementById('theme-switcher');
if (!switcher) return; 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) { function sync(theme) {
switcher.setAttribute('aria-pressed', String(theme === 'dark')); switcher.setAttribute('aria-pressed', String(theme === 'dark'));
switcher.setAttribute( switcher.setAttribute(
'aria-label', 'aria-label',
theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme' 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'); sync(root.dataset.theme === 'dark' ? 'dark' : 'light');

View file

@ -3,13 +3,17 @@ import type { CollectionEntry } from 'astro:content';
import { getEntry } from 'astro:content'; import { getEntry } from 'astro:content';
import EntryThumbnail from './EntryThumbnail.astro'; import EntryThumbnail from './EntryThumbnail.astro';
import ProjectLinks from './ProjectLinks.astro'; import ProjectLinks from './ProjectLinks.astro';
import { PROJECT_THUMBNAIL, articlePath, projectAnchor } from '../lib/site'; import { PROJECT_THUMBNAIL, articlePath, entrySlug } from '../lib/site';
interface Props { interface Props {
projects: CollectionEntry<'projects'>[]; 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 // The `essay` field is a `reference('posts')`, so when present it's always a
// `{ collection, id }` shape that `getEntry` resolves to a CollectionEntry. // `{ collection, id }` shape that `getEntry` resolves to a CollectionEntry.
@ -25,7 +29,7 @@ for (const project of projects) {
<ol class="project-list"> <ol class="project-list">
{ {
projects.map((project, index) => { projects.map((project, index) => {
const anchor = projectAnchor(project); const anchor = entrySlug(project);
const titleId = `${anchor}-title`; const titleId = `${anchor}-title`;
const essayHref = essayHrefs.get(project.id); const essayHref = essayHrefs.get(project.id);
const primaryHref = essayHref ?? project.data.links[0]?.url; const primaryHref = essayHref ?? project.data.links[0]?.url;
@ -39,8 +43,9 @@ for (const project of projects) {
class="project-thumbnail" class="project-thumbnail"
widths={PROJECT_THUMBNAIL.widths} widths={PROJECT_THUMBNAIL.widths}
sizes={PROJECT_THUMBNAIL.sizes} sizes={PROJECT_THUMBNAIL.sizes}
loading={index === 0 ? 'eager' : 'lazy'} ariaLabel={`Open project: ${project.data.title}`}
fetchpriority={index === 0 ? 'high' : undefined} loading={eagerFirstThumbnail && index === 0 ? 'eager' : 'lazy'}
fetchpriority={eagerFirstThumbnail && index === 0 ? 'high' : undefined}
/> />
<article class="project-card__summary"> <article class="project-card__summary">
<h3 id={titleId}> <h3 id={titleId}>

View file

@ -3,9 +3,22 @@ import type { SchemaContext } from 'astro:content';
import { glob } from 'astro/loaders'; import { glob } from 'astro/loaders';
import { z } from 'astro/zod'; 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({ const linkSchema = z.object({
label: z.string(), label: z.string(),
url: z.string(), url: safeUrl,
download: z.boolean().optional(), download: z.boolean().optional(),
}); });
@ -17,17 +30,30 @@ const thumbnailSchema = ({ image }: SchemaContext) =>
const mediaSchema = ({ image }: SchemaContext) => const mediaSchema = ({ image }: SchemaContext) =>
z z
.object({ .discriminatedUnion('type', [
type: z.enum(['image', 'video', 'diagram']), z.object({
src: image().optional(), type: z.enum(['image', 'diagram']),
poster: image().optional(), src: image(),
mp4: z.string().optional(), alt: z.string().optional(),
webm: z.string().optional(), decorative: z.boolean().optional(),
alt: z.string().optional(), caption: z.string().optional(),
decorative: z.boolean().optional(), transcript: z.string().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)), { .refine((item) => item.decorative || (Boolean(item.alt) && Boolean(item.caption)), {
message: 'Meaningful media needs both alt text and a 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' }), loader: glob({ pattern: '**/*.md', base: './src/content/projects' }),
schema: ({ image }) => schema: ({ image }) =>
z.object({ z.object({
sourceProjectId: z.string(),
title: z.string(), title: z.string(),
description: z.string().max(160), description: z.string().max(160),
thumbnail: thumbnailSchema({ image }), thumbnail: thumbnailSchema({ image }),

View file

@ -13,7 +13,7 @@ outcome: A small playable web game kept as an archive of early browser work
audience: general audience: general
links: links:
- label: Demo - 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. 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.

View file

@ -14,9 +14,9 @@ outcome: A browser drawing toy where user input seeds an agent simulation that r
audience: technical audience: technical
links: links:
- label: Demo - label: Demo
url: https://schmelczer.dev/fleeting/ url: /fleeting/
- label: Source - label: Source
url: https://github.com/schmelczer/webgpu url: https://home.schmelczer.dev/git/andras/webgpu
media: media:
- type: image - type: image
src: ./_assets/fleeting-garden.jpg src: ./_assets/fleeting-garden.jpg

View file

@ -15,7 +15,7 @@ outcome: A small, well-tested library that fills a gap between git, CRDTs, and p
audience: recruiter-relevant audience: recruiter-relevant
links: links:
- label: Demo - label: Demo
url: https://schmelczer.dev/reconcile url: /reconcile/
- label: Source - label: Source
url: https://github.com/schmelczer/reconcile url: https://github.com/schmelczer/reconcile
- label: crates.io - label: crates.io

View file

@ -1,5 +1,4 @@
--- ---
sourceProjectId: ad-astra
title: Ad Astra title: Ad Astra
description: A tiny embedded game engine and custom PCB built around an ATtiny85V. description: A tiny embedded game engine and custom PCB built around an ATtiny85V.
thumbnail: thumbnail:

View file

@ -1,5 +1,4 @@
--- ---
sourceProjectId: avoid
title: Avoid title: Avoid
description: A small early web game, kept as an archive of first experiments on the web. description: A small early web game, kept as an archive of first experiments on the web.
thumbnail: thumbnail:
@ -12,5 +11,5 @@ selected: false
essay: avoid-early-web-game essay: avoid-early-web-game
links: links:
- label: Demo - label: Demo
url: https://schmelczer.dev/avoid url: /avoid/
--- ---

View file

@ -1,5 +1,4 @@
--- ---
sourceProjectId: city-simulation
title: City Simulation title: City Simulation
description: A Unity traffic simulation where REST-controlled traffic lights could produce visible consequences for a cybersecurity challenge. description: A Unity traffic simulation where REST-controlled traffic lights could produce visible consequences for a cybersecurity challenge.
thumbnail: thumbnail:

View file

@ -1,5 +1,4 @@
--- ---
sourceProjectId: colors
title: Photo Colour Grader title: Photo Colour Grader
description: A proof-of-concept colour grading UI based on selecting colours and transforming nearby colour ranges. description: A proof-of-concept colour grading UI based on selecting colours and transforming nearby colour ranges.
thumbnail: thumbnail:

View file

@ -1,5 +1,4 @@
--- ---
sourceProjectId: declared
title: decla.red title: decla.red
description: A team-based mobile multiplayer browser game with shared client/server game logic. description: A team-based mobile multiplayer browser game with shared client/server game logic.
thumbnail: thumbnail:

View file

@ -1,5 +1,4 @@
--- ---
sourceProjectId: fleeting-garden
title: 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. description: A WebGPU drawing toy where coloured strokes spawn agents that follow them, branch off, and slowly rewrite the patch you laid down.
thumbnail: thumbnail:
@ -12,7 +11,7 @@ selected: true
essay: fleeting-garden-webgpu-drawing essay: fleeting-garden-webgpu-drawing
links: links:
- label: Demo - label: Demo
url: https://schmelczer.dev/fleeting/ url: /fleeting/
- label: Source - label: Source
url: https://github.com/schmelczer/webgpu url: https://home.schmelczer.dev/git/andras/webgpu
--- ---

View file

@ -1,5 +1,4 @@
--- ---
sourceProjectId: forex
title: Foreign Exchange Prediction Experiment title: Foreign Exchange Prediction Experiment
description: A frequency-domain prediction experiment using smoothing, differentiation, STFT, extrapolation, and inverse transforms. description: A frequency-domain prediction experiment using smoothing, differentiation, STFT, extrapolation, and inverse transforms.
thumbnail: thumbnail:

View file

@ -1,5 +1,4 @@
--- ---
sourceProjectId: great-ai
title: GreatAI title: GreatAI
description: A Python framework and research project for making AI deployment best practices easier to adopt. description: A Python framework and research project for making AI deployment best practices easier to adopt.
thumbnail: thumbnail:

View file

@ -1,5 +1,4 @@
--- ---
sourceProjectId: leds
title: Lights Synchronized to Music title: Lights Synchronized to Music
description: A Raspberry Pi music player that drove RGB LED strips from audio analysis. description: A Raspberry Pi music player that drove RGB LED strips from audio analysis.
thumbnail: thumbnail:

View file

@ -1,5 +1,4 @@
--- ---
sourceProjectId: my-notes
title: My Notes title: My Notes
description: A minimalist Android markdown note organizer and editor powered by Markwon. description: A minimalist Android markdown note organizer and editor powered by Markwon.
thumbnail: thumbnail:

View file

@ -1,5 +1,4 @@
--- ---
sourceProjectId: nuclear-editor
title: Graph Editor title: Graph Editor
description: A JavaFX editor for creating and editing input graphs for the cooling system simulator. description: A JavaFX editor for creating and editing input graphs for the cooling system simulator.
thumbnail: thumbnail:

View file

@ -1,5 +1,4 @@
--- ---
sourceProjectId: nuclear-simulation
title: Cooling System Simulation title: Cooling System Simulation
description: A graph-based process simulation with a monitoring client and JavaFX input editor. description: A graph-based process simulation with a monitoring client and JavaFX input editor.
thumbnail: thumbnail:

View file

@ -1,5 +1,4 @@
--- ---
sourceProjectId: photos
title: Photo Site Generator title: Photo Site Generator
description: A static photo site generated from a directory of images, with automatic resizing to multiple quality settings. description: A static photo site generated from a directory of images, with automatic resizing to multiple quality settings.
thumbnail: thumbnail:

View file

@ -1,5 +1,4 @@
--- ---
sourceProjectId: platform-game
title: 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. description: An early 3D game in C with SDL 1.2, random maps, destructible voxels, enemies, powerups, and time slowdown.
thumbnail: thumbnail:

View file

@ -1,5 +1,4 @@
--- ---
sourceProjectId: reconcile
title: reconcile-text title: reconcile-text
description: A Rust library that auto-resolves conflicting text edits without conflict markers, with WebAssembly and Python bindings. description: A Rust library that auto-resolves conflicting text edits without conflict markers, with WebAssembly and Python bindings.
thumbnail: thumbnail:
@ -12,7 +11,7 @@ selected: true
essay: reconcile-text-3-way-merge essay: reconcile-text-3-way-merge
links: links:
- label: Demo - label: Demo
url: https://schmelczer.dev/reconcile url: /reconcile/
- label: Source - label: Source
url: https://github.com/schmelczer/reconcile url: https://github.com/schmelczer/reconcile
- label: crates.io - label: crates.io

View file

@ -1,5 +1,4 @@
--- ---
sourceProjectId: sdf-2d
title: SDF-2D title: SDF-2D
description: A browser rendering library for optimized 2D ray tracing with signed distance fields. description: A browser rendering library for optimized 2D ray tracing with signed distance fields.
thumbnail: thumbnail:

View file

@ -1,5 +1,4 @@
--- ---
sourceProjectId: towers
title: Life Towers title: Life Towers
description: A multi-device goal tracker where the lasting idea was syncing state with immutable tries. description: A multi-device goal tracker where the lasting idea was syncing state with immutable tries.
thumbnail: thumbnail:

View file

@ -30,7 +30,7 @@ interface Props {
const { const {
title = site.title, title = site.title,
description = site.description, description = site.description,
canonicalPath = Astro.url.pathname, canonicalPath: rawCanonicalPath = Astro.url.pathname,
ogImage, ogImage,
ogImageAlt = "Andras Schmelczer's personal site", ogImageAlt = "Andras Schmelczer's personal site",
ogImageWidth, ogImageWidth,
@ -45,6 +45,12 @@ const {
const isRoot = title === site.title; const isRoot = title === site.title;
const pageTitle = isRoot ? site.title : `${title} · ${site.name}`; const pageTitle = isRoot ? site.title : `${title} · ${site.name}`;
const ogTitle = isRoot ? site.title : title; const ogTitle = isRoot ? site.title : title;
const canonicalPath =
rawCanonicalPath === '/' ||
rawCanonicalPath.endsWith('/') ||
/\.[^/]+$/.test(rawCanonicalPath)
? rawCanonicalPath
: `${rawCanonicalPath}/`;
const canonical = absoluteUrl(canonicalPath); const canonical = absoluteUrl(canonicalPath);
let resolvedOgImage = ogImage; let resolvedOgImage = ogImage;
@ -75,68 +81,13 @@ const ogImageType =
? 'image/svg+xml' ? 'image/svg+xml'
: 'image/jpeg'; : 'image/jpeg';
const jsonLdEntries = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : []; const jsonLdEntries = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : [];
const jsonLdStrings = jsonLdEntries.map((entry) =>
// Head meta tags built as a single HTML string so prettier-plugin-astro JSON.stringify(entry).replace(/</g, '\\u003c')
// doesn't shuffle them outside `<head>` when reformatting (it has trouble );
// with mixed JSX-expression and raw element siblings inside <head>).
const attr = (value: string) =>
value
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
const articleMetaParts = article
? [
`<meta property="article:published_time" content="${attr(article.publishedTime)}">`,
article.modifiedTime
? `<meta property="article:modified_time" content="${attr(article.modifiedTime)}">`
: '',
`<meta property="article:author" content="${attr(absoluteUrl('/about/'))}">`,
...(article.tags ?? []).map(
(tag) => `<meta property="article:tag" content="${attr(tag)}">`
),
]
: [];
const monoPreloadHtml = preloadMono
? '<link rel="preload" href="/fonts/ibm-plex-mono-latin-400.woff2" as="font" type="font/woff2" crossorigin>'
: '';
const headHtml = [
monoPreloadHtml,
`<link rel="alternate" type="application/rss+xml" title="${attr(`${site.name} RSS`)}" href="/rss.xml">`,
`<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">`,
`<link rel="icon" href="/favicon.ico" type="image/x-icon">`,
`<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">`,
`<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">`,
`<link rel="manifest" href="/site.webmanifest">`,
`<meta property="og:site_name" content="${attr(site.name)}">`,
`<meta property="og:title" content="${attr(ogTitle)}">`,
`<meta property="og:description" content="${attr(description)}">`,
`<meta property="og:url" content="${attr(canonical)}">`,
`<meta property="og:image" content="${attr(ogImageUrl)}">`,
`<meta property="og:image:type" content="${ogImageType}">`,
`<meta property="og:image:alt" content="${attr(ogImageAlt)}">`,
`<meta property="og:image:width" content="${resolvedOgWidth}">`,
`<meta property="og:image:height" content="${resolvedOgHeight}">`,
`<meta property="og:type" content="${attr(ogType)}">`,
`<meta property="og:locale" content="en">`,
...articleMetaParts,
`<meta name="twitter:card" content="summary_large_image">`,
`<meta name="twitter:title" content="${attr(ogTitle)}">`,
`<meta name="twitter:description" content="${attr(description)}">`,
`<meta name="twitter:image" content="${attr(ogImageUrl)}">`,
`<meta name="twitter:image:alt" content="${attr(ogImageAlt)}">`,
...jsonLdEntries.map(
(entry) =>
`<script type="application/ld+json">${JSON.stringify(entry).replace(/<\/script/gi, '<\\/script')}</script>`
),
].join('');
--- ---
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en" class="no-js">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta <meta
@ -147,18 +98,12 @@ const headHtml = [
<meta name="description" content={description} /> <meta name="description" content={description} />
<meta name="author" content={site.name} /> <meta name="author" content={site.name} />
<meta name="color-scheme" content="light dark" /> <meta name="color-scheme" content="light dark" />
<meta name="theme-color" content="#fbfaf7" media="(prefers-color-scheme: light)" /> <meta name="theme-color" content="#fbfaf7" />
<meta name="theme-color" content="#151514" media="(prefers-color-scheme: dark)" />
{noindex && <meta name="robots" content="noindex,follow" />} {noindex && <meta name="robots" content="noindex,follow" />}
{!noindex && <link rel="canonical" href={canonical} />} {!noindex && <link rel="canonical" href={canonical} />}
<script is:inline data-theme-script set:html={themeInit} /> <script is:inline data-theme-script set:html={themeInit} />
<noscript <meta name="theme-color" content="#fbfaf7" media="(prefers-color-scheme: light)" />
><style> <meta name="theme-color" content="#151514" media="(prefers-color-scheme: dark)" />
.theme-switcher {
display: none !important;
}
</style></noscript
>
<link <link
rel="preload" rel="preload"
href="/fonts/source-sans-3-latin-variable.woff2" href="/fonts/source-sans-3-latin-variable.woff2"
@ -166,7 +111,63 @@ const headHtml = [
type="font/woff2" type="font/woff2"
crossorigin crossorigin
/> />
<Fragment set:html={headHtml} /> {
preloadMono && (
<link
rel="preload"
href="/fonts/ibm-plex-mono-latin-400.woff2"
as="font"
type="font/woff2"
crossorigin
/>
)
}
<link
rel="alternate"
type="application/rss+xml"
title={`${site.name} RSS`}
href="/rss.xml"
/>
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="manifest" href="/site.webmanifest" />
<meta property="og:site_name" content={site.name} />
<meta property="og:title" content={ogTitle} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonical} />
<meta property="og:image" content={ogImageUrl} />
<meta property="og:image:type" content={ogImageType} />
<meta property="og:image:alt" content={ogImageAlt} />
<meta property="og:image:width" content={String(resolvedOgWidth)} />
<meta property="og:image:height" content={String(resolvedOgHeight)} />
<meta property="og:type" content={ogType} />
<meta property="og:locale" content="en_US" />
{
article && (
<>
<meta property="article:published_time" content={article.publishedTime} />
{article.modifiedTime && (
<meta property="article:modified_time" content={article.modifiedTime} />
)}
<meta property="article:author" content={absoluteUrl('/about/')} />
{article.tags?.map((tag) => (
<meta property="article:tag" content={tag} />
))}
</>
)
}
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={ogTitle} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={ogImageUrl} />
<meta name="twitter:image:alt" content={ogImageAlt} />
{
jsonLdStrings.map((jsonLdString) => (
<script is:inline type="application/ld+json" set:html={jsonLdString} />
))
}
</head> </head>
<body> <body>
<Header /> <Header />

View file

@ -2,9 +2,12 @@
import type { ComponentProps } from 'astro/types'; import type { ComponentProps } from 'astro/types';
import Base from './Base.astro'; import Base from './Base.astro';
type Props = ComponentProps<typeof Base>; type Props = Omit<ComponentProps<typeof Base>, 'title'> & { title: string };
const { title, description } = Astro.props; const { title, description } = Astro.props;
if (!title) {
throw new Error('Page layout requires a `title` prop.');
}
--- ---
<Base {...Astro.props}> <Base {...Astro.props}>

View file

@ -73,13 +73,6 @@ export function tagPath(tag: string) {
return `/tags/${tagSlug(tag)}/`; return `/tags/${tagSlug(tag)}/`;
} }
// Anchor used for `id="..."` on project cards and `#fragment` deep links.
// Always derived from the canonical `sourceProjectId` slug now that the
// legacy anchor mapping has been dropped.
export function projectAnchor(slugOrEntry: string | CollectionEntry<'projects'>) {
return typeof slugOrEntry === 'string' ? slugOrEntry : slugOrEntry.data.sourceProjectId;
}
export function getAllTags(posts: { data: { tags: readonly string[] } }[]) { export function getAllTags(posts: { data: { tags: readonly string[] } }[]) {
return [...new Set(posts.flatMap((post) => post.data.tags))].sort((a, b) => return [...new Set(posts.flatMap((post) => post.data.tags))].sort((a, b) =>
a.localeCompare(b) a.localeCompare(b)

View file

@ -51,7 +51,7 @@ const jsonLd = [blogJsonLd, breadcrumbJsonLd];
--- ---
<Page title="Articles" description={description} jsonLd={jsonLd}> <Page title="Articles" description={description} jsonLd={jsonLd}>
<nav id="tags" class="tag-filter" aria-label="Browse by tag"> <nav id="tag-filter" class="tag-filter" aria-label="Browse by tag">
<span>Browse by tag</span> <span>Browse by tag</span>
<TagList tags={tags} /> <TagList tags={tags} />
</nav> </nav>

View file

@ -4,7 +4,6 @@ import ogDefault from '../assets/og-default.jpg';
import { import {
absoluteUrl, absoluteUrl,
articlePath, articlePath,
entrySlug,
getPublishedPosts, getPublishedPosts,
optimizeOgImage, optimizeOgImage,
site, site,
@ -21,11 +20,6 @@ function escapeXml(value: string) {
.replace(/'/g, '&apos;'); .replace(/'/g, '&apos;');
} }
// Format a Date as `YYYY-MM-DD` in UTC for use inside tag: URIs.
function isoDate(date: Date) {
return date.toISOString().slice(0, 10);
}
export const GET: APIRoute = async (context) => { export const GET: APIRoute = async (context) => {
const posts = await getPublishedPosts(); const posts = await getPublishedPosts();
const feedUrl = absoluteUrl('/rss.xml'); const feedUrl = absoluteUrl('/rss.xml');
@ -54,10 +48,6 @@ export const GET: APIRoute = async (context) => {
].join('\n'), ].join('\n'),
items: posts.map((post) => { items: posts.map((post) => {
const url = absoluteUrl(articlePath(post)); const url = absoluteUrl(articlePath(post));
// Stable tag: URI keeps the GUID constant across path renames
// (e.g. the `/writing/` → `/articles/` migration). The date is the
// original publish date so re-publishing won't change the GUID.
const guid = `tag:schmelczer.dev,${isoDate(post.data.date)}:posts/${entrySlug(post)}`;
const updated = post.data.updated const updated = post.data.updated
? `<atom:updated>${post.data.updated.toISOString()}</atom:updated>` ? `<atom:updated>${post.data.updated.toISOString()}</atom:updated>`
: ''; : '';
@ -68,11 +58,7 @@ export const GET: APIRoute = async (context) => {
link: url, link: url,
author: `${site.email} (${site.name})`, author: `${site.email} (${site.name})`,
categories: [...post.data.tags], categories: [...post.data.tags],
customData: [ customData: [`<dc:creator>${creator}</dc:creator>`, updated]
`<guid isPermaLink="false">${escapeXml(guid)}</guid>`,
`<dc:creator>${creator}</dc:creator>`,
updated,
]
.filter(Boolean) .filter(Boolean)
.join('\n'), .join('\n'),
}; };

View file

@ -44,5 +44,5 @@ const breadcrumbJsonLd = buildBreadcrumbJsonLd(trail);
</nav> </nav>
<h2 class="sr-only">Articles</h2> <h2 class="sr-only">Articles</h2>
<ArticleList posts={filteredPosts} currentTag={tag} /> <ArticleList posts={filteredPosts} currentTag={tag} eagerFirstThumbnail />
</Page> </Page>

View file

@ -37,7 +37,9 @@ const jsonLd = [collectionJsonLd, breadcrumbJsonLd];
<Page title="Tags" description={description} jsonLd={jsonLd}> <Page title="Tags" description={description} jsonLd={jsonLd}>
<p class="dek"> <p class="dek">
{posts.length} articles across {tags.length} tags. {posts.length}
{posts.length === 1 ? 'article' : 'articles'} across {tags.length}
{tags.length === 1 ? 'tag' : 'tags'}.
</p> </p>
<TagList tags={tags} counts={tagCounts} /> <TagList tags={tags} counts={tagCounts} />
</Page> </Page>

View file

@ -1,9 +1,17 @@
// FOUC prevention: runs in <head> before paint. Sets the theme on <html> so // FOUC prevention: runs in <head> before paint. Sets the theme on <html> so
// the page renders with the right colors on first load. The theme switcher // the page renders with the right colors on first load. The theme switcher
// button is wired up separately, after it is parsed, in Header.astro. // button is wired up separately, after it is parsed, in Header.astro.
//
// Keep THEME_BG values in sync with --color-bg in global.css. They drive the
// browser-chrome <meta name="theme-color"> so it follows the user's manual
// toggle (the static media-keyed metas only tracked OS preference).
(function () { (function () {
document.documentElement.classList.remove('no-js');
document.documentElement.classList.add('js');
var STORAGE_KEY = 'theme'; var STORAGE_KEY = 'theme';
var LEGACY_KEY = 'dark-mode'; var LEGACY_KEY = 'dark-mode';
var THEME_BG = { light: '#fbfaf7', dark: '#151514' };
var saved = null; var saved = null;
try { try {
var value = localStorage.getItem(STORAGE_KEY); var value = localStorage.getItem(STORAGE_KEY);
@ -17,4 +25,8 @@
var theme = saved || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); var theme = saved || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.dataset.theme = theme; document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme; document.documentElement.style.colorScheme = theme;
var themeColorMetas = document.querySelectorAll('meta[name="theme-color"]');
for (var i = 0; i < themeColorMetas.length; i += 1) {
themeColorMetas[i].setAttribute('content', THEME_BG[theme]);
}
})(); })();

View file

@ -202,7 +202,8 @@
} }
main:focus-visible { main:focus-visible {
outline: none; outline: 2px solid var(--color-accent);
outline-offset: -2px;
} }
::selection { ::selection {
@ -534,7 +535,6 @@
} }
.tag-list a:hover, .tag-list a:hover,
.tag-list a[aria-current='page'],
.tag-list a[aria-current='true'] { .tag-list a[aria-current='true'] {
color: var(--color-fg); color: var(--color-fg);
} }
@ -647,10 +647,15 @@
border-color: var(--color-rule-strong); border-color: var(--color-rule-strong);
} }
.article-list > li:hover .entry-thumbnail img { .article-list > li:hover .entry-thumbnail img,
.article-list > li:focus-within .entry-thumbnail img {
transform: scale(1.02); transform: scale(1.02);
} }
.article-list > li:focus-within .entry-thumbnail {
border-color: var(--color-rule-strong);
}
.article-thumbnail { .article-thumbnail {
grid-area: thumb; grid-area: thumb;
align-self: center; align-self: center;
@ -673,10 +678,8 @@
transition: border-color 150ms ease; transition: border-color 150ms ease;
} }
.project-card:hover { .project-card:hover,
border-color: var(--color-rule-strong); .project-card:focus-within,
}
.project-card:target { .project-card:target {
border-color: var(--color-rule-strong); border-color: var(--color-rule-strong);
} }
@ -695,7 +698,8 @@
transition: transform 300ms ease; transition: transform 300ms ease;
} }
.project-card:hover .project-thumbnail img { .project-card:hover .project-thumbnail img,
.project-card:focus-within .project-thumbnail img {
transform: scale(1.02); transform: scale(1.02);
} }
@ -798,6 +802,7 @@
.post > .at-a-glance, .post > .at-a-glance,
.post > .post-thumbnail, .post > .post-thumbnail,
.post > .post-gallery,
.post-nav { .post-nav {
max-width: var(--measure-wide); max-width: var(--measure-wide);
margin-inline: auto; margin-inline: auto;
@ -1089,6 +1094,7 @@
.post > .post-header, .post > .post-header,
.post > .post-thumbnail, .post > .post-thumbnail,
.post > .post-gallery,
.post > .post-media, .post > .post-media,
.post > .post-nav { .post > .post-nav {
grid-column: 1 / -1; grid-column: 1 / -1;
@ -1242,6 +1248,15 @@
gap: var(--space-6); gap: var(--space-6);
} }
.post > .post-gallery {
width: 100%;
}
.post-gallery .post-media {
max-inline-size: 100%;
margin: 0;
}
/* -- External link affordance ----------------------------------------- */ /* -- External link affordance ----------------------------------------- */
.external-link-icon { .external-link-icon {
@ -1304,6 +1319,10 @@
border-color: var(--color-rule-strong); border-color: var(--color-rule-strong);
} }
.no-js .theme-switcher {
display: none !important;
}
.theme-switcher::before, .theme-switcher::before,
.theme-switcher::after { .theme-switcher::after {
content: ''; content: '';
@ -1414,7 +1433,7 @@
padding-block: var(--space-4); padding-block: var(--space-4);
} }
.article-list > li > div { .article-list > li > article {
padding-right: 0; padding-right: 0;
} }
@ -1447,6 +1466,13 @@
outline-offset: 1px; outline-offset: 1px;
} }
/* Preserve the inset outline on <main> so the post-skip-link focus ring
doesn't escape its container. Repeated here because this layer wins
over the base rule regardless of selector specificity. */
main:focus-visible {
outline-offset: -2px;
}
.post-nav__list { .post-nav__list {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }