Fable clean up
This commit is contained in:
parent
3441a7e4af
commit
4ce8a4f41d
46 changed files with 642 additions and 911 deletions
|
|
@ -28,20 +28,31 @@ jobs:
|
|||
run: npm ci
|
||||
|
||||
- name: Lint
|
||||
run: |
|
||||
npm run lint
|
||||
git diff
|
||||
if [[ `git status --porcelain` ]]; then
|
||||
exit 1
|
||||
fi
|
||||
run: npm run lint
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Build, Astro Audit & QA
|
||||
run: |
|
||||
npx playwright install chromium
|
||||
npm run qa
|
||||
- name: Check for em dashes
|
||||
run: npm run qa:no-em-dashes
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Astro audit
|
||||
run: npm run qa:astro-audit
|
||||
|
||||
- name: Check internal links
|
||||
run: npm run qa:links
|
||||
|
||||
- name: Check for unexpected JavaScript
|
||||
run: npm run qa:no-js
|
||||
|
||||
- name: Check viewport overflow
|
||||
run: npm run qa:overflow
|
||||
|
||||
- name: Check preview cropping
|
||||
run: npm run qa:preview-cropping
|
||||
|
||||
- name: Copy build to host pages mount
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
Engineering writeups by Andras Schmelczer: finished projects with the design constraints left in. Built with Astro, no required client JavaScript.
|
||||
|
||||
Articles live in `src/content/posts`, project index entries in `src/content/projects`, and normal pages are rendered as static HTML.
|
||||
All content lives in the single `src/content/work` collection: each entry can carry an `article` facet (a page under `/articles/`), a `project` facet (a card on `/projects/`), or both. Normal pages are rendered as static HTML.
|
||||
|
||||
## Setup
|
||||
|
||||
|
|
@ -11,6 +11,8 @@ npm ci
|
|||
npx playwright install --with-deps chromium # required before Playwright QA checks
|
||||
```
|
||||
|
||||
The `overrides.yaml` entry in `package.json` forces every transitive `yaml` dependency to 2.9+, ahead of what some Astro tooling requests on its own. Drop it once `npm ls yaml` shows nothing older without it.
|
||||
|
||||
## Commands
|
||||
|
||||
```sh
|
||||
|
|
@ -23,11 +25,11 @@ npm run qa
|
|||
|
||||
## Structure
|
||||
|
||||
- `src/content/posts`: Markdown articles
|
||||
- `src/content/projects`: project index entries
|
||||
- `src/content/work`: Markdown entries (article and/or project facets)
|
||||
- `src/pages`: static routes
|
||||
- `src/layouts`: page and post layouts
|
||||
- `src/components`: reusable UI pieces
|
||||
- `src/styles/global.css`: the visual system
|
||||
- `scripts`: QA checks run by `npm run qa` (shared helpers in `scripts/lib`)
|
||||
- `public/media/downloads`: CV and thesis PDFs
|
||||
- `public/media/video`: project videos
|
||||
|
|
|
|||
99
package-lock.json
generated
99
package-lock.json
generated
|
|
@ -7,19 +7,20 @@
|
|||
"name": "schmelczer-dev",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@plausible-analytics/tracker": "^0.4.5"
|
||||
"@plausible-analytics/tracker": "^0.4.5",
|
||||
"sharp": "^0.34.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/check": "^0.9.9",
|
||||
"@astrojs/rss": "^4.0.18",
|
||||
"@astrojs/sitemap": "^3.7.2",
|
||||
"@types/node": "^22.13.0",
|
||||
"astro": "^6.3.1",
|
||||
"playwright": "^1.59.1",
|
||||
"prettier": "^3.8.3",
|
||||
"prettier-plugin-astro": "^0.14.1",
|
||||
"rehype-autolink-headings": "^7.1.0",
|
||||
"rehype-slug": "^6.0.0",
|
||||
"sharp": "^0.34.5",
|
||||
"typescript": "^5.9.3",
|
||||
"unist-util-visit": "^5.1.0"
|
||||
},
|
||||
|
|
@ -479,7 +480,6 @@
|
|||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
|
|
@ -932,7 +932,6 @@
|
|||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
|
|
@ -945,7 +944,6 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -968,7 +966,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -991,7 +988,6 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1008,7 +1004,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1025,7 +1020,6 @@
|
|||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1042,7 +1036,6 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1059,7 +1052,6 @@
|
|||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1076,7 +1068,6 @@
|
|||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1093,7 +1084,6 @@
|
|||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1110,7 +1100,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1127,7 +1116,6 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1144,7 +1132,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1161,7 +1148,6 @@
|
|||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1184,7 +1170,6 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1207,7 +1192,6 @@
|
|||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1230,7 +1214,6 @@
|
|||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1253,7 +1236,6 @@
|
|||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1276,7 +1258,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1299,7 +1280,6 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1322,7 +1302,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1345,7 +1324,6 @@
|
|||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
|
|
@ -1365,7 +1343,6 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1385,7 +1362,6 @@
|
|||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1405,7 +1381,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2000,19 +1975,19 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.2.tgz",
|
||||
"integrity": "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==",
|
||||
"version": "22.19.21",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.21.tgz",
|
||||
"integrity": "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.19.0"
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node/node_modules/undici-types": {
|
||||
"version": "7.19.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
|
||||
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
|
|
@ -2925,7 +2900,6 @@
|
|||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
|
|
@ -5274,7 +5248,6 @@
|
|||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
|
||||
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
|
|
@ -5287,7 +5260,6 @@
|
|||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
|
||||
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
|
|
@ -5560,7 +5532,6 @@
|
|||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
|
||||
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/typesafe-path": {
|
||||
|
|
@ -6780,7 +6751,6 @@
|
|||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"tslib": "^2.4.0"
|
||||
|
|
@ -6971,14 +6941,12 @@
|
|||
"@img/colour": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||
"dev": true
|
||||
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="
|
||||
},
|
||||
"@img/sharp-darwin-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"@img/sharp-libvips-darwin-arm64": "1.2.4"
|
||||
|
|
@ -6988,7 +6956,6 @@
|
|||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"@img/sharp-libvips-darwin-x64": "1.2.4"
|
||||
|
|
@ -6998,77 +6965,66 @@
|
|||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@img/sharp-libvips-darwin-x64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@img/sharp-libvips-linux-arm": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
|
||||
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@img/sharp-libvips-linux-arm64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@img/sharp-libvips-linux-ppc64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
|
||||
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@img/sharp-libvips-linux-riscv64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
|
||||
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@img/sharp-libvips-linux-s390x": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
|
||||
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@img/sharp-libvips-linux-x64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@img/sharp-libvips-linuxmusl-arm64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@img/sharp-libvips-linuxmusl-x64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@img/sharp-linux-arm": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
|
||||
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"@img/sharp-libvips-linux-arm": "1.2.4"
|
||||
|
|
@ -7078,7 +7034,6 @@
|
|||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"@img/sharp-libvips-linux-arm64": "1.2.4"
|
||||
|
|
@ -7088,7 +7043,6 @@
|
|||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
|
||||
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"@img/sharp-libvips-linux-ppc64": "1.2.4"
|
||||
|
|
@ -7098,7 +7052,6 @@
|
|||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
|
||||
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"@img/sharp-libvips-linux-riscv64": "1.2.4"
|
||||
|
|
@ -7108,7 +7061,6 @@
|
|||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
|
||||
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"@img/sharp-libvips-linux-s390x": "1.2.4"
|
||||
|
|
@ -7118,7 +7070,6 @@
|
|||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"@img/sharp-libvips-linux-x64": "1.2.4"
|
||||
|
|
@ -7128,7 +7079,6 @@
|
|||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
|
||||
|
|
@ -7138,7 +7088,6 @@
|
|||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
|
||||
|
|
@ -7148,7 +7097,6 @@
|
|||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
|
||||
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"@emnapi/runtime": "^1.7.0"
|
||||
|
|
@ -7158,21 +7106,18 @@
|
|||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@img/sharp-win32-ia32": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
|
||||
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@img/sharp-win32-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@jridgewell/sourcemap-codec": {
|
||||
|
|
@ -7520,18 +7465,18 @@
|
|||
}
|
||||
},
|
||||
"@types/node": {
|
||||
"version": "25.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.2.tgz",
|
||||
"integrity": "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==",
|
||||
"version": "22.19.21",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.21.tgz",
|
||||
"integrity": "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"undici-types": "~7.19.0"
|
||||
"undici-types": "~6.21.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"undici-types": {
|
||||
"version": "7.19.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
|
||||
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
|
|
@ -8121,8 +8066,7 @@
|
|||
"detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"dev": true
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="
|
||||
},
|
||||
"devalue": {
|
||||
"version": "5.8.1",
|
||||
|
|
@ -9670,14 +9614,12 @@
|
|||
"semver": {
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
|
||||
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
|
||||
"dev": true
|
||||
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="
|
||||
},
|
||||
"sharp": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
|
||||
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@img/colour": "^1.0.0",
|
||||
"@img/sharp-darwin-arm64": "0.34.5",
|
||||
|
|
@ -9858,7 +9800,6 @@
|
|||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
|
||||
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"typesafe-path": {
|
||||
|
|
|
|||
11
package.json
11
package.json
|
|
@ -10,8 +10,8 @@
|
|||
"scripts": {
|
||||
"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\"",
|
||||
"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",
|
||||
|
|
@ -43,6 +43,7 @@
|
|||
"@astrojs/check": "^0.9.9",
|
||||
"@astrojs/rss": "^4.0.18",
|
||||
"@astrojs/sitemap": "^3.7.2",
|
||||
"@types/node": "^22.13.0",
|
||||
"astro": "^6.3.1",
|
||||
"playwright": "^1.59.1",
|
||||
"prettier": "^3.8.3",
|
||||
|
|
@ -50,13 +51,13 @@
|
|||
"rehype-autolink-headings": "^7.1.0",
|
||||
"rehype-slug": "^6.0.0",
|
||||
"typescript": "^5.9.3",
|
||||
"unist-util-visit": "^5.1.0",
|
||||
"sharp": "^0.34.5"
|
||||
"unist-util-visit": "^5.1.0"
|
||||
},
|
||||
"overrides": {
|
||||
"yaml": "^2.9.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@plausible-analytics/tracker": "^0.4.5"
|
||||
"@plausible-analytics/tracker": "^0.4.5",
|
||||
"sharp": "^0.34.5"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,59 +1,17 @@
|
|||
import { readdir, readFile, stat } from 'node:fs/promises';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { dist, requireDist, resolveFile } from './lib/dist.mjs';
|
||||
import { walk } from './lib/walk.mjs';
|
||||
|
||||
const dist = path.resolve('dist');
|
||||
const allowedPreservedRoutes = new Set(['/fleeting/', '/reconcile/']);
|
||||
const failures = [];
|
||||
|
||||
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 exists(file) {
|
||||
try {
|
||||
return (await stat(file)).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function targetExists(pathname) {
|
||||
if (allowedPreservedRoutes.has(pathname)) return true;
|
||||
|
||||
const safePath = path
|
||||
.normalize(decodeURIComponent(pathname))
|
||||
.replace(/^\/+/, '')
|
||||
.replace(/^(\.\.(\/|\\|$))+/, '');
|
||||
const candidate = path.join(dist, safePath);
|
||||
const candidates = [
|
||||
candidate,
|
||||
path.join(candidate, 'index.html'),
|
||||
path.join(dist, `${safePath}.html`),
|
||||
];
|
||||
|
||||
for (const file of candidates) {
|
||||
if (await exists(file)) return true;
|
||||
}
|
||||
return false;
|
||||
return (await resolveFile(pathname)) !== null;
|
||||
}
|
||||
|
||||
try {
|
||||
await stat(dist);
|
||||
} catch {
|
||||
throw new Error('dist/ does not exist. Run npm run build first.');
|
||||
}
|
||||
await requireDist();
|
||||
|
||||
const files = await walk(dist);
|
||||
const checkedFiles = files.filter((file) => /\.(html|xml|css|webmanifest)$/.test(file));
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { readdir, readFile, stat } from 'node:fs/promises';
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { walk } from './lib/walk.mjs';
|
||||
|
||||
const forbidden = String.fromCodePoint(0x2014);
|
||||
const root = process.cwd();
|
||||
|
|
@ -37,25 +38,6 @@ async function exists(filePath) {
|
|||
}
|
||||
}
|
||||
|
||||
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');
|
||||
|
|
|
|||
|
|
@ -1,30 +1,10 @@
|
|||
import { readdir, readFile, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { dist, requireDist } from './lib/dist.mjs';
|
||||
import { walk } from './lib/walk.mjs';
|
||||
|
||||
const dist = path.resolve('dist');
|
||||
const failures = [];
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
try {
|
||||
await stat(dist);
|
||||
} catch {
|
||||
throw new Error('dist/ does not exist. Run npm run build first.');
|
||||
}
|
||||
await requireDist();
|
||||
|
||||
const files = await walk(dist);
|
||||
const ALLOWED_JS_ASSET_PATTERNS = [
|
||||
|
|
|
|||
|
|
@ -1,200 +1,32 @@
|
|||
import { createServer } from 'node:http';
|
||||
import { mkdir, readdir, readFile, rm, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { chromium } from 'playwright';
|
||||
import {
|
||||
CONTEXT_TIMEOUT_MS,
|
||||
PAGE_TIMEOUT_MS,
|
||||
cleanupBrowserTmp,
|
||||
openBrowser,
|
||||
safeCloseBrowser,
|
||||
safeCloseContext,
|
||||
safeClosePage,
|
||||
setupBrowserTmp,
|
||||
shouldRetryNavigation,
|
||||
withTimeout,
|
||||
} from './lib/browser.mjs';
|
||||
import { discoverRoutes, requireDist, startDistServer } from './lib/dist.mjs';
|
||||
|
||||
const dist = path.resolve('dist');
|
||||
const browserTmp = path.resolve('.astro', 'playwright-overflow-tmp');
|
||||
const INDEX_FILE = 'index.html';
|
||||
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',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.webp': 'image/webp',
|
||||
'.avif': 'image/avif',
|
||||
'.ico': 'image/x-icon',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
'.mp4': 'video/mp4',
|
||||
'.webm': 'video/webm',
|
||||
'.vtt': 'text/vtt; charset=utf-8',
|
||||
'.pdf': 'application/pdf',
|
||||
};
|
||||
|
||||
function contentType(file) {
|
||||
const ext = path.extname(file).toLowerCase();
|
||||
return MIME[ext] ?? 'application/octet-stream';
|
||||
}
|
||||
|
||||
async function walk(dir) {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
const files = [];
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await walk(fullPath)));
|
||||
} else {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
async function discoverRoutes() {
|
||||
const files = await walk(dist);
|
||||
const routes = new Set();
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.html')) continue;
|
||||
const rel = path.relative(dist, file).replaceAll(path.sep, '/');
|
||||
if (rel === '404.html') continue;
|
||||
if (rel === INDEX_FILE) {
|
||||
routes.add('/');
|
||||
} else if (rel.endsWith(`/${INDEX_FILE}`)) {
|
||||
routes.add('/' + rel.slice(0, -INDEX_FILE.length));
|
||||
} else {
|
||||
routes.add('/' + rel.replace(/\.html$/, '/'));
|
||||
}
|
||||
}
|
||||
return [...routes].sort();
|
||||
}
|
||||
|
||||
async function resolveFile(url) {
|
||||
const parsed = new URL(url, 'http://localhost');
|
||||
const safePath = path
|
||||
.normalize(decodeURIComponent(parsed.pathname))
|
||||
.replace(/^\/+/, '')
|
||||
.replace(/^(\.\.(\/|\\|$))+/, '');
|
||||
const candidate = path.join(dist, safePath);
|
||||
const candidates = [
|
||||
candidate,
|
||||
path.join(candidate, 'index.html'),
|
||||
path.join(dist, `${safePath}.html`),
|
||||
];
|
||||
|
||||
for (const file of candidates) {
|
||||
try {
|
||||
const fileStat = await stat(file);
|
||||
if (fileStat.isFile()) return file;
|
||||
} catch {
|
||||
// Try the next candidate.
|
||||
}
|
||||
}
|
||||
|
||||
return path.join(dist, '404.html');
|
||||
}
|
||||
|
||||
try {
|
||||
await stat(dist);
|
||||
} catch {
|
||||
throw new Error('dist/ does not exist. Run npm run build first.');
|
||||
}
|
||||
|
||||
// Some CI/dev containers mount /tmp as a very small tmpfs. Chromium uses the
|
||||
// process temp directory for profiles and internal files; putting it under the
|
||||
// already-ignored .astro/ directory keeps the overflow check reproducible even
|
||||
// when the system temp mount is full.
|
||||
await rm(browserTmp, { recursive: true, force: true });
|
||||
await mkdir(browserTmp, { recursive: true });
|
||||
process.env.TMPDIR = browserTmp;
|
||||
process.env.TMP = browserTmp;
|
||||
process.env.TEMP = browserTmp;
|
||||
await requireDist();
|
||||
await setupBrowserTmp(browserTmp);
|
||||
|
||||
const routes = await discoverRoutes();
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
try {
|
||||
const file = await resolveFile(req.url ?? '/');
|
||||
const body = await readFile(file);
|
||||
res.writeHead(200, { 'content-type': contentType(file) });
|
||||
res.end(body);
|
||||
} catch (error) {
|
||||
res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
|
||||
res.end(String(error));
|
||||
}
|
||||
});
|
||||
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const { port } = server.address();
|
||||
const { server, port } = await startDistServer();
|
||||
const failures = [];
|
||||
|
||||
function launchBrowser() {
|
||||
return chromium.launch({
|
||||
headless: true,
|
||||
env: {
|
||||
...process.env,
|
||||
TMPDIR: browserTmp,
|
||||
TMP: browserTmp,
|
||||
TEMP: browserTmp,
|
||||
},
|
||||
args: ['--disable-dev-shm-usage', '--disable-gpu', '--no-sandbox'],
|
||||
});
|
||||
}
|
||||
|
||||
async function withTimeout(promise, timeoutMs, label) {
|
||||
let timeout;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise((_, reject) => {
|
||||
timeout = setTimeout(() => reject(new Error(label)), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function safeClosePage(page) {
|
||||
await withTimeout(
|
||||
page.close(),
|
||||
CLOSE_TIMEOUT_MS,
|
||||
'Timed out while closing Playwright page'
|
||||
).catch(() => {});
|
||||
}
|
||||
|
||||
async function safeCloseContext(context) {
|
||||
await withTimeout(
|
||||
context.close(),
|
||||
CLOSE_TIMEOUT_MS,
|
||||
'Timed out while closing Playwright context'
|
||||
).catch(() => {});
|
||||
}
|
||||
|
||||
async function safeCloseBrowser(browser) {
|
||||
const childProcess = browser.process?.();
|
||||
try {
|
||||
await withTimeout(
|
||||
browser.close(),
|
||||
CLOSE_TIMEOUT_MS,
|
||||
'Timed out while closing Chromium'
|
||||
);
|
||||
} catch {
|
||||
childProcess?.kill('SIGKILL');
|
||||
}
|
||||
}
|
||||
|
||||
async function openBrowser() {
|
||||
return withTimeout(
|
||||
launchBrowser(),
|
||||
LAUNCH_TIMEOUT_MS,
|
||||
'Timed out while launching Chromium'
|
||||
);
|
||||
}
|
||||
|
||||
async function newMeasurementContext(browser, width) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width, height: 900 },
|
||||
|
|
@ -227,13 +59,6 @@ async function measureViewport(page) {
|
|||
}));
|
||||
}
|
||||
|
||||
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 {
|
||||
|
|
@ -263,7 +88,7 @@ try {
|
|||
let browser;
|
||||
let context;
|
||||
try {
|
||||
browser = await openBrowser();
|
||||
browser = await openBrowser(browserTmp);
|
||||
context = await openMeasurementContext(browser, width);
|
||||
for (const route of routes) {
|
||||
let result;
|
||||
|
|
@ -278,7 +103,7 @@ try {
|
|||
}
|
||||
await safeCloseContext(context);
|
||||
await safeCloseBrowser(browser);
|
||||
browser = await openBrowser();
|
||||
browser = await openBrowser(browserTmp);
|
||||
context = await openMeasurementContext(browser, width);
|
||||
}
|
||||
}
|
||||
|
|
@ -296,7 +121,7 @@ try {
|
|||
}
|
||||
} finally {
|
||||
server.close();
|
||||
await rm(browserTmp, { recursive: true, force: true }).catch(() => {});
|
||||
await cleanupBrowserTmp(browserTmp);
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
|
|
|
|||
|
|
@ -1,115 +1,31 @@
|
|||
import { createServer } from 'node:http';
|
||||
import { mkdir, readdir, readFile, rm, stat } from 'node:fs/promises';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { chromium } from 'playwright';
|
||||
import {
|
||||
CONTEXT_TIMEOUT_MS,
|
||||
PAGE_TIMEOUT_MS,
|
||||
cleanupBrowserTmp,
|
||||
openBrowser,
|
||||
safeCloseBrowser,
|
||||
safeCloseContext,
|
||||
safeClosePage,
|
||||
setupBrowserTmp,
|
||||
shouldRetryNavigation,
|
||||
withTimeout,
|
||||
} from './lib/browser.mjs';
|
||||
import { discoverRoutes, requireDist, startDistServer } from './lib/dist.mjs';
|
||||
|
||||
const dist = path.resolve('dist');
|
||||
const previewCss = path.resolve('src/styles/global.css');
|
||||
const browserTmp = path.resolve('.astro', 'playwright-preview-cropping-tmp');
|
||||
const INDEX_FILE = 'index.html';
|
||||
const PREVIEW_SELECTOR = '[data-uncropped-preview]';
|
||||
// Common device widths: iPhone SE / Galaxy S / iPhone 14 / iPad portrait /
|
||||
// iPad landscape / common laptop / full HD desktop.
|
||||
const VIEWPORT_WIDTHS = [320, 390, 430, 768, 1024, 1440, 1920];
|
||||
const MAX_NAV_RETRIES = 4;
|
||||
const CLOSE_TIMEOUT_MS = 3000;
|
||||
const LAUNCH_TIMEOUT_MS = 10000;
|
||||
const CONTEXT_TIMEOUT_MS = 8000;
|
||||
const PAGE_TIMEOUT_MS = 15000;
|
||||
const MEASURE_TIMEOUT_MS = 30000;
|
||||
const CLIP_TOLERANCE_PX = 0.75;
|
||||
const RATIO_TOLERANCE = 0.01;
|
||||
|
||||
const MIME = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.webp': 'image/webp',
|
||||
'.avif': 'image/avif',
|
||||
'.ico': 'image/x-icon',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
'.mp4': 'video/mp4',
|
||||
'.webm': 'video/webm',
|
||||
'.vtt': 'text/vtt; charset=utf-8',
|
||||
'.pdf': 'application/pdf',
|
||||
};
|
||||
|
||||
function contentType(file) {
|
||||
const ext = path.extname(file).toLowerCase();
|
||||
return MIME[ext] ?? 'application/octet-stream';
|
||||
}
|
||||
|
||||
async function walk(dir) {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
const files = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await walk(fullPath)));
|
||||
} else {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
async function discoverRoutes() {
|
||||
const files = await walk(dist);
|
||||
const routes = new Set();
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.html')) continue;
|
||||
const rel = path.relative(dist, file).replaceAll(path.sep, '/');
|
||||
if (rel === '404.html') continue;
|
||||
if (rel === INDEX_FILE) {
|
||||
routes.add('/');
|
||||
} else if (rel.endsWith(`/${INDEX_FILE}`)) {
|
||||
routes.add('/' + rel.slice(0, -INDEX_FILE.length));
|
||||
} else {
|
||||
routes.add('/' + rel.replace(/\.html$/, '/'));
|
||||
}
|
||||
}
|
||||
|
||||
return [...routes].sort();
|
||||
}
|
||||
|
||||
async function resolveFile(url) {
|
||||
const parsed = new URL(url, 'http://localhost');
|
||||
const safePath = path
|
||||
.normalize(decodeURIComponent(parsed.pathname))
|
||||
.replace(/^\/+/, '')
|
||||
.replace(/^(\.\.(\/|\\|$))+/, '');
|
||||
const candidate = path.join(dist, safePath);
|
||||
const candidates = [
|
||||
candidate,
|
||||
path.join(candidate, 'index.html'),
|
||||
path.join(dist, `${safePath}.html`),
|
||||
];
|
||||
|
||||
for (const file of candidates) {
|
||||
try {
|
||||
const fileStat = await stat(file);
|
||||
if (fileStat.isFile()) return file;
|
||||
} catch {
|
||||
// Try the next candidate.
|
||||
}
|
||||
}
|
||||
|
||||
return path.join(dist, '404.html');
|
||||
}
|
||||
|
||||
try {
|
||||
await stat(dist);
|
||||
} catch {
|
||||
throw new Error('dist/ does not exist. Run npm run build first.');
|
||||
}
|
||||
await requireDist();
|
||||
|
||||
function lineAndColumn(text, index) {
|
||||
const before = text.slice(0, index);
|
||||
|
|
@ -197,95 +113,11 @@ async function checkPreviewCroppingStyles() {
|
|||
return styleFailures;
|
||||
}
|
||||
|
||||
// Keep Chromium temp files inside the repo so the check is reproducible in CI
|
||||
// containers with very small /tmp mounts.
|
||||
await rm(browserTmp, { recursive: true, force: true });
|
||||
await mkdir(browserTmp, { recursive: true });
|
||||
process.env.TMPDIR = browserTmp;
|
||||
process.env.TMP = browserTmp;
|
||||
process.env.TEMP = browserTmp;
|
||||
await setupBrowserTmp(browserTmp);
|
||||
|
||||
const routes = await discoverRoutes();
|
||||
const failures = await checkPreviewCroppingStyles();
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
try {
|
||||
const file = await resolveFile(req.url ?? '/');
|
||||
const body = await readFile(file);
|
||||
res.writeHead(200, { 'content-type': contentType(file) });
|
||||
res.end(body);
|
||||
} catch (error) {
|
||||
res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
|
||||
res.end(String(error));
|
||||
}
|
||||
});
|
||||
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const { port } = server.address();
|
||||
|
||||
function launchBrowser() {
|
||||
return chromium.launch({
|
||||
headless: true,
|
||||
env: {
|
||||
...process.env,
|
||||
TMPDIR: browserTmp,
|
||||
TMP: browserTmp,
|
||||
TEMP: browserTmp,
|
||||
},
|
||||
args: ['--disable-dev-shm-usage', '--disable-gpu', '--no-sandbox'],
|
||||
});
|
||||
}
|
||||
|
||||
async function withTimeout(promise, timeoutMs, label) {
|
||||
let timeout;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise((_, reject) => {
|
||||
timeout = setTimeout(() => reject(new Error(label)), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function safeClosePage(page) {
|
||||
await withTimeout(
|
||||
page.close(),
|
||||
CLOSE_TIMEOUT_MS,
|
||||
'Timed out while closing Playwright page'
|
||||
).catch(() => {});
|
||||
}
|
||||
|
||||
async function safeCloseContext(context) {
|
||||
await withTimeout(
|
||||
context.close(),
|
||||
CLOSE_TIMEOUT_MS,
|
||||
'Timed out while closing Playwright context'
|
||||
).catch(() => {});
|
||||
}
|
||||
|
||||
async function safeCloseBrowser(browser) {
|
||||
const childProcess = browser.process?.();
|
||||
try {
|
||||
await withTimeout(
|
||||
browser.close(),
|
||||
CLOSE_TIMEOUT_MS,
|
||||
'Timed out while closing Chromium'
|
||||
);
|
||||
} catch {
|
||||
childProcess?.kill('SIGKILL');
|
||||
}
|
||||
}
|
||||
|
||||
async function openBrowser() {
|
||||
return withTimeout(
|
||||
launchBrowser(),
|
||||
LAUNCH_TIMEOUT_MS,
|
||||
'Timed out while launching Chromium'
|
||||
);
|
||||
}
|
||||
const { server, port } = await startDistServer();
|
||||
|
||||
async function newMeasurementContext(browser, width) {
|
||||
const context = await browser.newContext({
|
||||
|
|
@ -451,13 +283,6 @@ async function inspectPreviews(page, route, width, phase, index = null) {
|
|||
);
|
||||
}
|
||||
|
||||
function shouldRetryNavigation(error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return /ERR_INSUFFICIENT_RESOURCES|Execution context was destroyed|Target.*closed|has been closed|Timed out while|navigation/i.test(
|
||||
message
|
||||
);
|
||||
}
|
||||
|
||||
async function measureRoute(context, route, width) {
|
||||
let page;
|
||||
|
||||
|
|
@ -492,7 +317,7 @@ try {
|
|||
let context;
|
||||
|
||||
try {
|
||||
browser = await openBrowser();
|
||||
browser = await openBrowser(browserTmp);
|
||||
context = await openMeasurementContext(browser, width);
|
||||
|
||||
for (const route of routes) {
|
||||
|
|
@ -509,7 +334,7 @@ try {
|
|||
|
||||
await safeCloseContext(context);
|
||||
await safeCloseBrowser(browser);
|
||||
browser = await openBrowser();
|
||||
browser = await openBrowser(browserTmp);
|
||||
context = await openMeasurementContext(browser, width);
|
||||
}
|
||||
}
|
||||
|
|
@ -523,7 +348,7 @@ try {
|
|||
}
|
||||
} finally {
|
||||
server.close();
|
||||
await rm(browserTmp, { recursive: true, force: true }).catch(() => {});
|
||||
await cleanupBrowserTmp(browserTmp);
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,16 @@
|
|||
import { spawn } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
import { mkdir, readdir, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { createServer as createNetServer } from 'node:net';
|
||||
import path from 'node:path';
|
||||
import { chromium } from 'playwright';
|
||||
import {
|
||||
cleanupBrowserTmp,
|
||||
openBrowser,
|
||||
safeCloseBrowser,
|
||||
setupBrowserTmp,
|
||||
} from './lib/browser.mjs';
|
||||
import { discoverRoutes, requireDist } from './lib/dist.mjs';
|
||||
|
||||
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'
|
||||
|
|
@ -19,7 +24,6 @@ const astroBin = path.resolve(
|
|||
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;
|
||||
|
|
@ -51,21 +55,7 @@ function parseViewports(raw = process.env.ASTRO_AUDIT_VIEWPORTS ?? DEFAULT_VIEWP
|
|||
});
|
||||
}
|
||||
|
||||
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() {
|
||||
async function discoverAuditRoutes() {
|
||||
if (process.env.ASTRO_AUDIT_ROUTES) {
|
||||
return process.env.ASTRO_AUDIT_ROUTES.split(',')
|
||||
.map((route) => route.trim())
|
||||
|
|
@ -73,29 +63,8 @@ async function discoverRoutes() {
|
|||
.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();
|
||||
await requireDist();
|
||||
return discoverRoutes();
|
||||
}
|
||||
|
||||
async function getFreePort() {
|
||||
|
|
@ -169,20 +138,6 @@ async function stopProcess(child) {
|
|||
}
|
||||
}
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
|
@ -385,19 +340,15 @@ function renderMarkdown(report) {
|
|||
}
|
||||
|
||||
const viewports = parseViewports();
|
||||
const routes = await discoverRoutes();
|
||||
const routes = await discoverAuditRoutes();
|
||||
|
||||
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 setupBrowserTmp(browserTmp);
|
||||
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}/`;
|
||||
|
|
@ -409,16 +360,7 @@ 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'],
|
||||
});
|
||||
browser = await openBrowser(browserTmp);
|
||||
|
||||
for (const viewport of viewports) {
|
||||
const context = await browser.newContext({
|
||||
|
|
@ -480,5 +422,5 @@ try {
|
|||
} finally {
|
||||
if (browser) await safeCloseBrowser(browser);
|
||||
if (devServer) await stopProcess(devServer);
|
||||
await rm(browserTmp, { recursive: true, force: true }).catch(() => {});
|
||||
await cleanupBrowserTmp(browserTmp);
|
||||
}
|
||||
|
|
|
|||
89
scripts/lib/browser.mjs
Normal file
89
scripts/lib/browser.mjs
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { mkdir, rm } from 'node:fs/promises';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
export const CLOSE_TIMEOUT_MS = 3000;
|
||||
export const LAUNCH_TIMEOUT_MS = 10000;
|
||||
export const CONTEXT_TIMEOUT_MS = 8000;
|
||||
export const PAGE_TIMEOUT_MS = 15000;
|
||||
|
||||
// Chromium puts profiles and internal files in the process temp directory.
|
||||
// Some CI/dev containers mount /tmp as a very small tmpfs, so the checks point
|
||||
// it at an already-ignored directory under .astro/ instead.
|
||||
export async function setupBrowserTmp(browserTmp) {
|
||||
await rm(browserTmp, { recursive: true, force: true });
|
||||
await mkdir(browserTmp, { recursive: true });
|
||||
process.env.TMPDIR = browserTmp;
|
||||
process.env.TMP = browserTmp;
|
||||
process.env.TEMP = browserTmp;
|
||||
}
|
||||
|
||||
export async function cleanupBrowserTmp(browserTmp) {
|
||||
await rm(browserTmp, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
|
||||
export 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);
|
||||
}
|
||||
}
|
||||
|
||||
export async function safeClosePage(page) {
|
||||
await withTimeout(
|
||||
page.close(),
|
||||
CLOSE_TIMEOUT_MS,
|
||||
'Timed out while closing Playwright page'
|
||||
).catch(() => {});
|
||||
}
|
||||
|
||||
export async function safeCloseContext(context) {
|
||||
await withTimeout(
|
||||
context.close(),
|
||||
CLOSE_TIMEOUT_MS,
|
||||
'Timed out while closing Playwright context'
|
||||
).catch(() => {});
|
||||
}
|
||||
|
||||
export 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');
|
||||
}
|
||||
}
|
||||
|
||||
export function openBrowser(browserTmp) {
|
||||
return withTimeout(
|
||||
chromium.launch({
|
||||
headless: true,
|
||||
env: {
|
||||
...process.env,
|
||||
TMPDIR: browserTmp,
|
||||
TMP: browserTmp,
|
||||
TEMP: browserTmp,
|
||||
},
|
||||
args: ['--disable-dev-shm-usage', '--disable-gpu', '--no-sandbox'],
|
||||
}),
|
||||
LAUNCH_TIMEOUT_MS,
|
||||
'Timed out while launching Chromium'
|
||||
);
|
||||
}
|
||||
|
||||
export 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
|
||||
);
|
||||
}
|
||||
108
scripts/lib/dist.mjs
Normal file
108
scripts/lib/dist.mjs
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { createServer } from 'node:http';
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { walk } from './walk.mjs';
|
||||
|
||||
export const dist = path.resolve('dist');
|
||||
|
||||
const INDEX_FILE = 'index.html';
|
||||
|
||||
export async function requireDist() {
|
||||
try {
|
||||
await stat(dist);
|
||||
} catch {
|
||||
throw new Error('dist/ does not exist. Run npm run build first.');
|
||||
}
|
||||
}
|
||||
|
||||
// Derives the site's route list from the HTML files in dist/.
|
||||
export async function discoverRoutes() {
|
||||
const files = await walk(dist);
|
||||
const routes = new Set();
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.html')) continue;
|
||||
const rel = path.relative(dist, file).replaceAll(path.sep, '/');
|
||||
if (rel === '404.html') continue;
|
||||
if (rel === INDEX_FILE) {
|
||||
routes.add('/');
|
||||
} else if (rel.endsWith(`/${INDEX_FILE}`)) {
|
||||
routes.add('/' + rel.slice(0, -INDEX_FILE.length));
|
||||
} else {
|
||||
routes.add('/' + rel.replace(/\.html$/, '/'));
|
||||
}
|
||||
}
|
||||
|
||||
return [...routes].sort();
|
||||
}
|
||||
|
||||
// Maps a request URL (or pathname) to a file in dist/, mirroring static-host
|
||||
// semantics: the path itself, its directory index.html, or the extensionless
|
||||
// .html variant. Returns null when nothing matches.
|
||||
export async function resolveFile(url) {
|
||||
const parsed = new URL(url, 'http://localhost');
|
||||
const safePath = path
|
||||
.normalize(decodeURIComponent(parsed.pathname))
|
||||
.replace(/^\/+/, '')
|
||||
.replace(/^(\.\.(\/|\\|$))+/, '');
|
||||
const candidate = path.join(dist, safePath);
|
||||
const candidates = [
|
||||
candidate,
|
||||
path.join(candidate, 'index.html'),
|
||||
path.join(dist, `${safePath}.html`),
|
||||
];
|
||||
|
||||
for (const file of candidates) {
|
||||
try {
|
||||
const fileStat = await stat(file);
|
||||
if (fileStat.isFile()) return file;
|
||||
} catch {
|
||||
// Try the next candidate.
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const MIME = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.webp': 'image/webp',
|
||||
'.avif': 'image/avif',
|
||||
'.ico': 'image/x-icon',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
'.mp4': 'video/mp4',
|
||||
'.webm': 'video/webm',
|
||||
'.vtt': 'text/vtt; charset=utf-8',
|
||||
'.pdf': 'application/pdf',
|
||||
};
|
||||
|
||||
export function contentType(file) {
|
||||
const ext = path.extname(file).toLowerCase();
|
||||
return MIME[ext] ?? 'application/octet-stream';
|
||||
}
|
||||
|
||||
// Serves dist/ on an ephemeral localhost port, falling back to 404.html.
|
||||
// Returns the server (callers own closing it) and the assigned port.
|
||||
export async function startDistServer() {
|
||||
const server = createServer(async (req, res) => {
|
||||
try {
|
||||
const file = (await resolveFile(req.url ?? '/')) ?? path.join(dist, '404.html');
|
||||
const body = await readFile(file);
|
||||
res.writeHead(200, { 'content-type': contentType(file) });
|
||||
res.end(body);
|
||||
} catch (error) {
|
||||
res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
|
||||
res.end(String(error));
|
||||
}
|
||||
});
|
||||
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
return { server, port: server.address().port };
|
||||
}
|
||||
23
scripts/lib/walk.mjs
Normal file
23
scripts/lib/walk.mjs
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { readdir, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
// Recursively lists every file under entryPath. A path that is itself a file
|
||||
// resolves to a single-element list, so callers can mix files and directories.
|
||||
export 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 {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
|
@ -1,11 +1,7 @@
|
|||
---
|
||||
import { navItems, site } from '../lib/site';
|
||||
import { THEME_BG, navItems, normalizeTrailingSlash, site } from '../lib/site';
|
||||
|
||||
const currentPath = Astro.url.pathname;
|
||||
const current =
|
||||
currentPath === '/' || currentPath.endsWith('/') || /\.[^/]+$/.test(currentPath)
|
||||
? currentPath
|
||||
: `${currentPath}/`;
|
||||
const current = normalizeTrailingSlash(Astro.url.pathname);
|
||||
|
||||
// Exact match for the current page; section match (descendant URLs) for
|
||||
// ancestor links. `aria-current="page"` is reserved for the exact page,
|
||||
|
|
@ -92,17 +88,16 @@ const headerNavItems = navItems.filter((item) => item.href !== '/' && !item.foot
|
|||
</div>
|
||||
</header>
|
||||
|
||||
<script is:inline data-theme-script>
|
||||
<script is:inline data-theme-script define:vars={{ THEME_BG }}>
|
||||
// Co-located with the button so the initial aria state is set as soon as the
|
||||
// button parses, avoiding a flash of the wrong icon. The theme itself is
|
||||
// already on <html> from theme-init.js in <head>.
|
||||
// already on <html> from theme-init.js in <head>. THEME_BG is injected from
|
||||
// src/lib/site.ts via define:vars.
|
||||
(function () {
|
||||
var root = document.documentElement;
|
||||
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: '#201f1d' };
|
||||
var themeColorMetas = document.querySelectorAll('meta[name="theme-color"]');
|
||||
|
||||
function sync(theme) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
---
|
||||
import type { CollectionEntry } from 'astro:content';
|
||||
import { isExternal } from '../lib/site';
|
||||
|
||||
type Link = CollectionEntry<'work'>['data']['links'][number];
|
||||
|
||||
|
|
@ -8,10 +9,6 @@ interface Props {
|
|||
}
|
||||
|
||||
const { links } = Astro.props;
|
||||
|
||||
function isExternal(url: string) {
|
||||
return /^https?:\/\//.test(url);
|
||||
}
|
||||
---
|
||||
|
||||
{
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
articlePath,
|
||||
entrySlug,
|
||||
getHeaderVideo,
|
||||
isExternal,
|
||||
projectCard,
|
||||
} from '../lib/site';
|
||||
|
||||
|
|
@ -24,10 +25,6 @@ const {
|
|||
eagerThumbnailCount = eagerFirstThumbnail ? 1 : 0,
|
||||
} = Astro.props;
|
||||
|
||||
function isExternal(url: string) {
|
||||
return /^https?:\/\//.test(url);
|
||||
}
|
||||
|
||||
// Project and article are the same entry now. A card links to its article when
|
||||
// the entry has a published (non-draft) `article` facet; an entry without one
|
||||
// is a project card with no article page. When that article has a header video,
|
||||
|
|
|
|||
|
|
@ -130,10 +130,10 @@ const articleFacet = ({ image }: SchemaContext) =>
|
|||
iframeThumbnail: z.boolean().default(false),
|
||||
featuredOrder: z.number().optional(),
|
||||
tags: z.array(z.enum(TAGS)).default([]),
|
||||
role: z.string().optional(),
|
||||
role: z.string().min(1).optional(),
|
||||
stack: z.array(z.string()).optional(),
|
||||
scale: z.string().optional(),
|
||||
outcome: z.string().optional(),
|
||||
scale: z.string().min(1).optional(),
|
||||
outcome: z.string().min(1).optional(),
|
||||
audience: z.enum(['general', 'technical', 'recruiter-relevant']).default('technical'),
|
||||
media: z.array(mediaSchema({ image })).default([]),
|
||||
});
|
||||
|
|
@ -158,7 +158,7 @@ const work = defineCollection({
|
|||
title: z.string(),
|
||||
description: z.string().max(160),
|
||||
thumbnail: thumbnailSchema({ image }),
|
||||
period: z.string().optional(),
|
||||
period: z.string().min(1).optional(),
|
||||
date: z.coerce.date(),
|
||||
links: z.array(linkSchema).default([]),
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
title: A 50 FPS Game Engine on an 8-Bit Microcontroller
|
||||
description: 'A handheld game built from the PCB up: ATtiny85V, OLED, IR receiver, EEPROM, 8 MHz 8-bit ALU. 50 FPS floor.'
|
||||
description: 'A handheld game built from the PCB up: ATtiny85V, OLED, IR receiver, EEPROM. 8 MHz, 8-bit ALU, a locked 20 ms frame, and a 52-byte save file.'
|
||||
date: 2026-05-06
|
||||
period: 'Spring 2020'
|
||||
thumbnail:
|
||||
|
|
@ -13,7 +13,7 @@ article:
|
|||
tags: ['embedded', 'games', 'systems']
|
||||
role: Hardware and firmware author
|
||||
stack: ['C', 'ATtiny85V', 'SPI OLED', 'IR receiver', 'EEPROM', 'KiCad']
|
||||
scale: 8 MHz, 8-bit ALU, ~31 mW at full brightness, ~1.5 mA standby, 15–20 ms frame budget
|
||||
scale: 8 MHz, 8-bit ALU, 64×32 rendered pixels at a locked 20 ms frame, ~31 mW at full brightness, ~1.5 mA standby
|
||||
outcome: A handheld built from schematic to firmware, with a 50 FPS game on it
|
||||
audience: technical
|
||||
media:
|
||||
|
|
@ -27,26 +27,43 @@ article:
|
|||
transcript: No spoken dialogue. The handheld board runs its OLED game; the player moves through the small display while the IR input controls gameplay.
|
||||
project:
|
||||
title: Ad Astra
|
||||
description: 'A handheld game built from a custom PCB up: ATtiny85V, OLED, IR, EEPROM. 8-bit ALU at 8 MHz, 50 FPS floor.'
|
||||
description: 'A handheld game built from a custom PCB up: ATtiny85V, OLED, IR, EEPROM. 8-bit ALU at 8 MHz, locked 50 FPS.'
|
||||
selected: true
|
||||
technologies: ['C', 'ATtiny85V', 'OLED', 'EEPROM', 'PCB design']
|
||||
thumbnail:
|
||||
alt: The Ad Astra handheld game running on its OLED display.
|
||||
---
|
||||
|
||||
I'd done microcontroller work on dev boards before and it always felt like I was renting the hardware. As soon as I had a real board with my own soldering on it, bugs stopped feeling like software inconveniences and started feeling like consequences of choices I'd made in KiCad. That shift was most of the value of doing it this way. Four years on from [my first hardware project](/articles/lights-synchronized-to-music/), the lesson was that owning the whole stack down to the copper changes how you debug.
|
||||
I'd done microcontroller work before, but always on someone else's dev board, and it always felt a little like renting. In spring 2020 I wanted to know what it would feel like to own the whole thing, so I drew a schematic in KiCad, ordered the PCB, and soldered an ATtiny85V onto a board with an OLED, an IR receiver, and not much else. The feeling turned out to be very specific: once the copper was mine, a glitch was never just a software inconvenience again. Every bug was the consequence of a decision I had personally made, somewhere between the board layout and the C. Four years after [my first hardware project](/articles/lights-synchronized-to-music/), this was the one where debugging stopped being guesswork about other people's choices.
|
||||
|
||||
This one is a handheld game built from the PCB up around an ATtiny85V: 8-bit ALU at 8 MHz, no FPU, no SIMD, 8 KB of flash. Anything I built had to fit inside that, or I'd be staring at a brick.
|
||||
The budget the chip gives you: an 8-bit ALU at 8 MHz, 8 KB of flash, 512 bytes of RAM, no FPU, no dedicated SPI peripheral. The game had to fit inside that, hold 50 FPS (the main loop locks every frame to 20 ms), and still leave room for the art.
|
||||
|
||||
## The bits worth showing
|
||||
## Eight pixels at a time
|
||||
|
||||
- **SIMD-on-an-8-bit-ALU display driver.** The OLED is 128×64 monochrome, 1024 bytes per frame. The driver packs four pixels into a byte and processes them with bit-parallel tricks. That's how the frame budget stayed under 20 ms with room for game logic.
|
||||
- **Prototype-based inheritance, in C.** Entities share behaviour by pointing at a struct of function pointers. No vtable, no class, no allocator. Cheap dispatch and the whole object model fits on one screen.
|
||||
- **Atomic EEPROM commits.** Sprite data and save state both live in EEPROM. The commit path writes a new region, then swaps a tiny header pointer. Pull the battery mid-write and the previous version is intact.
|
||||
- **PNG-to-C sprite pipeline.** A Python script turns PNG artwork into static C arrays the firmware can include directly. Asset workflow without ever leaving the source tree.
|
||||
The OLED is 128×64, but the firmware never sees that resolution. It renders at 64×32 and lets the panel double every pixel, which nobody notices at that physical size and which quarters the work per frame.
|
||||
|
||||
Sprites are stored as 16-bit columns: the high byte is an inverted mask, the low byte the fill bits. Compositing eight vertical pixels of sprite onto the framebuffer is one expression:
|
||||
|
||||
```c
|
||||
newColumn = oldColumn & ~invertedMask | fill;
|
||||
```
|
||||
|
||||
One AND, one OR, eight pixels handled. The chip has no SIMD, but every byte-wide operation already works on eight pixels at once if you arrange the data so that it can. Arranging for it (in the sprite format, in the compositing window, in the Python script that generates the C arrays) is most of what the display driver is. The panel itself speaks SPI, which the ATtiny doesn't have, so the driver bit-bangs it through the USI peripheral, four register writes per bit.
|
||||
|
||||
## An object model that fits on one screen
|
||||
|
||||
Every game object points at a `Prototype`, a struct holding exactly three things: a `tick` function, a `draw` function, and a size. Dispatch means following a pointer. There are at most ten live objects (one spaceship, one astronaut you steer between stations, up to four asteroids drifting past), so the "engine" is a fixed array and a for loop, and that is genuinely all the engine this game needs.
|
||||
|
||||
## The game, since you ask
|
||||
|
||||
You keep a broken spaceship alive. Asteroids drift past; you mine them and spend the proceeds on upgrades (beds, then a turret, then a table), each of which unlocks a new station to tend. Firing the turret costs health, so defending the ship is a real decision rather than a reflex. The table's interaction handler is a function called `showLove`, which is the kind of thing you find in your own four-year-old firmware and decide to leave exactly as it is.
|
||||
|
||||
## EEPROM holds the art and the saves
|
||||
|
||||
Flash was for code, so the sprites live in EEPROM, packed into the 16-bit column format by a Python script that eats PNG sheets and emits C arrays; the spaceship alone is 216 bytes. Save state is 52 bytes: hull health, astronaut count, progression, screen contrast. Writes happen byte by byte from an interrupt handler and skip any byte that hasn't changed, so saving never stalls the frame loop and the EEPROM's limited write endurance gets spared.
|
||||
|
||||
## What I'd change
|
||||
|
||||
- **A host-side emulator.** Debugging firmware directly on hardware was character-building and slow. A small SDL-based simulator linking the same C code would have shortened the iteration loop from "reflash and hope" to "rebuild and run."
|
||||
- **Power numbers I'd actually trust.** I have peak and standby draw. I don't have a curve over a real gameplay session, so I honestly can't say how long the battery lasts under load. I can only say it outlasted my patience.
|
||||
- **A development log for the driver.** The display driver and the EEPROM commit protocol are the parts I'd still defend. They deserved diagrams and measurements at the time, not the half page of comments I left them with.
|
||||
- **A host-side emulator.** Debugging on the hardware was character-building and slow. A small SDL simulator linking the same C files would have turned "reflash and hope" into "rebuild and run".
|
||||
- **Power-loss-safe saves.** The byte-by-byte writes are gentle on the EEPROM but they aren't atomic; pull the battery mid-save and you can get a torn save. A double-buffered save with a commit byte would have cost a handful of the bytes I wasn't using.
|
||||
- **Battery numbers I'd actually trust.** I measured peak and standby draw, but never a full gameplay session, so the honest answer to "how long does it last" is that it outlasted my patience.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
title: Avoid
|
||||
description: My first browser game. Tiny, archived for honesty.
|
||||
description: My first browser game, and later the handout for a small JS/Canvas workshop. Kept around so the timeline stays honest.
|
||||
date: 2026-04-29
|
||||
period: 'January 2018'
|
||||
thumbnail:
|
||||
|
|
@ -10,7 +10,7 @@ article:
|
|||
tags: ['games', 'web']
|
||||
role: Game author
|
||||
stack: ['JavaScript', 'Canvas']
|
||||
outcome: My first browser game; kept for the timeline
|
||||
outcome: My first browser game; kept for the timeline, reused for teaching
|
||||
audience: general
|
||||
project:
|
||||
description: My first browser game, kept around so the timeline is honest.
|
||||
|
|
@ -18,4 +18,8 @@ project:
|
|||
alt: Screenshot of the Avoid canvas game.
|
||||
---
|
||||
|
||||
Keeping it here because pretending the older work didn't happen would be dishonest. The first browser game I wrote, January 2018. It isn't good, but it was the moment a `<canvas>` element stopped being mysterious.
|
||||
January 2018, my first browser game. You're a peach-coloured dot; red dots stream in from the edges and chase you; the score counts how many the game has dared to spawn so far. It isn't good. I keep it here because pretending the older work didn't happen would be revisionism, and because it marks the moment a `<canvas>` element stopped being mysterious to me.
|
||||
|
||||
It had a second job, too. The README isn't documentation; it's the handout for a small JS/Canvas workshop, in Hungarian: what JavaScript is, what a 2D context gives you, why a game loop wants `requestAnimationFrame` and a delta time, why `var` is past tense. The little game turned out to be the right size for explaining things to people who'd never drawn a pixel from code before. The process advice in that handout (write working code first, then make it pretty) is still roughly the only process I follow.
|
||||
|
||||
When I archived the repo in 2022 I allowed myself to fix the retina scaling and make the page vaguely responsive, and then made myself stop.
|
||||
|
|
|
|||
|
|
@ -25,11 +25,11 @@ project:
|
|||
alt: Placeholder thumbnail for the backup container project.
|
||||
---
|
||||
|
||||
Once you self-host a few services with live databases, the backup question stops being theoretical. A Postgres or SQLite file half-written when `tar` reads it goes into the archive in a state nothing on Earth will replay; you just don't find out until the restore. Two years in, with multiple incidents I had to actually recover from (including the photos behind the [e-ink frame](/articles/frame-eink-photo-display/)), I trust this stack precisely because the correctness argument is short: BTRFS gives me an atomic snapshot, and everything above it can be a shell script. One Alpine container, ~75 lines of Bash, pushes that snapshot to one or more [Borg](https://borgbackup.readthedocs.io/) repositories on a fixed interval. Multi-target is numeric env vars (`BORG_REPO_0`, `BORG_REPO_1`, ...). No config format, no DSL; the env file is the configuration.
|
||||
Once you self-host a few services with live databases, the backup question stops being theoretical: everything on the box is mid-write at every moment of the day. This container is my answer, two years and several real restores in (including the photo library behind the [e-ink frame](/articles/frame-eink-photo-display/)). One Alpine container and four short shell scripts (the longest is 84 lines) push a BTRFS snapshot to one or more [Borg](https://borgbackup.readthedocs.io/) repositories on a fixed interval. Multi-target is numeric env vars (`BORG_REPO_0`, `BORG_REPO_1`, ...); there's no config format and no DSL, because the env file is the configuration. The design has exactly one clever moment, the snapshot, and I've worked to keep everything else too simple to break.
|
||||
|
||||
## The problem the snapshot solves
|
||||
|
||||
I self-host several databases that are mid-write at every moment of the day. `tar | borg create` against the live volume is a race: a Postgres or SQLite file that's half-written when borg reads it goes into the archive in a state nothing on Earth can replay. The "right" answer is to coordinate a quiesce with every database: a fan-out of `pg_dump`, SQLite `.backup`, Redis `BGSAVE`, and so on, all with retry, timeouts, and per-app credentials.
|
||||
`tar | borg create` against a live volume is a race: a Postgres or SQLite file that's half-written when borg reads it goes into the archive in a state nothing on Earth can replay, and you find out at restore time, which is the one moment you can least afford the discovery. The "right" answer is to coordinate a quiesce with every database: a fan-out of `pg_dump`, SQLite `.backup`, Redis `BGSAVE`, and so on, each with retries, timeouts, and per-app credentials.
|
||||
|
||||
The cheaper answer, if you've put everything on one BTRFS volume, is `btrfs subvolume snapshot`. It returns instantly with a copy-on-write fork of the entire filesystem. Every file is now atomically consistent at exactly the same instant. Run borg against the snapshot, not against the live volume.
|
||||
|
||||
|
|
@ -71,13 +71,13 @@ while true; do
|
|||
done
|
||||
```
|
||||
|
||||
A comment in the file says it out loud: "Using a simple sleep loop to schedule backups instead of cron to avoid concurrency issues." Cron with a one-hour cadence and a backup that occasionally takes 70 minutes will eventually overlap itself. The sleep-loop can't: the next run starts when the previous one is done, plus the interval. One process, one snapshot, one borg invocation. Concurrency bugs you can't have are concurrency bugs you don't have.
|
||||
A comment in the file says it out loud: "Using a simple sleep loop to schedule backups instead of cron to avoid concurrency issues." Cron with a one-hour cadence and a backup that occasionally takes 70 minutes will eventually overlap itself, and two borg processes writing the same repo is a bad afternoon. The sleep loop can't overlap: the next run starts when the previous one finishes, plus the interval. One process, one snapshot, one borg invocation, and a whole category of bug that has nowhere to live.
|
||||
|
||||
## Healthcheck is a file mtime
|
||||
|
||||
`borg create` succeeded? Write `date > /health/backup_completion_time.log`. The Docker healthcheck shells out every 10 seconds and compares that mtime against `MAX_BACKUP_AGE_SECONDS` (default 86400). Older than that, container is unhealthy and whatever's watching containers (in my case a notification hook) finds out.
|
||||
|
||||
Two subtleties worth naming:
|
||||
Two subtleties hide in there:
|
||||
|
||||
- **First-boot grace period.** If `backup_completion_time.log` doesn't exist yet (fresh container, first backup still running), fall back to `container_start_time.log` so the container isn't reported unhealthy during the first scheduled run.
|
||||
- **Partial success is not success.** In multi-target mode, the completion log is only written if _every_ target succeeded. One repo failing means the healthcheck stays red even if the other two are fine. Stale-but-quiet was the failure mode I wanted to make impossible.
|
||||
|
|
@ -91,6 +91,7 @@ Two subtleties worth naming:
|
|||
- **`--files-cache=ctime,size,inode`.** The default `mtime,size,inode` re-hashes files when their mtime changes; on BTRFS, ctime is the more honest signal of "this content actually changed."
|
||||
- **`compression=zstd,12`.** The sweet spot for backup data on my hardware: substantially better than zlib, not so slow it dominates the run.
|
||||
- **`borg compact --threshold=5 --cleanup-commits`.** Reclaims space from pruned archives whenever the segment-file fragmentation crosses 5%.
|
||||
- **Retention: 6 daily, 3 weekly, 48 monthly, 10 yearly.** Four years of monthly archives sounds extravagant until the question becomes "when exactly did this file change?", which is the question every incident eventually asks.
|
||||
- **`IGNORE_GIT_UNTRACKED=true`.** Optional. Walks every `.git` dir under the snapshot, runs `git ls-files --others --exclude-standard`, and feeds the result into `--exclude-from`. Skips `target/`, `node_modules/`, build caches; anything the repo already knows isn't worth keeping.
|
||||
- **`SYS_ADMIN` capability on the container.** Needed for `btrfs subvolume snapshot` and `delete` from inside the namespace. The narrower capability set didn't have a way through.
|
||||
|
||||
|
|
@ -100,4 +101,4 @@ Two subtleties worth naming:
|
|||
- **A failure notifier separate from the healthcheck.** Docker healthcheck-unhealthy is one signal; I'd also want an explicit push (ntfy, email, Telegram) on first failure of a run, so I don't have to be watching the container state.
|
||||
- **Parallel targets when network and disk don't compete.** The current loop is strictly sequential: rsync.net then local HDD. They share neither bandwidth nor spindles; they could run in parallel and halve the wall-clock. Sequential made the wrapper trivial; the trade was knowable and I made it.
|
||||
|
||||
Two years in, the part I'd defend hardest is the snapshot. Everything above it is a wrapper that could be rewritten in an afternoon. The snapshot is what makes the wrapper allowed to be one.
|
||||
Two years in, the part I'd defend hardest is still the snapshot. Everything above it is a wrapper anyone could rewrite in an afternoon, and that's not an apology for the wrapper, it's the property I now optimise for on purpose.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
title: A Unity City Where Bad PLC Code Made Cars Crash
|
||||
description: A REST-controlled traffic-light sim for a cybersecurity event. Bad PLC code showed up as car crashes, the most honest feedback loop I've shipped.
|
||||
description: A REST-controlled traffic-light sim for a cybersecurity event. Wrong control logic didn't print "incorrect"; it piled cars into the junction.
|
||||
date: 2026-05-01
|
||||
period: 'July-August 2018'
|
||||
thumbnail:
|
||||
|
|
@ -19,8 +19,10 @@ project:
|
|||
alt: Screenshot of a Unity city traffic simulation.
|
||||
---
|
||||
|
||||
Most security challenges punish wrong answers with a red "incorrect." This one punished them with car wrecks, and people learned faster. A PLC cybersecurity event in the summer of 2018 needed something visceral; I built a small Unity city where the traffic lights were driven by a REST API and contestants wrote the control logic.
|
||||
Most security challenges punish a wrong answer with a red "incorrect". This one punished it with car wrecks. For a PLC cybersecurity event in the summer of 2018, I built a small Unity city where the traffic lights were driven over a REST API by whatever control logic the contestants wrote. Good logic produced boring, flowing traffic. Bad logic produced intersections that people gathered around to watch.
|
||||
|
||||
All decisions ran on the server and got broadcast to clients. The harder problem wasn't the simulation; it was making the broadcast fault-tolerant on conference Wi-Fi without flooding it. I built it solo, including the models and animations in Blender. Not a flex, just context for why everything's a little janky.
|
||||
I've never seen a quiz make a lesson land that fast. A junction locking up because your ladder logic forgot a state is feedback no scoreboard can deliver, and people debugged noticeably harder once their mistakes were entertaining to everyone else in the room.
|
||||
|
||||
There was also a HUD overlay for tweets. It felt clever at the time and dated horribly. Skip that part.
|
||||
All decisions ran on the server and were broadcast to the watching clients; the genuinely difficult part wasn't the simulation but keeping that broadcast alive on conference Wi-Fi without flooding it. I built the whole thing solo, including the car and building models in Blender, which I mention less as a flex than as the explanation for how everything looks.
|
||||
|
||||
There was also a HUD overlay that showed live tweets about the event. It felt clever in 2018 and has dated exactly the way you'd guess.
|
||||
|
|
|
|||
|
|
@ -15,42 +15,44 @@ links:
|
|||
article:
|
||||
tags: ['games', 'web', 'systems']
|
||||
role: Game and backend systems author
|
||||
stack: ['TypeScript', 'Node.js', 'WebSockets', 'Firebase', 'WebGL', 'SDF-2D']
|
||||
scale: Multiple game servers, each talking to 16–32 clients, browser and mobile
|
||||
stack: ['TypeScript', 'Node.js', 'WebSockets', 'WebGL', 'SDF-2D']
|
||||
scale: Game servers ticking at 200 Hz, each serving 16–32 clients at 25 updates a second, browser and mobile
|
||||
outcome: A multiplayer browser game that proved SDF-2D survived a real game loop
|
||||
audience: technical
|
||||
media:
|
||||
- type: image
|
||||
src: ./_assets/decla-red.jpg
|
||||
alt: The decla.red browser game interface showing a space scene with team controls and planets.
|
||||
caption: A real game loop is a worse audience than a tech demo. That's the point.
|
||||
caption: Two teams, small planets, real gravity. The renderer underneath is the SDF-2D library from my thesis.
|
||||
project:
|
||||
title: decla.red
|
||||
description: Browser multiplayer where the client and server linked the same TypeScript rules module. Concurrency bugs you can't have are bugs you don't have.
|
||||
description: A multiplayer space shooter where client and server import the same rules package, the server ticks at 200 Hz, and the wire format is class names.
|
||||
selected: true
|
||||
technologies: ['TypeScript', 'Node.js', 'WebSockets', 'Firebase', 'WebGL']
|
||||
technologies: ['TypeScript', 'Node.js', 'WebSockets', 'WebGL']
|
||||
thumbnail:
|
||||
src: ./_assets/declared.jpg
|
||||
---
|
||||
|
||||
My thesis was a renderer; proving it in a real multiplayer loop was the point. A real game loop is a worse audience than a tech demo. That's the point. So through autumn 2020 I built decla.red on top of [SDF-2D](/articles/sdf-2d-ray-tracing/): a conquest-style space shooter, two teams, small planets, ray-traced 2D rendering, browser and mobile. The architecture decision worth remembering came out of needing the server and the client to stop lying to each other: one TypeScript module containing the game rules, linked by both sides of the wire.
|
||||
My BSc thesis was a renderer; decla.red was the proof it could survive contact with a real game. Through autumn 2020 I built a conquest-style space shooter on top of [SDF-2D](/articles/sdf-2d-ray-tracing/): two teams, small planets with actual gravity, ray-traced 2D rendering, playable in a browser on a phone. The decision I still think about, though, came from somewhere less glamorous: needing the client and the server to stop disagreeing about what the game even was.
|
||||
|
||||
## The split that usually goes wrong
|
||||
## One package, both sides of the wire
|
||||
|
||||
Real-time multiplayer has an awkward two-machine problem. The server has to be authoritative or the game is cheatable; the client has to feel immediate or the game is unplayable. If you write the rules twice, once on each side, they will drift. Eventually a player's screen will say one thing and the server will think another.
|
||||
Real-time multiplayer hands you an awkward two-machine problem. The server has to be authoritative or the game is cheatable; the client has to feel immediate or the game is unplayable. Write the rules twice, once per side, and they drift: slowly at first, then visibly, with a player's screen saying one thing while the server believes another.
|
||||
|
||||
I wanted the server's "compute the next state" function and the client's "predict the next state locally" function to be literally the same function. So I put the rules in a shared TypeScript library, published nothing, and had both `package.json` files link to it.
|
||||
So the object model lives in a single `shared` package that both `package.json` files pull in with `file:../shared`. Every entity exists in three layers: a `Base` class in the shared package (the wire shape and the rules both sides must agree on), a `Physical` subclass only the server instantiates (forces, collisions, health), and a `View` subclass only the client does (meshes, lights). The server simulates at 200 Hz. Clients hear from it 25 times a second, and each update carries not just values but their rates of change, so the client-side extrapolators can keep planets and projectiles moving believably between packets instead of teleporting them on arrival.
|
||||
|
||||
The win wasn't elegance, it was the bugs that didn't happen. Client prediction stopped being an approximation of the server; it _was_ the server, run optimistically and reconciled when the authoritative update came back.
|
||||
The serialisation format is the part that makes me smile now: an object crosses the socket as `[className, ...fields]`, numbers rounded to three decimals. The class name _is_ the protocol, which means the minifier has to be told, in the webpack config, never to rename game classes. It taught me early that "works in dev, breaks in prod" usually means a build tool was being clever somewhere out of sight.
|
||||
|
||||
## Other choices worth a sentence
|
||||
|
||||
- **k-d trees for spatial queries.** Once the world held more than a few dozen objects, naive collision and proximity checks dominated the server tick. A k-d tree dropped them out of the profile.
|
||||
- **Message-passing object model.** Lifted from Smalltalk's `doesNotUnderstand:` idea. Entities respond to messages they care about and ignore the rest. Easier to extend than the inheritance tree I tried first, and less brittle.
|
||||
- **Firebase only for server discovery.** Not for game state, just for "which servers are currently in the pool." Tiny consistent store, didn't need to write one.
|
||||
- **A k-d tree over bounding boxes.** Once the world held more than a few dozen objects, naive collision and proximity checks dominated the server tick. The tree dropped them out of the profile.
|
||||
- **Message passing with a default handler.** Entities extend a `CommandReceiver` that dispatches commands by type and routes anything unhandled to a default executor: Smalltalk's `doesNotUnderstand:`, reborn in TypeScript. Extending the game meant adding a command, not reorganising an inheritance tree.
|
||||
- **Bots that perceive at 30 Hz and plan at 1 Hz.** Letting NPCs re-run their spatial queries on every 200 Hz physics tick was the server's dominant cost. A comment in `npc.ts` argues that a few dozen refreshes a second is plenty for dodging a projectile, and it's right.
|
||||
- **Interest management with hysteresis.** Each player only hears about objects near their view, with the enter threshold (1.2× the viewport) deliberately wider than the exit one (0.9×), so objects sitting on the boundary don't flicker in and out of existence.
|
||||
- **Server discovery, eventually boring.** The join screen originally asked Firebase Remote Config which servers existed. Today it's a hardcoded list polled over HTTP, which is what it should have been from the start.
|
||||
|
||||
## What I'd change
|
||||
|
||||
- **Observability for desync.** Multiplayer systems live or die by visibility into divergence. I had logs; I needed dashboards showing the rate, the shape, and the triggering interaction for every prediction miss. Without those, debugging was guessing.
|
||||
- **Don't tangle rendering and networking in the same tree.** Both were interesting, both put different kinds of pressure on the architecture, and the directories grew into each other. Separate top-level folders from day one next time.
|
||||
- **Skip multi-server until the math demands it.** I wired up multi-server early because it sounded right. With 16–32 clients per server I was nowhere near needing it; the complexity wasn't free.
|
||||
- **Observability for desync.** Multiplayer systems live or die by visibility into divergence. I had logs; I needed to see the rate, the shape, and the triggering interaction for every extrapolation miss. Without that, debugging was guessing.
|
||||
- **Untangle rendering from networking.** Both were interesting, both pushed on the architecture in different directions, and their directories slowly grew into each other. Separate top-level homes from day one next time.
|
||||
- **Skip multi-server until the maths demands it.** I wired up multiple game servers early because it sounded like the serious thing to do. At 16–32 players per server I was nowhere near needing it, and the complexity wasn't free.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
title: A Physics Practice App for the Hungarian Érettségi
|
||||
description: A static jQuery site I built in high school to drill past exam questions. 659 questions, a decade of past papers, still online and still used.
|
||||
description: A static jQuery site I built in high school to drill past exam questions. 682 questions, twenty years of past papers, still online and still used.
|
||||
date: 2026-05-28
|
||||
period: '2017-2018'
|
||||
thumbnail:
|
||||
|
|
@ -19,22 +19,24 @@ article:
|
|||
audience: general
|
||||
project:
|
||||
title: Fizika
|
||||
description: 'I needed it for my own physics érettségi: 659 past-paper questions, jQuery, localStorage, no accounts. Eight years on, students still find it.'
|
||||
description: 'I needed it for my own physics érettségi: 682 past-paper questions, jQuery, localStorage, no accounts. Eight years on, students still find it.'
|
||||
technologies: ['jQuery', 'HTML/CSS', 'Node/Express', 'JSON', 'localStorage']
|
||||
thumbnail:
|
||||
alt: Screenshot of the Fizika practice app showing topic-selection buttons.
|
||||
---
|
||||
|
||||
I needed it. In my last year of high school I was about to sit the _emelt szintű_ (advanced-level) physics érettségi, and the practice material I could find online was either paywalled or scattered across PDFs that wouldn't tell you whether your answer was right. So one evening I started typing past exam questions into a JSON file. A few weeks later I had something resembling a study tool, and a few weeks after that I had 659 questions covering more than a decade of past papers.
|
||||
I needed it. In my last year of high school I was about to sit the advanced-level (_emelt szintű_) physics érettségi, and the practice material I could find online was either paywalled or scattered across PDFs that wouldn't tell you whether your answer was right. So one evening in 2017 I started typing past exam questions into a JSON file.
|
||||
|
||||
The site is intentionally small. A static frontend on jQuery, four CSS files, a JSON blob of questions, a folder of scanned diagrams from the original papers. You pick a topic (_Mechanika, Hőtan, Elektromosság, Atomfizika_) or hunt down a specific year's exam, get a randomised quiz, answer, and the page colours each row green or red. Past results sit in `localStorage`, because the audience was high schoolers; account-less was the privacy answer.
|
||||
That file is the real project. It has grown to 682 questions covering twenty years of past papers, 2005 through 2025, alongside 247 diagrams scanned from the original exam sheets. Each question knows its source down to the task number (`"2016/m1/1"`), which is what lets you either drill a whole topic or sit a specific year's paper. The topics run from five flavours of mechanics down to a handful of astronomy questions, fifteen codes in all. Everything else (the site, the backend, the admin page) is a wrapper around those weeks of typing.
|
||||
|
||||
It outgrew Firebase eventually. I moved the data to a small Express backend so I could keep editing questions without a paid plan, with a JSON file and an image folder as the storage layer. The admin routes have no auth; instead, the service stays off the public internet and I edit through an SSH-forwarded localhost. Fine for a one-person CMS, terrible advice for anything with multiple editors.
|
||||
The wrapper is intentionally small. A static frontend on jQuery, four CSS files, the JSON blob, the scans. Pick a topic or a year, get a randomised quiz, answer, and the page colours each row green or red and writes your percentage into `localStorage`. No accounts, ever: the audience was high schoolers, and account-less was the privacy answer.
|
||||
|
||||
What I'd change if I were starting it now:
|
||||
The plumbing underneath has been rebuilt twice without the product changing at all. It started fully static, spent a few years on Firebase, and now sits on a small Express server whose entire storage layer is the JSON file and an image folder, which means I can edit questions through a little admin page instead of raw JSON. The admin routes themselves don't authenticate anyone; that job belongs to the reverse proxy in front, where nginx demands basic auth before anything reaches the app. A reasonable arrangement for a one-person CMS, and terrible advice for anything with more than one editor.
|
||||
|
||||
- **Astro instead of jQuery plus a Node server.** The whole thing could be one static site that re-renders on push. No backend, no CSP fiddling, no Docker.
|
||||
- **Markdown source, not a hand-edited JSON file.** Editing questions in JSON is fine until you forget a comma at 1am and the site stops loading.
|
||||
- **A real licence note on the question text.** The papers are public exam material, but it's worth saying so somewhere on the page.
|
||||
What I'd change if I were starting now:
|
||||
|
||||
It's been online in some form for eight years. Every spring I get a few emails from students asking whether I'll add the latest year's paper. I usually do, eventually. The thing I made for myself in 2017 is still doing its job for someone else's last year of high school, and that's the only metric on it I actually care about.
|
||||
- **Astro instead of jQuery plus a Node server.** The whole thing could be one static site that re-renders on push. No backend, no Docker, nothing to keep alive.
|
||||
- **Markdown source, not hand-edited JSON.** Editing questions in JSON is fine until you drop a comma at 1am and the site stops loading.
|
||||
- **A licence note on the question text.** The papers are public exam material, but the page should say so.
|
||||
|
||||
It's been online in some form for eight years. Every spring a few emails arrive from students asking whether I'll add the latest year's paper, and I usually do, eventually; that's how a database typed up for my own exam in 2017 now ends at 2025. The thing I made for myself is still doing its job for someone else's last year of high school, and that's the only metric on it I care about.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
title: A WebGPU Drawing Garden Where Agents Rewrite Your Strokes
|
||||
description: A single-file WebGPU drawing toy. You stroke a colour, agents follow it, and a 3×3 matrix per vibe gives each preset its personality.
|
||||
description: A single-file WebGPU drawing toy. You stroke a colour, a million agents follow it, and a 3×3 matrix of -1s, 0s, and 1s gives each vibe its personality.
|
||||
date: 2026-05-22
|
||||
period: '2026'
|
||||
thumbnail:
|
||||
|
|
@ -16,7 +16,7 @@ article:
|
|||
tags: ['graphics', 'simulation', 'web']
|
||||
role: Graphics and shader author
|
||||
stack: ['TypeScript', 'WebGPU', 'WGSL', 'Compute shaders', 'Vite', 'Tweakpane']
|
||||
scale: One HTML file, ~10 WGSL shaders, 6 vibe presets, 60 FPS target on consumer hardware
|
||||
scale: One HTML file, 10 WGSL shaders, 6 vibe presets, up to 1.5M agents, 60 FPS target on consumer hardware
|
||||
outcome: A browser drawing toy where user strokes seed an agent simulation that overwrites them
|
||||
audience: technical
|
||||
media:
|
||||
|
|
@ -26,51 +26,53 @@ article:
|
|||
caption: A snapshot from one session. What you see is the trail texture; the agents that drew it are already gone.
|
||||
project:
|
||||
title: Fleeting Garden
|
||||
description: A single-file WebGPU drawing toy. Your strokes seed a swarm; nine numbers per vibe give each preset its personality.
|
||||
description: A single-file WebGPU drawing toy. Your strokes seed a swarm of up to a million agents; nine numbers per vibe give each preset its personality.
|
||||
selected: true
|
||||
---
|
||||
|
||||
Nine numbers in `{-1, 0, 1}` arranged in a 3×3 matrix decide an entire vibe's personality. That constraint is what kept me up: proving simplicity can be expressive, that you don't need a behaviour function per preset. A WebGPU drawing toy where you stroke a colour, agents spawn along it, and the garden slowly overwrites the patch you laid down. One static HTML file, six compute stages, none of them skippable.
|
||||
Fleeting Garden is a WebGPU drawing toy: you stroke a colour onto the canvas, agents spawn along the stroke, and over the next minute the garden slowly overwrites the patch you laid down. The question that kept me at it was smaller and stranger than the graphics: whether nine numbers, each of them -1, 0, or 1, could be an entire personality.
|
||||
|
||||
## Why physarum needed a knob
|
||||
|
||||
Physarum-style agent sims are everywhere and most of them stop being interesting after thirty seconds, because they converge to the same family of branching shapes no matter what you feed them. Seeding the initial condition isn't enough; the input has to keep being a force inside the loop, otherwise you're just watching the attractor settle.
|
||||
Physarum-style agent simulations are everywhere, and most of them stop being interesting after thirty seconds, because they converge to the same family of branching shapes no matter what you feed them. Seeding the initial condition isn't enough; the user's input has to stay a live force inside the loop, or you're just watching an attractor settle.
|
||||
|
||||
My second self-imposed constraint was that one engine had to produce six visibly different presets without forking. The first prototype had a `switch (preset)` with one behaviour function per vibe and it was already painful at vibe two. I needed the personality to live in data, not code.
|
||||
I also wanted one engine to produce six visibly different moods without forking. The first prototype had a `switch (preset)` with a behaviour function per vibe, and it was already painful by vibe two. The personality had to move out of the code and into data.
|
||||
|
||||
## The reaction matrix
|
||||
## Nine numbers
|
||||
|
||||
Each vibe is a 3×3 table of colour-to-colour affinities. When an agent of colour `i` looks at the trail in front of it, it weights the three channels of that sample by row `i` of the matrix, then uses the sign to pick left, right, or straight. That's it. The whole behaviour rule.
|
||||
Each vibe is a 3×3 table of colour-to-colour affinities, every entry -1, 0, or 1. When an agent of colour `i` samples the trail ahead of it, it weights the three channels by row `i` of the matrix and uses the sign to pick left, right, or straight. That's the entire behaviour rule, and it turns out to be enough:
|
||||
|
||||
Three examples of what nine numbers can do:
|
||||
- **Aurora Mycelium** is cyclic: each colour chases the next, and agents wind into ribbons.
|
||||
- **Velvet Observatory** has every off-diagonal entry negative, so the colours repel into separate islands.
|
||||
- **Paper Lantern Fog** is all ones; the colours collapse into one slow cooperative blob.
|
||||
|
||||
- **Aurora Mycelium:** cyclic, each colour chases the next. Agents wind into ribbons.
|
||||
- **Velvet Observatory:** every off-diagonal entry negative. Colours repel into separate islands.
|
||||
- **Paper Lantern Fog:** matrix filled with ones. Colours collapse into one cooperative blob.
|
||||
The other three (Lichen Signal, Tidepool Lantern, Chrome Pollen) sit between those poles, separated as much by their tuning as by their matrices: Chrome Pollen's agents look 14 pixels ahead and spawn densely, while Paper Lantern Fog's look 66 pixels ahead and drift at a fifth of the speed. Adding a tenth number would tax every existing vibe; tuning the nine I have is a text edit. Six presets in, I haven't needed to extend it.
|
||||
|
||||
Adding a tenth number to the matrix would tax every existing vibe. Tuning the nine I have is a text edit. Six presets in, I haven't extended it.
|
||||
## Each vibe also has a key signature
|
||||
|
||||
## The compute work, broken into small jobs
|
||||
The part the screenshots can't show: the garden plays piano. Strokes trigger sampled notes (velocity-layered recordings, loaded six at a time so the page doesn't stall), and every vibe carries its own scale and four-chord progression. Aurora Mycelium ambles through a sus2–major–minor–sus4 progression in Lydian at 60 BPM, others get Dorian or Mixolydian or natural minor. It's the same trick as the matrix in a different domain: the music engine is one piece of code, and each vibe is just data that flavours it.
|
||||
|
||||
Six stages, ten WGSL files, each one short enough that I can hold it in my head when something breaks:
|
||||
## The compute work, in small jobs
|
||||
|
||||
1. **Agent step:** sample the trail at a sensor offset, pick a turn, move, deposit colour. ~300 lines, the longest one.
|
||||
2. **Diffusion:** blur and decay so old marks soften. The boring one, and the one you can't skip: without it, strokes stay forever and the garden collapses into noise.
|
||||
3. **Brush:** write user strokes into both the trail texture and a separate "source" texture the agents can read.
|
||||
4. **Eraser:** two variants: one clears a region of the trail, the other kills agents in a radius.
|
||||
5. **Agent generation:** spawn along strokes, resize the buffer when the cap changes, compact after erasure so dead slots don't waste GPU time.
|
||||
6. **Render:** read the trail, apply palette and grain.
|
||||
Six stages across ten WGSL files, each short enough to hold in my head when something breaks:
|
||||
|
||||
The bind-group setup overhead from running more pipelines was lost in the noise next to the simulation cost. The win was that when the eraser shader started killing the wrong agents, I opened one file and reasoned about it without touching anything else.
|
||||
1. **Agent step.** Sample the trail at the sensor offsets, pick a turn, move, deposit colour. At ~300 lines, the longest.
|
||||
2. **Diffusion.** Blur and decay so old marks soften. The boring stage, and the one you can't skip: without it, strokes never fade and the garden hardens into noise.
|
||||
3. **Brush.** Write user strokes into the trail texture and a separate source texture the agents can read.
|
||||
4. **Eraser.** Two variants, one clearing a region of trail, the other killing agents in a radius.
|
||||
5. **Agent generation.** Spawn along strokes, resize the buffer when the cap changes, compact after erasure so dead slots don't burn GPU time.
|
||||
6. **Render.** Read the trail, apply palette and grain.
|
||||
|
||||
Splitting it this finely costs some bind-group setup, which disappears in the noise next to the simulation itself. What I got back: when the eraser started killing the wrong agents, the bug had exactly one file to hide in.
|
||||
|
||||
## Smaller calls
|
||||
|
||||
- **Adaptive cap, circular buffer.** If FPS drops, the cap shrinks; if there's headroom, it grows. When the cap is hit, new agents overwrite older ones. The decay you see, a stroke vanishing thirty seconds after you drew it, isn't an explicit eraser, it's the buffer wrapping around.
|
||||
- **URL is the share format.** The chosen vibe is in the query string. The "send your friend this preset" link is just a URL with `?vibe=tidepool-lantern` on it. The parser is tolerant about accents and casing because people retype these.
|
||||
- **One HTML file.** All CSS and JS inline. The piano samples sit beside it. Self-contained enough to email or drop on a USB stick.
|
||||
- **An adaptive cap over a circular buffer.** The population starts allowed up to a million agents; if the frame rate dips, the cap shrinks (never below 50,000), and when the cap is hit, new agents overwrite the oldest. The melancholy effect, your stroke dissolving thirty seconds after you drew it, isn't an explicit eraser. It's the buffer wrapping around.
|
||||
- **The URL is the share format.** "Send your friend this preset" is a link with `?vibe=tidepool-lantern` on it, and the parser forgives accents and casing because people retype these by hand.
|
||||
- **One HTML file.** All CSS and JS inline, the piano samples beside it. Self-contained enough to email.
|
||||
|
||||
## What I'd change
|
||||
|
||||
- The intro animation (agents fly in to spell the title, then transition to steady state) couples three shaders through a single `progress: 0 → 1` value. It's the bit I'd least want to refactor today. Next time I'd model the intro as its own dispatch with its own buffer and hand off cleanly.
|
||||
- Mobile works, but the toolbar fights the canvas for screen and the agent cap has to shrink hard to keep frame time down. A proper fix means rethinking the toolbar and exposing the cap-vs-resolution tradeoff to the user.
|
||||
- The simulation has invariants that proptest would falsify in minutes: agent count under the cap, every stroke produces a positive-coloured deposit on the next frame, and the eraser doesn't leak agents past its radius. Snapshot tests aren't the right tool here.
|
||||
- The intro (180,000 agents fly in from the rim, spell out "Fleeting" over four seconds, then disperse into the steady state) couples three shaders through a single `progress` value, and it's the code I'd least like to touch today. There's a workable apology in the agent shader (a second entry point, `mainSteady()`, that drops the intro's dead branches once it's over), but the honest fix is giving the intro its own dispatch and buffer and a clean handoff.
|
||||
- Mobile works, but the toolbar fights the canvas for space and the agent cap has to shrink hard to keep frame time down. A real fix means rethinking the toolbar and letting the user trade resolution against population themselves.
|
||||
- The simulation has invariants a property-based tester would falsify in minutes: population stays under the cap, every stroke deposits colour on the next frame, the eraser never leaks agents past its radius. Snapshot tests aren't the right tool here, and I haven't written the right one yet.
|
||||
|
|
|
|||
|
|
@ -19,16 +19,8 @@ project:
|
|||
alt: Chart from a foreign exchange prediction experiment.
|
||||
---
|
||||
|
||||
In the autumn of 2019 I was an undergrad with a few weekends free and the quiet conviction that I could find a small edge on EUR/USD. The screenshots were flattering: the prediction (blue) hugged the actual rate (green) in a way that looked like skill. A linear regression in the frequency domain, dressed up. I did not trade real money with it, and that restraint is the only thing about the project that aged well.
|
||||
In the autumn of 2019 I was an undergrad with a few free weekends and the quiet conviction that I could find a small edge on EUR/USD. The screenshots that survive are flattering: the predicted rate in blue hugging the actual rate in green closely enough to look like skill. It was a linear extrapolation in the frequency domain wearing a nice coat.
|
||||
|
||||
The pipeline:
|
||||
The pipeline: smooth the price series, differentiate it, run a short-time Fourier transform over overlapped Hanning-windowed frames, extrapolate the frequency-domain coefficients forward, then invert everything back into a predicted price. A Python server (NumPy, SciPy, Flask) served the model; an MQL4 client sitting in a broker terminal called it and stood ready to place trades, if I'd dared. I never dared, and that restraint is the part of the project that aged best.
|
||||
|
||||
- Smooth the input series.
|
||||
- Differentiate.
|
||||
- Short-time Fourier transform with overlapped, Hanning-windowed frames.
|
||||
- Extrapolate the frequency-domain coefficients.
|
||||
- Invert everything back to a predicted price series.
|
||||
|
||||
A Python server (NumPy, SciPy, Flask) ran the model. An MQL4 client on a broker terminal called the server and would have placed trades if I'd dared.
|
||||
|
||||
What I actually learned: even a naive model can show a sometimes-profitable backtest, and that's the trap. The real game is built by people with co-located servers, microsecond ticks, and millions in infrastructure. This project taught me how far my edge wasn't.
|
||||
What the weekends actually bought me was a working understanding of the trap: even a naive model will hand you a sometimes-profitable backtest, and a sometimes-profitable backtest is the most persuasive wrong evidence there is. The people playing this game for real have co-located servers, microsecond ticks, and teams whose whole job is the thing I was doing between lectures. I didn't learn how to predict currencies. I learned, precisely and cheaply, how far my edge wasn't, which I've come to think is the best possible return on a project like this.
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ So it's a Raspberry Pi Zero 2W driving Waveshare's [PhotoPainter](https://www.wa
|
|||
|
||||
## Why a stupid amount of engineering for a picture on a wall
|
||||
|
||||
That's the point. Albert Borgmann once distinguished _devices_ (which efficiently deliver a commodity and disappear into the wall) from _focal things_, which gather a practice around them. A Nest Hub is a device; it shows you photos the way a microwave delivers heat. The frame is a focal thing. I curated the weights. I hung it where the light was right. I tweak it when something feels off. It doesn't sell my attention back to me; it asks me to pay some.
|
||||
Because the engineering is how you end up caring about the object. Albert Borgmann distinguished _devices_, which efficiently deliver a commodity and disappear into the wall, from _focal things_, which gather a practice around them. A Nest Hub is a device; it shows you photos the way a microwave delivers heat. The frame became a focal thing somewhere along the way: I curated the weights, hung it where the light is right, and still tweak it when something feels off. It doesn't sell my attention back to me; it asks me to pay some.
|
||||
|
||||
The medium helps. E-ink doesn't glow and doesn't beep. From across the room it reads as _image_, not as _screen_, and that one perceptual difference changes how often I actually look at it.
|
||||
|
||||
|
|
@ -60,7 +60,7 @@ The medium helps. E-ink doesn't glow and doesn't beep. From across the room it r
|
|||
|
||||
The cron line does most of the work. Every 15 minutes, the script checks the time of day, then asks Home Assistant whether anyone in `HA_PRESENCE` is home. If not, it quits. The panel keeps showing the last photo, because e-ink, so you walk in to whatever was there when the house emptied.
|
||||
|
||||
The point isn't power saving. John Berger drew a line between photographs kept inside a context of lived meaning (private), and ones severed and circulated (public). Google Photos hands you the public mode dressed as the private. A wall in the hallway, lit only when your people are home, restores the context. The same photograph means something different surfacing while you're cooking dinner than it does in a feed at 11pm.
|
||||
This was never about power saving. John Berger drew a line between photographs kept inside a context of lived meaning (private) and ones severed from it and circulated (public), and Google Photos hands you the public mode dressed as the private one. A wall in the hallway, refreshed only while your people are home, restores some of the context. The same photograph means something different surfacing while you're cooking dinner than it does in a feed at 11pm.
|
||||
|
||||
## How a photo gets picked
|
||||
|
||||
|
|
@ -83,6 +83,8 @@ Hundred Rabbits, a couple who live offshore on a sailboat doing permacomputing i
|
|||
## Smaller calls
|
||||
|
||||
- **Capture age and EXIF location painted as text.** White on a black stroke, written _after_ dithering, so the labels stay sharp on the 6-colour palette.
|
||||
- **A lock file, because refreshes take a while.** A render can run past 12 seconds; `flock` on a lock file makes an overlapping cron tick exit quietly instead of fighting the panel.
|
||||
- **When every candidate fails the head check,** the picker quietly falls back to photos it has shown before. A repeat on the wall beats a sliced face.
|
||||
- **Swap masked, journald volatile.** The SD card is the most likely thing to die on this build. Don't write to it unless you have to.
|
||||
- **Wifi power-save reconnect job.** The Pi Zero 2W's wifi drops if power-save kicks in. A separate `wifi-check.sh` every five minutes brings it back.
|
||||
|
||||
|
|
@ -92,4 +94,4 @@ Hundred Rabbits, a couple who live offshore on a sailboat doing permacomputing i
|
|||
- **A bigger panel and a small light.** The [Inky Impression](https://shop.pimoroni.com/products/inky-impression) 13" with a custom frame and integrated lighting would help most in the evenings, when the e-ink reads muddled under warm lamps.
|
||||
- **A daytime cadence curve.** 15 minutes is constant. It should slow at night and speed up around the times we're actually in the hallway.
|
||||
|
||||
The frame is small, slow, and almost entirely silent. It does one thing for one household and doesn't tell anyone about it. The smallness is the point. There should be more of this kind of thing.
|
||||
The frame is small, slow, and almost entirely silent. It does one thing for one household and reports to no one. I'd like more of my software to be like that.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
title: A JavaFX Editor for the Cooling Simulator
|
||||
description: Companion editor for the cooling-system sim. Drag-and-drop graph layout, JSON export, upload-to-backend. Small tool, mattered more than I expected.
|
||||
description: Companion editor for the cooling-system sim. Drag-and-drop plant layout, JSON export, upload-to-backend. The smallest tool, and the one the event leaned on.
|
||||
date: 2026-04-25
|
||||
period: 'October-November 2018'
|
||||
thumbnail:
|
||||
|
|
@ -19,6 +19,8 @@ project:
|
|||
alt: JavaFX editor interface for the cooling system simulator input graph.
|
||||
---
|
||||
|
||||
Non-technical event organisers needed to rewire a cooling plant in real time without me hovering. That was the brief, and it ruled out every interface I'd have enjoyed writing. The [cooling system sim](/articles/nuclear-cooling-simulation/) was only as useful as the tool that fed it, so in late 2018 I built a JavaFX desktop editor: lay out the plant as a graph, edit each element's parameters in a side panel, export JSON, or upload straight to the backend.
|
||||
The brief was one sentence: non-technical event organisers needed to rewire a simulated cooling plant, live, without me hovering behind them. That sentence quietly ruled out every interface I would have enjoyed building: config files, a DSL, anything with a curly brace in it. The [cooling-system sim](/articles/nuclear-cooling-simulation/) was only going to be as useful as the tool that fed it, so in late 2018 I built a JavaFX desktop editor: lay the plant out as a graph, drag pipes between pumps and coolers, tune each element's parameters in a side panel, then export JSON or upload it straight to the backend.
|
||||
|
||||
Small tool, and the whole event hinged on it. If I built it again I'd skip JavaFX and put the editor in the browser next to the monitoring clients. One install fewer for everyone, and one fewer reason for someone to call me over.
|
||||
It was the smallest piece of the whole project and the piece the event quietly depended on. A simulator only its author can reconfigure is a demo; the editor is what made it a tool other people could own for a weekend.
|
||||
|
||||
If I built it again I'd skip JavaFX and put the editor in the browser, next to the monitoring clients. Cross-platform desktop packaging was a fight, and every install I asked of the organisers was one more reason for them to call me over, which was the exact thing the brief said to prevent.
|
||||
|
|
|
|||
|
|
@ -34,26 +34,29 @@ project:
|
|||
technologies: ['Python', 'ML deployment', 'API design']
|
||||
---
|
||||
|
||||
By the end of 2021 I had stopped believing the people skipping ML deployment best practices were the problem. They knew the list. They agreed with the list. They had a deadline, and every item on the list cost five lines of glue. My MSc thesis turned that into the actual research question: not "what should engineers do" but "what API shape makes doing the right thing cheaper than not." The framework that fell out, `great-ai`, is a decorator on a plain Python function. The thesis behind it is the part worth reading.
|
||||
By the end of 2021 I had stopped believing that the people skipping ML deployment best practices were the problem. They knew the list. They agreed with the list. They had a deadline, and every item on the list cost five lines of glue. My MSc thesis turned that observation into the actual research question: not "what should engineers do" but "what API shape makes doing the right thing cheaper than not doing it". The framework that came out the other end, `great-ai`, is a decorator you put on a plain Python function. The thesis behind it is the part I'd still hand someone today.
|
||||
|
||||
## The thing nobody wants to admit
|
||||
|
||||
The literature has a long list of habits you should adopt when shipping an ML service: track inputs, version models, expose health, log decisions, keep predictions reproducible. Everyone agrees with the list. Almost nobody implements all of it.
|
||||
The literature has a long list of habits you're supposed to adopt when shipping an ML service: track inputs, version models, expose health, log decisions, keep predictions reproducible. Everyone agrees with the list; almost nobody implements all of it. I spent the bulk of the thesis cataloguing 33 such habits, proposing 6 more, and surveying engineers about which ones actually got applied in their day jobs. The pattern in the answers wasn't ignorance, laziness, or budget. It was that the visible cost of each habit (a few lines of glue, multiplied across a whole stack) kept outbidding the invisible cost of skipping it. So skipping it became the default, even among people who'd argue passionately for the list.
|
||||
|
||||
I spent the bulk of the thesis catalogueing 33 such habits, proposing 6 more, and surveying engineers on which actually got applied in their day jobs. The data was pretty clear about the failure mode: it wasn't ignorance, it wasn't laziness, it wasn't budget. It was that the cost of doing the right thing, five lines of glue per habit multiplied across a stack, was higher than the visible cost of skipping it. So skipping it became the default.
|
||||
You don't fix a default with another lecture. You fix it by repricing the choice.
|
||||
|
||||
So the real research question wasn't "what should engineers do." It was "what API shape makes doing the right thing cheaper than not."
|
||||
## What one decorator buys
|
||||
|
||||
## The framework's bet
|
||||
`@GreatAI.create` takes a regular prediction function and gives back a deployed service: a REST endpoint whose request schema is derived from the function's own signature, a trace store that records every prediction with inputs, latency, and model versions (a local JSON database by default, MongoDB if you point it at one), a live dashboard with execution-time histograms and a filterable trace table, an LRU cache, and argument validation straight from the type hints. A second decorator, `@use_model`, injects versioned model artefacts from disk or S3 and stamps the version it used into every trace, so "which model said this?" always has an answer.
|
||||
|
||||
- **A decorator on a plain function.** `@GreatAI.create` turns a regular Python function into a deployed service with metadata, request tracing, and a versioned interface. No inheritance, no project layout, no enforced directory structure. The mental cost is one import.
|
||||
- **Implicit behaviour only for cross-cutting concerns.** Logging, versioning, metadata are implicit. Anything touching business logic stays explicit. The rule: if it would surprise me when I'm debugging, it shouldn't be implicit.
|
||||
- **Own the contract, leave the storage alone.** Where you persist logs, models, or metrics is your choice; GreatAI defines the shape and provides defaults. The model registry stays somebody else's library.
|
||||
Two rules kept the API surface from sprawling:
|
||||
|
||||
The survey backed up the central premise: ease of use and functionality both matter for adoption, and they're independent axes. A framework that ticks every box and is awkward will lose to a smaller one that doesn't.
|
||||
- **Implicit behaviour only for cross-cutting concerns.** Logging, versioning, and tracing happen without being asked. Anything touching business logic stays explicit. The test I used: if it would surprise me while debugging, it doesn't get to be implicit.
|
||||
- **Own the contract, leave the storage alone.** GreatAI defines the shape of a trace and provides defaults; where you persist things is your choice. The model registry stays somebody else's library.
|
||||
|
||||
## Measuring whether it worked
|
||||
|
||||
Opinions about API design are cheap, so I tried to buy slightly better ones. Working engineers got a deliberately mundane task (take a half-finished notebook pair training a financial-sentiment classifier, fill in the TODOs, and deploy it with GreatAI) followed by a standard technology-acceptance questionnaire. The sample was small (ten respondents, so directional rather than gospel), but the signal was consistent: perceived ease of use tracked intent to adopt almost linearly (r ≈ 0.8), independently of how much the respondents valued the functionality itself. Which was the thesis's premise restated as data: a framework that ticks every box but is awkward will lose to a smaller one that isn't.
|
||||
|
||||
## What I'd change
|
||||
|
||||
- I'd narrow further. Anything GreatAI did that overlapped with MLflow, BentoML, or modern observability stacks would go. The durable bit was always the decorator and the catalogue behind it.
|
||||
- I'd publish the survey instrument separately. The 33-habit catalogue and the adoption-vs-impact methodology outlive the framework. People still ask about that part.
|
||||
- I'd stop calling them "best practices." I used that phrase in the thesis and it aged into corporate-speak. The honest name is "things that hurt later if you skip them."
|
||||
- I'd narrow it further. Anything GreatAI did that overlaps with MLflow, BentoML, or a modern observability stack would go. The durable part was always the decorator and the catalogue behind it, not the dashboard.
|
||||
- I'd publish the survey instrument separately. The 33-habit catalogue and the adoption-versus-impact methodology outlive the framework; it's the part people still ask about.
|
||||
- I'd stop saying "best practices". I used the phrase throughout the thesis and it has aged into corporate wallpaper. The honest name is "things that hurt later if you skip them".
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
title: Syncing State with an Immutable Trie
|
||||
description: 'A visual goal tracker whose lasting idea was the sync model: an immutable trie so structural diffs are trivial and only deltas cross the wire.'
|
||||
title: The Sync Protocol I Designed and the One I Shipped
|
||||
description: 'A 2019 goal tracker. I designed delta sync over an immutable trie; what shipped was the whole tree plus a version counter, and that was the better idea.'
|
||||
date: 2026-05-05
|
||||
period: 'August-September 2019'
|
||||
thumbnail:
|
||||
|
|
@ -13,52 +13,45 @@ article:
|
|||
featuredOrder: 4
|
||||
tags: ['systems', 'web', 'tools']
|
||||
role: Full-stack author
|
||||
stack: ['Python', 'Angular', 'TypeScript', 'Custom sync protocol']
|
||||
stack: ['Python', 'Angular', 'TypeScript', 'FastAPI', 'SQLite']
|
||||
scale: Multi-device goal and task state shared between clients and a server
|
||||
outcome: A working sync protocol where structural sharing made the delta tiny
|
||||
outcome: A goal tracker still in use, and a lesson about when clever sync isn't worth it
|
||||
audience: recruiter-relevant
|
||||
media:
|
||||
- type: image
|
||||
src: ./_assets/towers.jpg
|
||||
alt: Screenshot of a life tracking web interface represented with tower-like visual structures.
|
||||
caption: The interface was a 2019 weekend experiment. The trie underneath aged better.
|
||||
caption: Towers of finished tasks, one column per goal. Done blocks fall into place with a small gravity animation.
|
||||
project:
|
||||
title: Life Towers
|
||||
description: A multi-device goal tracker. The trie underneath made the sync diff free; the towers were just the UI.
|
||||
description: A multi-device goal tracker where the clever trie lost to send-the-whole-tree with a version counter. The towers were the UI; the protocol was the lesson.
|
||||
selected: true
|
||||
technologies: ['Python', 'Angular', 'TypeScript', 'Immutable trie']
|
||||
technologies: ['Python', 'Angular', 'TypeScript', 'Immutable trees']
|
||||
---
|
||||
|
||||
In August 2019 I wanted a goal tracker I'd actually open, on whichever device was nearest, without watching it disagree with itself. Nothing off the shelf fit, so I built one over a couple of weekends. The tower metaphor was the part friends saw; the part that aged well was the sync model that fell out of needing the same state in three places at once.
|
||||
In August 2019 I wanted a goal tracker I'd actually open, on whichever device was nearest, without it disagreeing with itself. Nothing off the shelf fit, so I built one over a couple of weekends: an Angular frontend where every goal is a tower, and every finished task is a block that falls into it with a little gravity animation. A block's difficulty (1 to 100) decides how many tiles it adds, six blocks to a row, so a productive month literally raises the skyline. Friends saw the towers. I was more attached to what was underneath.
|
||||
|
||||
## The problem in one paragraph
|
||||
## The clever part
|
||||
|
||||
Pick any non-trivial mutable object graph, sync it across devices, and you end up either sending the whole thing on every change (wasteful) or writing ad-hoc diff logic per shape (brittle). I wanted a representation where the _shape_ of the data made the diff fall out for free.
|
||||
The state (pages of towers of blocks) is a tree, and I stored it as an immutable one. The commit that introduced `Root`, `InnerNode`, and `Node` landed at the end of the first month. Updates produced new structure and shared everything untouched by reference, so "where I was" and "where I am" were two pointers, and the difference between them could be walked in time proportional to the change, not the state. I had the whole plan in my head: diff the roots, ship one tiny op per changed leaf, rebase in-flight edits on top of whatever came back. A delta-sync protocol where the data structure does the hard part.
|
||||
|
||||
## The trie, concretely
|
||||
## The part that shipped
|
||||
|
||||
A goal in Life Towers is a path of strings. `Health / Running / 5k`. Tasks under a goal hang off the leaf. A user's whole state is a tree, and a trie is exactly the data structure that makes that tree's _identity_ manipulable.
|
||||
What the client actually sends, to this day, is the whole tree.
|
||||
|
||||
Two properties did the heavy lifting:
|
||||
`PUT /api/v1/data` carries every page, tower, and block, with an `If-Match` header holding the last revision number this device saw. If the server's counter has moved on, the request gets a 409 and the client refetches and adopts the server's version, discarding its own un-pushed edit. A server-sent-events stream pushes new revision numbers so other devices know to refetch; a 750 ms debounce keeps typing from becoming a PUT storm.
|
||||
|
||||
- **Structural sharing.** When you tick off a task under `Health / Running / 5k`, the new root reuses every untouched subtree by reference. The `Career` branch and the `Reading` branch are the same objects they were before. Comparing the old and new roots is mostly pointer equality; only the path that actually changed gets walked.
|
||||
- **Immutability.** Updates produce new structure instead of mutating. "Where I was" and "where I am" become two pointers, not two snapshots. The diff between them is whatever's not shared, and that walk is O(changes), not O(state).
|
||||
I never built the delta protocol, and the honest reason isn't that I ran out of time. The numbers refused to justify it. A person's life plans are a few kilobytes of JSON; shipping all of them costs nothing perceptible, while the diff machinery would have saved bytes nobody was paying for and added a rebase layer that is exactly where the subtle bugs would have lived. Even server-wins, which sounds brutal, is close to the right semantics here, because the only concurrent writer is me on another device, usually hours apart.
|
||||
|
||||
The sync loop falls out:
|
||||
When I rewrote the backend this spring (FastAPI and SQLite now, and the frontend moved to Angular signals), I kept the protocol exactly as it was. That's the strongest endorsement I know how to give it.
|
||||
|
||||
1. Client holds the last root the server acknowledged plus its own current root.
|
||||
2. To send: walk only the unshared paths, emit one op per changed leaf. In practice that's a handful of bytes for a typical edit, no matter how large the rest of the tree is.
|
||||
3. Server applies, returns its new root.
|
||||
4. Client rebases any in-flight edits by replaying them on top.
|
||||
## What the trie was actually for
|
||||
|
||||
There's no conflict resolution layer because the operations commute on the structure. Two clients adding tasks under different branches produce non-overlapping deltas that compose trivially. The hard cases (two clients editing the same leaf) are tiny and obvious, because they're the _only_ place the deltas touch the same path.
|
||||
The immutability wasn't wasted; it just never needed to cross the wire. Inside the client it made the everyday things simple: undo is keeping an old pointer, change detection is reference equality, and nothing ever observes a half-applied update. The structure earned its keep locally and stayed an implementation detail, which is a perfectly good career for a data structure.
|
||||
|
||||
The wish for a real merge, where two devices edit the same thing and both edits survive, didn't die either. It turned out to belong to a different problem. Years later it became [reconcile-text](/articles/reconcile-text-3-way-merge/), where the data is prose and losing an edit actually hurts.
|
||||
|
||||
## What I'd change
|
||||
|
||||
- **Property tests around the rebase.** The reconcile path is exactly where a generator finds bugs that hand-written tests never think to write. I had hand-written cases; I'd start with `proptest` now.
|
||||
- **A standalone spec for the wire format.** The part worth lifting out was the protocol, not the goal tracker. A short spec would let me (or anyone) reimplement it in a different stack without re-deriving everything from the Python source.
|
||||
- **Strip the visual experiment.** The tower visualisation was fun but it bound the storage to a UI metaphor. The sync model should be a library; the towers should be a separate toy.
|
||||
|
||||
## If you take one idea from this
|
||||
|
||||
Most sync problems are diff problems pretending to be transport problems. Pick the data structure that makes the diff free, and the protocol almost writes itself. The corollary: if you're writing a lot of "if this changed, send that" code, you're using the wrong structure.
|
||||
- **Tell the loser.** Server-wins is a fine policy; silent server-wins is rude. The device whose edit got discarded should at least say so out loud.
|
||||
- **Write the protocol down at the time.** I spent years remembering this as "the delta-sync project", because that was the design in my head. The code disagreed. One README paragraph written in 2019 would have kept my memory honest.
|
||||
|
|
|
|||
|
|
@ -19,10 +19,10 @@ project:
|
|||
alt: RGB LED strips glowing from a music synchronization project.
|
||||
---
|
||||
|
||||
Spring 2016. I had a Raspberry Pi, a couple of 12V RGB LED strips someone had given me, a handful of MOSFETs from an electronics kit, and zero idea what I was doing. I wired one of the MOSFETs backwards and it got hot enough to leave a small mark on the breadboard. I learned to read a datasheet, slowly, by needing one. This was the first thing I started and actually finished.
|
||||
Spring 2016. I had a Raspberry Pi, a couple of 12V RGB LED strips someone had given me, a handful of MOSFETs from an electronics kit, and no idea what I was doing. I wired one of the MOSFETs backwards and it got hot enough to leave a small mark on the breadboard, which is how I learned, slowly and because I had to, to read a datasheet. This was the first thing I ever started and actually finished.
|
||||
|
||||
The plan was something like: play music, look at it, make the lights match. I got bands wrong first. Mapping raw audio amplitude to brightness made the lights pulse with anything (clipping, voice, fan noise), a strobing mess that hurt to look at. Reading about Fourier transforms long enough to type `numpy.fft.fft(audio_chunk)` into a REPL was the moment the project started actually behaving like the thing I'd imagined. Bass-heavy frequency bins went to red; mids to green; highs to blue. Smoothing the output over a few frames stopped the seizure-inducing flicker.
|
||||
The plan was roughly: play music, look at it somehow, make the lights match. My first attempt mapped raw audio amplitude to brightness, which makes the lights pulse at everything (clipping, voices, fan noise): a strobing mess that hurt to watch. I read about Fourier transforms just long enough to type `numpy.fft.fft(audio_chunk)` into a REPL, and that was the moment the project started behaving like the thing I'd imagined. Bass-heavy bins went to red, mids to green, highs to blue, and smoothing the output over a few frames killed the flicker.
|
||||
|
||||
The frontend was a vanilla web page on the same Pi: pick a track, tweak the band thresholds, see what changed. No framework. Just a `<select>`, a few sliders, and an `XMLHttpRequest`. It worked.
|
||||
The frontend was a vanilla web page served from the same Pi: pick a track, drag a few threshold sliders, watch the room change. A `<select>`, some sliders, an `XMLHttpRequest`. It worked.
|
||||
|
||||
It's not impressive in 2026. The thing I actually keep from it isn't the FFT or the MOSFETs; it's the discovery that I'd rather have a finished janky thing than an elegant unfinished one. Most of the projects on this site are downstream of that discovery; [the ATtiny85 handheld](/articles/ad-astra-attiny85-game-engine/) four years later is the same instinct with the soldering iron held steadier. I'd still recommend the same path to anyone learning: pick something physical, plug things together until they work, accept that the first version will be ugly.
|
||||
None of this would impress anyone in 2026, and that's fine, because what I actually took from it wasn't the FFT or the MOSFETs. It was discovering that I'd rather have a finished janky thing than an elegant unfinished one. Most of the projects on this site are downstream of that discovery; [the ATtiny85 handheld](/articles/ad-astra-attiny85-game-engine/) four years later is the same instinct with the soldering iron held steadier. I'd still recommend the same first project to anyone: something physical, plugged together until it works, ugly on purpose.
|
||||
|
|
|
|||
|
|
@ -22,6 +22,6 @@ project:
|
|||
alt: Screenshot of the My Notes Android markdown app.
|
||||
---
|
||||
|
||||
In November 2019 I wrote my own notes app for Android, used it daily for a while, and then it lost a long battle with Obsidian. The loss was the lesson: I learned what I actually wanted from a notes app by watching mine fail to be it. Years later that same itch is why I wrote [reconcile-text](/articles/reconcile-text-3-way-merge/); by then I was editing the same notes in Vim, VS Code, and Obsidian, and nothing existed to merge three independently-edited copies back into one.
|
||||
In November 2019 I wrote my own notes app for Android, used it daily for a while, and then watched it lose a long, fair fight with Obsidian. The losing was the useful part: I learned what I actually wanted from a notes app by watching mine fail to be it, one small daily annoyance at a time. Years later the same itch resurfaced as [reconcile-text](/articles/reconcile-text-3-way-merge/); by then I was editing the same notes in Vim, VS Code, and Obsidian, and nothing existed to merge three independently edited copies back into one.
|
||||
|
||||
The app itself was small: Markdown notes, hashtag filtering, Markwon for rendering. Every developer writes their own notes app eventually and the bar for shipping one isn't high. What I actually wanted was a few weeks outside the web stack, somewhere with different conventions about lifecycle, storage, and resource constraints. Android delivered that. I'd still recommend "write a small thing on a new platform" as a way to recalibrate what you take for granted.
|
||||
The app itself was modest: Markdown notes, hashtag filtering, Markwon doing the rendering. Every developer writes a notes app eventually, and the bar for shipping one isn't high. What I was really buying was a few weeks somewhere that wasn't the web: a platform with different opinions about lifecycle, storage, and what happens to your process when the user looks away. Android delivered exactly that, and I'd still recommend "build a small thing on an unfamiliar platform" as the cheapest way to find out which of your habits are skills and which are just local customs.
|
||||
|
|
|
|||
|
|
@ -31,34 +31,27 @@ project:
|
|||
src: ./_assets/nuclear-simulation.jpg
|
||||
---
|
||||
|
||||
Trying to solve flow and heat as a coupled system would have been a real CFD problem and I had two weeks. A cybersecurity event in late 2018 needed a cooling-system simulator that contestants could poke at through PLCs over a weekend, and the deadline shaped every decision after it: cheap to compute, plausible to a non-specialist, runs all weekend on one server. The useful design move was modelling flow and heat as **two separate graph passes**, not one combined PDE.
|
||||
Solving flow and heat as one coupled system would have been a genuine CFD problem, and I had two weeks. A cybersecurity event in late 2018 needed a cooling-plant simulator that contestants could poke at through PLCs over a weekend, and that deadline made every decision for me: cheap to compute, plausible to a non-specialist, able to run unattended on one server from Friday night to Sunday. The design move I'd keep from it is modelling flow and heat as two separate graph passes instead of one PDE.
|
||||
|
||||
## What the event needed
|
||||
|
||||
The challenge was about PLCs. Contestants would change setpoints, valves, or pump speeds, and we needed them to see whether their action made the plant stable, wasted coolant, or melted something. That meant:
|
||||
|
||||
- Multiple monitoring clients had to update from one simulation server in near real time.
|
||||
- The system had to be configurable enough that the event organisers could ship me a new plant on Friday night and have it running Saturday morning.
|
||||
- It had to be obvious. A simulator nobody understands isn't a teaching tool, it's noise.
|
||||
The challenge was about PLCs. Contestants changed setpoints, valves, and pump speeds, and they needed to see whether their action kept the plant stable, wasted coolant, or melted something. Concretely that meant a single simulation server feeding many monitoring clients in near real time, a plant configurable enough that the organisers could hand me a new layout on Friday night and have it running Saturday morning, and behaviour legible enough to teach. A simulator nobody understands isn't a teaching tool; it's noise with units on it.
|
||||
|
||||
## The split that made it cheap
|
||||
|
||||
Instead of the coupled solver:
|
||||
Each tick runs two passes:
|
||||
|
||||
1. **Flow first, as graph traversal.** Walk the pipe graph from the pumps, accumulate pressure, distribute water to nodes.
|
||||
2. **Heat second, as a linear system.** Build the adjacency matrix from the flow result, add boundary conditions (heaters, exchangers, base temperatures), solve for node temperatures with NumPy.
|
||||
3. Repeat both passes per tick.
|
||||
1. **Flow first, as a graph traversal.** Walk the pipe network outward from the pumps, accumulate pressures, distribute water across the nodes.
|
||||
2. **Heat second, as a linear system.** Use the flow result to build an adjacency matrix, add the boundary conditions (heaters, exchangers, ambient temperatures), and let NumPy solve for the node temperatures.
|
||||
|
||||
This is wrong as physics. It's right as a model. Flow doesn't react to instantaneous heat in any way contestants could perceive, and the cost of solving them separately was a tiny fraction of solving them together. The clean phase boundary also meant when "the heat is weird," I knew exactly which pass to look at.
|
||||
As physics, this is wrong; flow and heat are coupled, and a real plant knows it. As a model, it's right, because nothing a contestant could do made the coupling perceptible on a dashboard, and the decoupled version cost a tiny fraction of the real solve. It bought one more thing I didn't anticipate valuing so much: a clean seam for debugging. When the temperatures looked weird, I knew which of the two passes to suspect before I'd opened the editor.
|
||||
|
||||
## Why the editor mattered
|
||||
## The editor mattered more than the solver
|
||||
|
||||
The simulator's most-used UI was the _input_ editor, a separate JavaFX tool where you laid out the plant, set parameters per element, and exported JSON the sim ate. I wrote up the editor's [own story here](/articles/graph-editor-javafx-simulation-input/), because in hindsight it deserved to be its own project.
|
||||
|
||||
The lesson: a simulation is only as useful as its input pipeline. If editing the plant requires editing source, organisers won't use it.
|
||||
The most-used interface in the whole system was the input editor, a separate JavaFX tool where organisers laid out the plant, parameterised each element, and exported the JSON the sim consumed. It earned [its own write-up](/articles/graph-editor-javafx-simulation-input/), because in hindsight it was its own project: the simulator decided what was possible, but the editor decided who could use it.
|
||||
|
||||
## What I'd change
|
||||
|
||||
- **State what the model claims.** A convincing sim needs an honest README about what it does and doesn't model. Mine didn't. Anyone who took the numbers seriously could have walked away believing more than the model deserved.
|
||||
- **Recorded scenarios as regression tests.** Sim projects drift in ways that look plausible on screen. Storing "this input over 60 seconds produces these outputs" would have caught me when I broke the temperature solver on Saturday morning at the event.
|
||||
- **Skip JavaFX.** Cross-platform packaging was painful and the desktop dependency made the editor harder to hand off than it should have been. A web-based editor in the same browser the monitors used would have meant one fewer install for the organisers.
|
||||
- **State what the model claims.** A convincing simulator owes its audience an honest README about what it does and doesn't capture. Mine didn't have one, and anyone who took the numbers seriously could have walked away believing more than the model deserved.
|
||||
- **Recorded scenarios as regression tests.** Simulations drift in ways that still look plausible on screen. A few stored "this input over 60 seconds produces these outputs" runs would have caught me when I broke the temperature solver, which I did on the Saturday morning of the event, naturally.
|
||||
- **Skip JavaFX.** Cross-platform packaging was a fight, and a web editor living next to the monitoring clients would have meant one fewer install for everyone.
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ article:
|
|||
- type: image
|
||||
src: ./_assets/perfect-postcode.jpg
|
||||
alt: A Perfect Postcode dashboard view of Manchester with five active filters (property type, price, public-transport time to Manchester city centre, crime, noise) and a hex heatmap of 1,247 matching properties.
|
||||
caption: A normal user pan triggers a hexagon aggregation under filter. The hot path holds itself to two u16 compares per row.
|
||||
caption: A normal user pan triggers a hexagon aggregation under filter. The hot path holds itself to three integer compares per row.
|
||||
project:
|
||||
title: Perfect Postcode
|
||||
description: A UK property-intelligence map. ~25M historical transactions, ~150 features per row, all u16-quantised in RAM, served from a single Rust binary.
|
||||
|
|
@ -42,13 +42,13 @@ project:
|
|||
alt: The Perfect Postcode dashboard with active filters on property type, price, transit time, and crime, showing a Manchester map with matching properties as a heatmap.
|
||||
---
|
||||
|
||||
A user told me the map felt sluggish when they dragged it across Manchester with four filters on. They were right. The previous version round-tripped to a database, decoded floats, and lost the budget for a single pan inside the first filter. The rewrite is one Rust binary that holds the entire UK property history in RAM and treats every filter as three integer compares. Everything else in this post is the consequence of refusing to break that latency again.
|
||||
A user told me the map felt sluggish when they dragged it across Manchester with four filters on. They were right, and it stung, because the previous version round-tripped to a database, decoded floats, and had spent its entire latency budget before it finished evaluating the first filter. The rewrite is one Rust binary that holds the entire UK property history in RAM and treats every filter as three integer compares. Everything else in this post follows from refusing to let that sluggishness come back.
|
||||
|
||||
## The constraint that shapes everything
|
||||
|
||||
The answer to _"what's the median price in this hexagon, filtered to four-bedroom terraces under £450k with a 35-minute transit to Manchester"_ needs to come back inside a single map pan. Per visible cell, per request, every time the user moves anything. That's the work.
|
||||
|
||||
At the resolution we want, the inputs are roughly 25M historical transactions, each with around 150 numeric features (price, EPC, deprivation deciles, school catchment metrics, POI proximities, noise, crime, …). Naively f32 per cell, that's ~15 GB before you count anything else: postcodes, POIs, places, tiles, travel times. The rest of the architecture is the consequence of insisting it all lives in one process on one rentable box.
|
||||
At the resolution we want, the inputs are roughly 25M historical transactions, each with around 150 numeric features (price, EPC, deprivation deciles, school catchment metrics, POI proximities, noise, crime, …). Naively f32 per cell, that's ~15 GB before you count anything else: postcodes, POIs, places, tiles, travel times. I decided early that all of it would live in one process on one rentable box, and the rest of the architecture is what that decision demanded.
|
||||
|
||||
## u16 quantisation in a row-major flat array
|
||||
|
||||
|
|
@ -88,7 +88,7 @@ A small per-thread `FxHashMap<u64, u64>` H3 cache inside each rayon chunk takes
|
|||
|
||||
`AppState` is large and immutable after the boot-time loads. `SharedState = RwLock<Arc<AppState>>` wraps it; every handler does `shared.load_state()`: a brief read lock, an `Arc::clone`, no further lock contention for the request.
|
||||
|
||||
The standard read-mostly pattern, but worth naming for one reason: it makes hot-reloading the parquet trivial later. Build a new `AppState` from disk, take the write lock, swap the `Arc`, drop the old one when the last in-flight request finishes. None of the handlers need to change.
|
||||
The standard read-mostly pattern, and I mention it for one reason: it makes hot-reloading the parquet trivial later. Build a new `AppState` from disk, take the write lock, swap the `Arc`, drop the old one when the last in-flight request finishes. None of the handlers need to change.
|
||||
|
||||
On top of that there's a per-endpoint `ConcurrencyLimitLayer::new(N)`. The expensive endpoints (filter-counts, hexagon-stats, screenshot, export) get 3–5; the cheap ones get 20–30. It is the simplest backpressure you can write and it does most of the work.
|
||||
|
||||
|
|
@ -109,8 +109,8 @@ On top: a per-week token budget (`AI_FILTERS_WEEKLY_TOKEN_LIMIT = 10_000_000`) a
|
|||
|
||||
## Smaller calls
|
||||
|
||||
- **`mlockall(MCL_CURRENT | MCL_FUTURE)` at startup.** The hot dataset has to never page out. With `CAP_IPC_LOCK` it works; without it we log and continue.
|
||||
- **`malloc_trim(0)` after each big load.** Polars leaves a high allocator water-mark after parquet scans. Trimming after each major load gives back hundreds of MB of RSS before steady state.
|
||||
- **jemalloc, tuned to give memory back.** glibc's malloc parks freed memory in per-CPU arenas and rarely returns it, so a Polars parquet scan leaves a high-water mark in RSS forever. The binary ships jemalloc with one-second decay (`dirty_decay_ms:1000,muzzy_decay_ms:1000`), a background thread purging every 10 seconds, and a synchronous arena purge after each big load. (I considered `mlockall` to pin the hot data instead; a comment in the code records why not: locking every freed-but-dirty page resident would have inflated ~10 GB of RSS into ~40.)
|
||||
- **Camera bounds from the 5th–95th percentile.** When the AI filter returns matches, the map frames the middle 90% of them per axis rather than the true bounding box, so one stray property in Cornwall can't zoom the camera out to all of England.
|
||||
- **Prometheus path normalisation.** `/api/tiles/5/16/10` becomes `/api/tiles/:z/:x/:y` before it becomes a label. Otherwise `/.env`, `/wp-admin/...`, and bot scans explode cardinality.
|
||||
- **Median-half eviction over LRU.** Token, share-bounds, and superuser-token caches evict the older half on overflow instead of one entry at a time. Cheap, and it spreads the re-validation cost instead of triggering a thundering herd.
|
||||
- **`spawn_blocking` for Polars I/O.** Parquet scans are CPU-bound. They block the tokio executor if you let them; they don't if you don't.
|
||||
|
|
@ -121,7 +121,6 @@ On top: a per-week token budget (`AI_FILTERS_WEEKLY_TOKEN_LIMIT = 10_000_000`) a
|
|||
|
||||
## What I'd change
|
||||
|
||||
- **Pin the allocator.** I rely on `malloc_trim` to keep RSS predictable. A jemalloc with explicit purge would behave better than glibc plus periodic trimming, especially under sustained load.
|
||||
- **One bench for the hot loop.** I trust the structure but I have no number for _filter throughput per row per filter under typical load_. That number would tell me when the u16 trick stops being enough.
|
||||
- **Move free-zone bounds to PocketBase.** `FREE_ZONE_BOUNDS` is a `const`. It's been right for the demo region for a year. The next time it changes I'll regret hardcoding it.
|
||||
- **A typed query DSL instead of `;;`-separated strings.** The current filter wire format is `name:min:max;;name:val1|val2`. Cheap to parse, awful to evolve. A small JSON envelope would survive the next feature.
|
||||
|
|
|
|||
|
|
@ -19,8 +19,8 @@ project:
|
|||
alt: Screenshot of a colour grading interface applied to a photograph.
|
||||
---
|
||||
|
||||
In June 2018 I got tired of every grader I tried making me think in masks. I wanted to point at "this orange" in a photo from one of my [walks](/articles/photo-site-generator/), nudge it, and have the neighbouring reds and yellows come along by however much made sense. Distance in colour space, not a brush. So I built the proof.
|
||||
In June 2018 I got tired of every photo editor making me think in masks. What I wanted was to point at _this orange_ in a photo from one of my [walks](/articles/photo-site-generator/), nudge it, and have the neighbouring reds and yellows come along by however much made sense; the edit defined by distance in colour space, not by a region I'd painted on the image. No tool worked that way, so I built the proof of concept.
|
||||
|
||||
The UI was a colour wheel where you'd click to drop a marker, drag to move it, click anywhere to add another. Each marker had its own settings; transformations fell off smoothly with distance from the picked colour. No masks, ever.
|
||||
The interface was a colour wheel. Click to drop a marker on a colour, drag to shift it, click again to add another marker with its own settings. Every transformation fell off smoothly with distance from the picked colour, so the photo never developed the hard seams that masks produce. There was no mask anywhere in the program, which was the entire experiment.
|
||||
|
||||
I never built it into a real tool. The idea still feels right: distance in colour space is the natural unit for prose-style editing of an image. If I returned to it, I'd reach for WebGL instead of canvas. The interaction only earns its keep if the preview is live on a real photo, and canvas couldn't get there.
|
||||
I never grew it into a real tool, but the idea hasn't stopped feeling right: distance in colour space is the natural unit for the way people describe edits out loud ("warm up the skin a little, leave the sky"). If I came back to it I'd build the pipeline on WebGL rather than canvas, because the interaction only earns its keep when the preview is live on a full-resolution photo, and canvas could never quite get there.
|
||||
|
|
|
|||
|
|
@ -1,24 +1,24 @@
|
|||
---
|
||||
title: A Photo Site That Generated Itself From a Folder
|
||||
description: A Webpack script that turns a folder of photos into a static site with responsive image variants. Mostly here as an excuse to talk about walks.
|
||||
description: 'A folder of photos becomes a static site: five widths, three formats, hashed filenames. Started as a Webpack script in 2016; mostly an excuse to walk.'
|
||||
date: 2026-04-27
|
||||
period: 'Summer 2016'
|
||||
period: '2016 onwards'
|
||||
thumbnail:
|
||||
src: ./_assets/photos.jpg
|
||||
alt: Screenshot of a generated photography site.
|
||||
article:
|
||||
tags: ['web', 'tools']
|
||||
role: Site generator author
|
||||
stack: ['Webpack', 'Image processing', 'Static site generation']
|
||||
outcome: A photography site that updated itself when I dropped new images into a folder
|
||||
stack: ['Webpack', 'Vite', 'TypeScript', 'Image processing']
|
||||
outcome: A photography site that updates itself when I drop new images into a folder
|
||||
audience: general
|
||||
project:
|
||||
title: Photo Site Generator
|
||||
description: Point a Webpack script at a folder of photos, get a static site with responsive image variants. An excuse to walk with a camera.
|
||||
description: Point a build script at a folder of photos, get a static site with responsive image variants. An excuse to walk with a camera.
|
||||
---
|
||||
|
||||
I take walks with a camera. Most of what I shoot isn't good, but the act of walking slowly with a frame to think about is the most reliable way I know to come back with an idea for whatever I'm working on. In the summer of 2016 I wanted somewhere to put the few frames that survived, and I wasn't going to maintain a CMS for it.
|
||||
I take walks with a camera. Most of what I shoot isn't good, but walking slowly with a frame to think about is the most reliable way I know to come back with an idea for whatever I'm actually working on. In the summer of 2016 I wanted somewhere to put the few frames that survived, and I wasn't going to run a CMS for it. So: a Webpack script. Point it at a directory of full-size photos, get a static site with responsive variants of each. Drop a photo in, build, deploy.
|
||||
|
||||
So a Webpack script: point it at a directory of full-size photos, get a static site with responsive variants per image. Drop in a new photo, run the build, deploy. The pipeline mattered less than making the habit visible. The same habit later produced a [colour grader](/articles/photo-colour-grader/) for the same shots.
|
||||
The site still exists and still works exactly that way, though almost none of the 2016 code survives. The current build renders every photo into five widths and three formats (AVIF, WebP, and a JPEG fallback), strips the EXIF, and bakes a content hash into each filename so a change to the encoder settings can never serve anyone a stale image (a lesson from the first iteration, learned the usual way). A catalogue file and the photo folder have to agree exactly, in both directions, or the build refuses to run. It's a lot of pipeline for a site whose whole job is showing pictures without a framework attached, but the pipeline is what keeps the habit cheap: photograph, drop in the folder, push.
|
||||
|
||||
If I rebuilt it today I'd use Astro, which is what this site runs on.
|
||||
The same walks later produced a [colour grader](/articles/photo-colour-grader/) for the same shots.
|
||||
|
|
|
|||
|
|
@ -19,10 +19,10 @@ project:
|
|||
alt: Screenshot from an early 3D platform game.
|
||||
---
|
||||
|
||||
Autumn 2017, Basics of Programming, a deadline that forced me to learn C the hard way. I'd write almost none of it the same way today, and I'd defend every choice in it anyway. A 3D voxel platformer in pure C with SDL 1.2. No engine, no scripting layer.
|
||||
Autumn 2017, first semester, a course called Basics of Programming, and a free-choice project I aimed far too high on: a 3D voxel platformer in pure C with SDL 1.2. No engine, no scripting layer, no one to tell me that "3D in software, in C, in your first semester" was an unusual reading of the assignment.
|
||||
|
||||
Maps were randomly generated and destructible voxel by voxel, so the player could dig their way out of trouble or wall off flying enemies that merged into larger ones as they got closer. Powerups let you shoot, or slow down time at the cost of points.
|
||||
The maps were randomly generated and destructible voxel by voxel, so you could dig your way out of trouble or wall yourself off from the flying enemies, which merged into bigger ones as they closed in, a mechanic I'd love to claim was designed rather than discovered. Powerups let you shoot, or slow down time at the cost of points.
|
||||
|
||||
What I actually learned was pointers, painfully, through an adequate number of segfaults. The course was meant to teach the basics of programming; for me it was the moment programming stopped feeling like a list of facts and started feeling like a thing I could build with. The next time I reached for C it was on hardware that punished waste; see [Ad Astra](/articles/ad-astra-attiny85-game-engine/).
|
||||
What the project actually taught me was pointers, the honest way, through an adequate number of segmentation faults. Somewhere in those weeks programming stopped feeling like a list of facts to memorise and started feeling like a material you could build with, and I never quite went back. The next time I wrote C it was for hardware that punished every wasted byte: [Ad Astra](/articles/ad-astra-attiny85-game-engine/), three years later.
|
||||
|
||||
First-project privilege.
|
||||
I'd write almost none of it the same way today, and I'd still defend every choice in it. First projects get that privilege exactly once.
|
||||
|
|
|
|||
|
|
@ -49,25 +49,25 @@ Every existing tool got close and missed:
|
|||
|
||||
So the library does exactly one thing: pure function from three strings to one. No async, no networking, no concurrency, no plugins. Anything outside that boundary is somebody else's library.
|
||||
|
||||
## The decisions worth naming
|
||||
## The decisions that mattered
|
||||
|
||||
**Myers diff per side, then weave the diffs.** Each child is diffed against the parent, the two edit scripts are optimised so adjacent changes group cleanly, then a single weaving pass interleaves them into one ordered op sequence that produces the merged text. The weave borrows the shape of operational transformation, but the inputs are batched complete diffs, not live keystrokes, so it only runs once per merge.
|
||||
|
||||
**Tokeniser is the user knob.** This is the choice I'd defend hardest. Most of what people want when they say "merge differently" isn't a new algorithm; it's a different unit. Word-level tokenisation turns most "conflicts" in prose into two adjacent edits that coexist. Line-level makes it behave like `git merge-file`. Markdown-level merges on headings and list items. Same engine, four different products depending on what you call a token.
|
||||
**Tokeniser is the user knob.** Of everything in the library, this is the choice I'd argue for longest. Most of what people want when they say "merge differently" isn't a new algorithm; it's a different unit. Word-level tokenisation turns most "conflicts" in prose into two adjacent edits that coexist. Line-level makes it behave like `git merge-file`. Markdown-level merges on headings and list items. Same engine, four different products depending on what you call a token.
|
||||
|
||||
**Cursors are first-class merge inputs.** Each cursor has a stable ID and rides through the merge so a collaborative editor can ask "where did this cursor go?" without reconstructing it from the output text. This is the bit that made it useful to anything that wasn't just [the Obsidian sync plugin I wrote alongside it](/articles/vault-link-obsidian-sync/).
|
||||
**Cursors are first-class merge inputs.** Each cursor has a stable ID and rides through the merge, so a collaborative editor can ask "where did this cursor go?" without reconstructing it from the output text. The test fixtures mark cursor positions with a `|` straight in the YAML, which keeps these cases readable enough that I actually write them. Cursor support is the bit that made the library useful to anything that wasn't just [the Obsidian sync plugin I wrote alongside it](/articles/vault-link-obsidian-sync/).
|
||||
|
||||
**The Rust core is generic; the FFI surface is not.** Inside Rust, the tokeniser is a `dyn Fn(&str) -> Vec<Token<T>>`. That dies the moment you try to pass it through wasm-bindgen or pyo3. The fix was a closed enum of built-in tokenisers for non-Rust callers, with the generic version reserved for Rust users. Not elegant, but the alternative was per-binding glue forever.
|
||||
|
||||
**WASM size mattered enough to tune for it.** The release profile is aggressive about size, and the JS package ships a small leak detector that warns if you forget to free wasm-bindgen objects. I lost an afternoon to that the first time and didn't want anyone else to.
|
||||
**WASM size mattered enough to tune for it.** The release profile is aggressive about size, and the JS package ships a small leak detector that warns if you forget to free wasm-bindgen objects. I lost an afternoon to that the first time and didn't want anyone else to. The strangest target is React Native: Hermes doesn't run WASM, so the package transpiles the WASM back into JavaScript with Binaryen: slower, but the same Rust core gets to follow the library anywhere JavaScript runs.
|
||||
|
||||
## What's held up, what I'd change
|
||||
|
||||
- **Kept:** the never-emits-markers, never-drops-edits guarantee. It's the only reason a sync engine can call this library without an escape hatch.
|
||||
- **Kept:** the comparison example against `diff-match-patch`. It's a runnable program in the repo showing exact inputs where the alternative is wrong. Way more convincing than a benchmark table.
|
||||
- **Cut:** the snapshot tests do well on regressions and badly on unknown edge cases. Three-way merging is exactly what proptest was made for, and I should have written generators on day one.
|
||||
- **Regret:** the snapshot and fixture tests do well on regressions and badly on edge cases nobody has imagined yet. Three-way merging is exactly the shape of problem property-based testing was made for, and the generators still aren't written. Saying it here is partly a way of making myself do it.
|
||||
- **Next:** I want to be more explicit about the boundary. reconcile-text is a merge primitive, not a live collab engine. If you have a keystroke stream and a real-time channel, use Yjs or Automerge. This library is for when you don't.
|
||||
|
||||
## If you take one idea from this
|
||||
|
||||
Prose deserves a merger that prefers a slightly clumsy sentence over a marker. Code doesn't. That one asymmetry is the whole reason the library exists in the shape it does; everything else fell out of taking it seriously.
|
||||
Prose deserves a merger that prefers a slightly clumsy sentence over a conflict marker. Code doesn't. That one asymmetry is the whole reason the library exists in the shape it does; every other decision is downstream of taking it seriously.
|
||||
|
|
|
|||
|
|
@ -41,28 +41,29 @@ project:
|
|||
technologies: ['TypeScript', 'WebGL', 'WebGL2', 'Signed distance fields']
|
||||
---
|
||||
|
||||
Winter 2020, BSc thesis deadline closing in, and the thing had to run acceptably on my advisor's laptop the day he graded it. That single shipping pressure exposed every lazy assumption in the architecture and picked the design: tile-based passes, deferred shading, shaders generated per scene and per device. A 2D ray tracer in the browser via signed distance fields: soft shadows, smooth reflections, no triangle mesh. The other half of the thesis was [decla.red](/articles/declared-shared-simulation-code/), the multiplayer game that proved the renderer survived a real game loop.
|
||||
Winter 2020, the BSc thesis deadline closing in, and one fact concentrating my mind wonderfully: the renderer had to run acceptably on my advisor's laptop on the day he graded it. Nothing exposes a lazy architectural assumption like knowing exactly which unremarkable GPU your grade depends on. The thesis was a 2D ray tracer for the browser built on signed distance fields (soft shadows, smooth reflections, no triangle mesh anywhere), and it shipped as an NPM library rather than a demo page. The other half of the thesis was [decla.red](/articles/declared-shared-simulation-code/), the multiplayer game that proved the renderer could survive a real game loop.
|
||||
|
||||
## What "mobile GPU" actually meant
|
||||
|
||||
A 2D SDF ray tracer is conceptually simple: for each pixel, march along a ray, sample the distance field, accumulate light. The implementation that works on a desktop NVIDIA card spends so much per pixel that a mobile GPU melts. So the design problem was never "can SDFs do soft shadows" (yes, easily), it was "what work can I avoid per pixel without giving up the look."
|
||||
A 2D SDF ray tracer is conceptually simple: for every pixel, march along rays, sample the distance field, accumulate light. The implementation that hums on a desktop NVIDIA card spends so much per pixel that a phone melts. So the design problem was never "can SDFs do soft shadows" (they can, easily) but "how much per-pixel work can be avoided without giving up the look".
|
||||
|
||||
Three constraints did most of the design work:
|
||||
Three constraints did most of the architecture for me:
|
||||
|
||||
- **WebGL1 and WebGL2 both supported.** No "modern browser only" cheat. That ruled out anything that needed compute shaders or storage buffers.
|
||||
- **No per-scene hand-tuned shader.** This is a library; users plug in their own scene descriptions. The renderer has to compile something appropriate at runtime.
|
||||
- **Acceptable on a phone.** Not "good when the user owns the right hardware." It had to be acceptable on the laptop my advisor used to grade the thesis.
|
||||
- **WebGL1 and WebGL2, both.** No "modern browsers only" escape hatch, which ruled out anything needing compute shaders or storage buffers.
|
||||
- **No hand-tuned shader per scene.** It's a library. Users bring scenes I've never seen, so the renderer has to compile something appropriate at runtime.
|
||||
- **Acceptable on a phone.** Not "fine if the user owns the right hardware", but acceptable on whatever mid-range thing is actually in the room.
|
||||
|
||||
## How it actually runs
|
||||
## How it runs
|
||||
|
||||
- **Tile-based rendering.** Group pixels and reason about them together. Most regions of a frame share the same nearby geometry, so you can early-out enormous swathes of pixel work if you know the tile's bounds. This was the single biggest perf win.
|
||||
- **Deferred shading.** Separate "find the surface" from "shade the surface." Shadow casting and reflections need the same geometry queries; doing them once per pixel and reusing the result was worth the extra texture bandwidth.
|
||||
- **Generated shaders per scene and device.** If a scene has no reflective surfaces, the generated shader doesn't carry the reflection path. If the device only supports WebGL1, the shader doesn't reach for WebGL2 features. Static feature flags do this badly; runtime generation does it well.
|
||||
- **TypeScript scene descriptions, no DSL.** I prototyped a small DSL for SDF authoring and threw it away. Pride's expensive. Users describe scenes in plain TypeScript and the library compiles them down. A DSL would have meant one more language to teach and one more compiler to debug.
|
||||
- **Tile-based passes.** Group pixels and reason about them together: most regions of a frame share the same nearby geometry, so a tile that knows its bounds can skip enormous amounts of per-pixel work. The single biggest performance win in the project.
|
||||
- **Deferred shading.** The first pass writes the distance field into a texture: a 16-bit float channel where WebGL2 offers one, packed into RGBA where WebGL1 doesn't. The lighting pass reads it back. Shadows and reflections want the same geometry queries, so answering them once per pixel and reusing the result was worth the texture bandwidth.
|
||||
- **Shaders generated per scene and device.** The generator is honest string substitution: GLSL templates with placeholder slots, filled in at runtime from the scene's actual contents. A scene with no reflective surfaces produces a shader with no reflection path; a WebGL1 device produces one that never mentions WebGL2 features. Where the browser supports parallel shader compilation, the variants build without blocking the first frame.
|
||||
- **Quality that follows the frame rate.** An autoscaler watches the 90th-percentile frame time and trades render resolution against it, with the distance and lighting passes scaled independently and a hysteresis band so quality doesn't oscillate. It also adapts more cautiously the longer the session runs, which keeps a momentary stutter from permanently degrading the picture.
|
||||
- **TypeScript scene descriptions, no DSL.** I prototyped a small language for authoring SDFs and then made myself throw it away, which hurt more than it should have. Users describe scenes in plain TypeScript and the library compiles them down; a DSL would have been one more language to teach and one more compiler to debug, in exchange for elegance only I would have noticed.
|
||||
|
||||
## Held up, didn't hold up
|
||||
## What held up, what didn't
|
||||
|
||||
- **Held up:** the mobile constraint forced structural perf work instead of cosmetic perf work. When something only runs on a desktop GPU you mistake headroom for good architecture, and the rude awakening comes from a user.
|
||||
- **Held up:** letting the weakest target device pick the architecture. When something only has to run on a desktop GPU, headroom quietly impersonates good design, and a user eventually delivers the correction.
|
||||
- **Held up:** keeping the library boundary clean. A demo can hide a messy implementation; a published package can't.
|
||||
- **Didn't:** I had no instrumentation around shader variants. Today I'd ship a small `?debug=1` overlay that prints exactly which shader got compiled for that session and why.
|
||||
- **Didn't:** the docs are words about ray marching. The ideas are visual; the explanation should have been too. Diagrams next time.
|
||||
- **Didn't:** I never built instrumentation around the shader variants. I still think a `?debug=1` overlay that prints which shader a session compiled, and why, is the first thing I'd add today.
|
||||
- **Didn't:** the documentation explains ray marching in prose. The ideas are visual and the explanation should have been too. Diagrams next time.
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ project:
|
|||
selected: true
|
||||
---
|
||||
|
||||
I refuse to give up the editor. Obsidian on the phone, Vim on the laptop, VS Code at work, the occasional headless `sed` across the whole vault. None of them know about each other, none of them are going to learn to, and I'm not switching to whichever sync product picks a favourite. VaultLink is the architecture that falls out of that refusal: one Rust server, one TypeScript sync engine, an Obsidian plugin, a CLI, and two test harnesses. The merge primitive underneath it all is [reconcile-text](/articles/reconcile-text-3-way-merge/), which I wrote first. VaultLink is the question that made it worth writing, finally asked in earnest.
|
||||
I refuse to give up my editors. Obsidian on the phone, Vim on the laptop, VS Code at work, the occasional headless `sed` across the whole vault. None of them know about each other, none of them are going to learn to, and I'm not switching to whichever sync product picks a favourite. VaultLink is what that refusal costs: one Rust server, one TypeScript sync engine, an Obsidian plugin, a CLI, and two test harnesses. The merge primitive underneath everything is [reconcile-text](/articles/reconcile-text-3-way-merge/), which I had written first, on the bet that a question like this would eventually ask for it.
|
||||
|
||||
## The constraint that picks the algorithm
|
||||
|
||||
|
|
@ -51,9 +51,9 @@ The sync engine is two loops, deliberately disentangled:
|
|||
- **Wire loop** (`syncer.ts`). Drains the single-consumer FIFO of pending HTTP and WebSocket ops. Updates a document's record fields (`remoteRelativePath`, `parentVersionId`, `remoteHash`) and writes content to whatever path the record currently holds. _Never moves files for path placement._
|
||||
- **Path reconciler** (`reconciler.ts`). Runs after every drained event. Best-effort pass that moves files on disk so `localPath === remoteRelativePath`. The move graph is topologically sorted. Records with pending local events are skipped; the reconciler only operates on settled ones. Failures (slot occupied by something untracked) are silent skips; the next pass retries.
|
||||
|
||||
The split is the load-bearing decision. It used to be one loop with both responsibilities, and the bug catalogue was a parade of slot-collision stashes, "conflict-uuid" hacks, and `MoveOnConflict.NEW`/`EXISTING` policy choices. Separating wire transport from path placement made most of that vanish: the wire loop can freely write `remoteRelativePath` to whatever the server returned, even if it disagrees with the file on disk, because the reconciler won't move anything out from under a queued user rename.
|
||||
This split is the decision the rest of the engine leans on. It used to be one loop with both responsibilities, and the bug catalogue from that era reads like a confession: slot-collision stashes, "conflict-uuid" hacks, `MoveOnConflict.NEW`/`EXISTING` policy enums. Separating wire transport from path placement made most of it vanish, because the wire loop can now freely write `remoteRelativePath` to whatever the server returned, even when it disagrees with the file on disk, knowing the reconciler will never move anything out from under a queued user rename.
|
||||
|
||||
Cycles in the move graph (A→B, B→C, C→A) are resolved by reading every file in the cycle into memory and writing each back to its new slot; no tmp files. A write-ahead marker at `.vaultlink/swap-<uuid>.json` lists each leg. On startup the reconciler reads the marker, hashes each `from` to determine which legs ran, and replays the rest. `.vaultlink/**` is hardcoded into the internal ignore pattern so the swap markers never themselves get synced.
|
||||
Cycles in the move graph (A→B, B→C, C→A) are found with Tarjan's strongly-connected-components pass and resolved by reading every file in the cycle into memory and writing each back to its new slot; no tmp files. A write-ahead marker at `.vaultlink/swap-<uuid>.json` lists each leg along with the hash the source file is expected to have, so after a crash the reconciler can hash each `from`, work out which legs already ran, and replay only the rest. `.vaultlink/**` is hardcoded into the internal ignore pattern so the swap markers never themselves get synced.
|
||||
|
||||
## Pending creates are Promises, not strings
|
||||
|
||||
|
|
@ -65,27 +65,27 @@ If you're walking `events[]` and comparing docIds with `===`, you'll silently fa
|
|||
|
||||
The catch-up handshake says "give me everything newer than `lastSeenUpdateId`." If the client advances that id as it receives a stream of RemoteChange ids out of order, it'll publish a too-high cursor, and the next reconnect will request from a point past events it never actually applied. Permanent gap. Replay-forever bug, with extra steps.
|
||||
|
||||
The fix is a small data structure called `MinCovered`: a contiguous-prefix tracker over a stream of integers. It advances the public min only when the next consecutive id has been processed. Out-of-order arrivals stash without bumping the cursor. Five files of test, one screen of implementation, and an entire category of confusing data-loss bugs disappears.
|
||||
The fix is a small data structure called `MinCovered`: a contiguous-prefix tracker over a stream of integers. It advances the public minimum only when the next consecutive id has been processed; out-of-order arrivals stash without bumping the cursor. The implementation is one screen of code, the test file is slightly longer than the implementation, and an entire category of confusing data-loss bugs stopped existing.
|
||||
|
||||
## reconcile-text on the server
|
||||
|
||||
The merge sits on the server. When two clients submit edits against the same `parent_version_id`, the second submission triggers a 3-way merge against the parent and the freshly-committed first edit. Three strings in, one out. No conflict markers. The engine commits the merged result, increments the version, and broadcasts the new state to every connected client.
|
||||
|
||||
Two restrictions, both honest:
|
||||
Two restrictions, neither hidden:
|
||||
|
||||
- **Only `.md` and `.txt`.** Markdown that fails UTF-8 validation gets treated as binary, same as PNGs and PDFs.
|
||||
- **Last-write-wins for everything else.** Concurrent edits to a `.docx` lose one of the writes. The right fix is "don't edit binaries concurrently," which is unsatisfying but true.
|
||||
- **Only `.md` and `.txt` get merged.** Markdown that fails UTF-8 validation is treated as binary, same as PNGs and PDFs.
|
||||
- **Last-write-wins for everything else.** Concurrent edits to a `.docx` lose one of the writes. The real fix is "don't edit binaries concurrently", which is unsatisfying and true.
|
||||
|
||||
Merge quality is exactly what reconcile-text gives me. Word-level tokenisation turns most prose conflicts into two adjacent edits that coexist. If the merge looks slightly clumsy now and then, the alternative is a `<<<<<<< HEAD` block in my notes, and I'd take the clumsy sentence every time.
|
||||
|
||||
## Two test harnesses, one workflow
|
||||
|
||||
Distributed-sync bugs are confusing the first time and impossible the second. The fix is two harnesses:
|
||||
A distributed-sync bug is bewildering the first time you see it and gone by the time you've attached a debugger. My answer is two harnesses with different jobs:
|
||||
|
||||
- **`test-client` (fuzz).** N parallel processes hammering random ops against a shared server for minutes at a time. Catches bugs nobody thought to write a test for. Reproductions are noisy.
|
||||
- **`deterministic-tests`.** Scripted multi-client scenarios with a step grammar (`pause-server`, `pause-websocket`, `barrier`, `assert-consistent`) using an in-memory filesystem against a real server binary. Used to capture a fuzz-found bug as a minimal repro before fixing it.
|
||||
- **`test-client` (fuzz).** N parallel processes hammering random operations against a shared server for minutes at a time. It catches the bugs nobody would have thought to write a test for, and its reproductions are unreadable.
|
||||
- **`deterministic-tests`.** Scripted multi-client scenarios, over a hundred of them by now, written in a small step grammar (`pause-server`, `pause-websocket`, `barrier`, `assert-consistent`) and run with an in-memory filesystem against the real server binary.
|
||||
|
||||
The workflow: fuzz finds something, I sift logs for a root cause, write the minimal deterministic test that fails on it, fix until both that test and the fuzz pass. Without the deterministic harness, every bug fix would be vibes-based.
|
||||
The workflow connects them: the fuzzer finds something, I sift the logs for a root cause, write the smallest deterministic scenario that fails the same way, then fix until both stay green. Without the second harness, every fix would be an act of faith.
|
||||
|
||||
## Smaller calls
|
||||
|
||||
|
|
@ -93,7 +93,7 @@ The workflow: fuzz finds something, I sift logs for a root cause, write the mini
|
|||
- **`sqlx::query!` macros over a checked-in `.sqlx` cache.** SQL is verified against the schema at compile time. Touching SQL means re-running `cargo sqlx prepare --workspace`; if you forget, CI catches it.
|
||||
- **One sync engine, four consumers.** `sync-client` is the engine. Obsidian plugin, standalone CLI, fuzz harness, and deterministic harness all depend on it via `file:../sync-client`. Bugs are fixed once and inherited everywhere.
|
||||
- **`record.localPath` mutates in place across awaits.** The watcher can rename a doc while a wire-loop handler is mid-HTTP. Snapshotting `localPath` into a local at function entry and reading it after the await reads a vacated slot. Read it live; only snapshot when you deliberately want to compare _before_ and _after_ the await.
|
||||
- **Watermark advancement is load-bearing both ways.** Branches that skip a remote event without advancing `lastSeenUpdateId` create permanent gaps that re-deliver forever. Branches that advance without applying the content lose data. The rule that survives review is: advance only if you applied the event or deliberately discarded it.
|
||||
- **Watermark advancement can fail in both directions.** A branch that skips a remote event without advancing `lastSeenUpdateId` creates a permanent gap that re-delivers forever; a branch that advances without applying the content loses data. The rule that survives code review: advance only if you applied the event or deliberately discarded it.
|
||||
|
||||
## The race I haven't structurally fixed
|
||||
|
||||
|
|
@ -108,4 +108,4 @@ The two-loop split doesn't fix this and probably shouldn't. The honest path is s
|
|||
- **A first-class "pause" with a write-ahead op log.** See above.
|
||||
- **More than `.md` and `.txt`.** A canvas-aware merge for Obsidian's `.canvas` files is one reconcile-text tokeniser away. Not because anyone asked, but because the asymmetry annoys me.
|
||||
|
||||
The way I think about VaultLink now: reconcile-text was the bet. VaultLink is what I built once the bet looked like it might pay off. The interesting part of the bet was always that three independently-edited files can become one without anyone telling the system about the keystrokes that produced them. The interesting part of the application is everything you have to do _around_ that merge to stop the rest of the system from undoing it.
|
||||
The way I think about VaultLink now: reconcile-text was the bet, and VaultLink is what I built once the bet looked like paying off. The bet said three independently edited files can become one without anyone telling the system about the keystrokes that produced them. What building the application taught me is that the merge is the easy half; the real work is everything around it, arranged so the rest of the system can't undo what the merge got right.
|
||||
|
|
|
|||
|
|
@ -2,7 +2,13 @@
|
|||
import Footer from '../components/Footer.astro';
|
||||
import Header from '../components/Header.astro';
|
||||
import Analytics from '../components/Analytics.astro';
|
||||
import { absoluteUrl, optimizeOgImage, site } from '../lib/site';
|
||||
import {
|
||||
THEME_BG,
|
||||
absoluteUrl,
|
||||
normalizeTrailingSlash,
|
||||
optimizeOgImage,
|
||||
site,
|
||||
} from '../lib/site';
|
||||
import defaultOg from '../assets/og-default.jpg';
|
||||
import themeInit from '../scripts/theme-init.js?raw';
|
||||
import '../styles/global.css';
|
||||
|
|
@ -46,13 +52,7 @@ 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);
|
||||
const canonical = absoluteUrl(normalizeTrailingSlash(rawCanonicalPath));
|
||||
|
||||
let resolvedOgImage = ogImage;
|
||||
let resolvedOgWidth = ogImageWidth ?? 1200;
|
||||
|
|
@ -81,6 +81,9 @@ const ogImageType =
|
|||
: ogImageExt === 'svg'
|
||||
? 'image/svg+xml'
|
||||
: 'image/jpeg';
|
||||
// theme-init.js is inlined raw (it must run before paint), so it can't import
|
||||
// the theme colors; they're substituted into its placeholder here instead.
|
||||
const themeInitScript = themeInit.replace('__THEME_BG__', JSON.stringify(THEME_BG));
|
||||
const jsonLdEntries = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : [];
|
||||
const jsonLdStrings = jsonLdEntries.map((entry) =>
|
||||
JSON.stringify(entry).replace(/</g, '\\u003c')
|
||||
|
|
@ -101,9 +104,17 @@ const jsonLdStrings = jsonLdEntries.map((entry) =>
|
|||
<meta name="color-scheme" content="light dark" />
|
||||
{noindex && <meta name="robots" content="noindex,follow" />}
|
||||
{!noindex && <link rel="canonical" href={canonical} />}
|
||||
<meta name="theme-color" content="#fbfaf7" media="(prefers-color-scheme: light)" />
|
||||
<meta name="theme-color" content="#201f1d" media="(prefers-color-scheme: dark)" />
|
||||
<script is:inline data-theme-script set:html={themeInit} />
|
||||
<meta
|
||||
name="theme-color"
|
||||
content={THEME_BG.light}
|
||||
media="(prefers-color-scheme: light)"
|
||||
/>
|
||||
<meta
|
||||
name="theme-color"
|
||||
content={THEME_BG.dark}
|
||||
media="(prefers-color-scheme: dark)"
|
||||
/>
|
||||
<script is:inline data-theme-script set:html={themeInitScript} />
|
||||
<link
|
||||
rel="preload"
|
||||
href="/fonts/source-sans-3-latin-variable.woff2"
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
getHeaderVideo,
|
||||
getRelatedArticles,
|
||||
optimizeOgImage,
|
||||
visibleBreadcrumbs,
|
||||
} from '../lib/site';
|
||||
import Base from './Base.astro';
|
||||
|
||||
|
|
@ -36,10 +37,7 @@ const related = getRelatedArticles(allPosts, post, 3);
|
|||
const ogImageOptimized = await optimizeOgImage(post.data.thumbnail.src);
|
||||
|
||||
const trail = buildBreadcrumbTrail({ post });
|
||||
const breadcrumbTrail = trail.map((c, i) => ({
|
||||
label: c.name,
|
||||
href: i === trail.length - 1 ? undefined : c.href,
|
||||
}));
|
||||
const breadcrumbTrail = visibleBreadcrumbs(trail);
|
||||
|
||||
// Reading time: words in body / 200 wpm, rounded up.
|
||||
const wordCount = post.body ? post.body.trim().split(/\s+/).filter(Boolean).length : 0;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,12 @@ import { getCollection } from 'astro:content';
|
|||
import { getImage } from 'astro:assets';
|
||||
import type { ImageMetadata } from 'astro';
|
||||
|
||||
// Theme background colors, the single source of truth for the
|
||||
// <meta name="theme-color"> tags and the FOUC-prevention scripts (injected in
|
||||
// Base.astro and Header.astro). Keep --color-bg in global.css in sync; CSS
|
||||
// cannot import these values.
|
||||
export const THEME_BG = { light: '#fbfaf7', dark: '#201f1d' };
|
||||
|
||||
export const site = {
|
||||
brand: 'schmelczer.dev',
|
||||
name: 'Andras Schmelczer',
|
||||
|
|
@ -54,6 +60,17 @@ export function yearOf(date: Date) {
|
|||
return new Intl.DateTimeFormat('en', { year: 'numeric' }).format(date);
|
||||
}
|
||||
|
||||
export function isExternal(url: string) {
|
||||
return /^https?:\/\//.test(url);
|
||||
}
|
||||
|
||||
// Directory-style paths get a trailing slash; the root and file-style paths
|
||||
// (e.g. /rss.xml) pass through unchanged. Shared by canonical URL generation
|
||||
// and the header's current-page matching so the two can never disagree.
|
||||
export function normalizeTrailingSlash(path: string) {
|
||||
return path === '/' || path.endsWith('/') || /\.[^/]+$/.test(path) ? path : `${path}/`;
|
||||
}
|
||||
|
||||
export function entrySlug(entry: { id: string }) {
|
||||
return entry.id.replace(/\.mdx?$/, '').replace(/\/index$/, '');
|
||||
}
|
||||
|
|
@ -268,6 +285,15 @@ export function buildBreadcrumbTrail({
|
|||
return trail;
|
||||
}
|
||||
|
||||
// Adapts a trail for the visible Breadcrumbs component, which renders the
|
||||
// current page (the last crumb) as plain text rather than a link.
|
||||
export function visibleBreadcrumbs(trail: BreadcrumbCrumb[]) {
|
||||
return trail.map((crumb, index) => ({
|
||||
label: crumb.name,
|
||||
href: index === trail.length - 1 ? undefined : crumb.href,
|
||||
}));
|
||||
}
|
||||
|
||||
// Builds the schema.org BreadcrumbList JSON-LD object for a given trail.
|
||||
// Shared by every page that emits breadcrumb structured data.
|
||||
export function buildBreadcrumbJsonLd(trail: BreadcrumbCrumb[]) {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
getAllTags,
|
||||
getArticles,
|
||||
tagSlug,
|
||||
visibleBreadcrumbs,
|
||||
} from '../../lib/site';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
|
|
@ -27,10 +28,7 @@ const filteredPosts = posts.filter((post) =>
|
|||
);
|
||||
const title = `Articles tagged "${tag}"`;
|
||||
const trail = buildBreadcrumbTrail({ tag });
|
||||
const visibleTrail = trail.map((c, i) => ({
|
||||
label: c.name,
|
||||
href: i === trail.length - 1 ? undefined : c.href,
|
||||
}));
|
||||
const visibleTrail = visibleBreadcrumbs(trail);
|
||||
const breadcrumbJsonLd = buildBreadcrumbJsonLd(trail);
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { init as plausibleInit } from '@plausible-analytics/tracker';
|
|||
|
||||
const ANALYTICS_AUTO_CAPTURE_PAGEVIEWS = true;
|
||||
const ANALYTICS_DOMAIN = 'schmelczer.dev';
|
||||
// Self-hosted Plausible proxy. The non-standard `/status` path is deliberate:
|
||||
// the default `/api/event` path is on adblocker filter lists.
|
||||
const ANALYTICS_ENDPOINT = 'https://stats.schmelczer.dev/status';
|
||||
const ANALYTICS_LOGGING = import.meta.env.DEV;
|
||||
|
||||
|
|
|
|||
|
|
@ -2,15 +2,18 @@
|
|||
// 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.
|
||||
//
|
||||
// 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).
|
||||
// THEME_BG is substituted by Base.astro from src/lib/site.ts (the single
|
||||
// source of truth). The values 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 () {
|
||||
document.documentElement.classList.remove('no-js');
|
||||
document.documentElement.classList.add('js');
|
||||
|
||||
var STORAGE_KEY = 'theme';
|
||||
var THEME_BG = { light: '#fbfaf7', dark: '#201f1d' };
|
||||
// The placeholder string is replaced with the serialized colors at build
|
||||
// time; parsing keeps this file valid standalone JavaScript.
|
||||
var THEME_BG = JSON.parse('__THEME_BG__');
|
||||
var saved = null;
|
||||
try {
|
||||
var value = localStorage.getItem(STORAGE_KEY);
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@
|
|||
--font-mono: 'IBM Plex Mono', 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace;
|
||||
|
||||
/* Palette: light-dark() pairs each token (light, dark) */
|
||||
/* Keep --color-bg in sync with THEME_BG in src/lib/site.ts, which drives the
|
||||
browser-chrome theme-color metas (CSS cannot import those values). */
|
||||
--color-bg: light-dark(#fbfaf7, #201f1d);
|
||||
--color-fg: light-dark(#181817, #d8d0c3);
|
||||
/* Contrast with --color-bg: light ~5.4:1, dark ~6.5:1 (both clear WCAG AA
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue