diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index c219e9b..0000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,11 +0,0 @@ -version: 2 -updates: - - package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "weekly" - - - package-ecosystem: "npm" - directory: "/" - schedule: - interval: "weekly" diff --git a/README.md b/README.md index ccb3b1f..26b3a2e 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ required client JavaScript. ## Setup ```sh -npm install +npm ci npx playwright install --with-deps chromium # required before `npm run qa:overflow` ``` diff --git a/astro.config.mjs b/astro.config.mjs index 5b6f436..56c2b91 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -5,6 +5,7 @@ import sitemap from '@astrojs/sitemap'; import { defineConfig } from 'astro/config'; import rehypeAutolinkHeadings from 'rehype-autolink-headings'; import rehypeSlug from 'rehype-slug'; +import { visit } from 'unist-util-visit'; // Build a lookup of post slugs to their last modification dates so the sitemap // can advertise accurate values to crawlers. astro:content isn't @@ -38,17 +39,12 @@ const postLastmodLookup = new Map( export default defineConfig({ site: 'https://schmelczer.dev', - trailingSlash: 'always', - build: { inlineStylesheets: 'always' }, - redirects: { - '/writing/': '/articles/', - '/writing/[slug]': '/articles/[slug]', - }, + trailingSlash: 'ignore', integrations: [ sitemap({ filter: (page) => { const path = new URL(page).pathname; - return !path.startsWith('/writing/') && path !== '/404/'; + return !/^\/tags\/[^/]+\/?$/.test(path) && path !== '/404/'; }, serialize(item) { const url = new URL(item.url); @@ -92,13 +88,49 @@ export default defineConfig({ behavior: 'append', properties: { className: ['heading-anchor'], - ariaLabel: 'Permalink', }, // Glyph rendered via CSS ::before so it doesn't leak into the TOC // when astro:content extracts heading.text from the rendered HTML. content: [], }, ], + // Make scrollable code blocks and tables reachable via keyboard (WCAG + // 2.1.1): without tabindex, a keyboard user cannot focus a horizontally + // overflowing
 or  to scroll it. tabindex=0 is sufficient
+      // on its own; role=region would require a meaningful per-block label,
+      // which we don't have at markdown level.
+      function rehypeFocusableScrollables() {
+        const SCROLLABLE = new Set(['pre', 'table']);
+        return (tree) => {
+          visit(tree, 'element', (node) => {
+            if (!SCROLLABLE.has(node.tagName)) return;
+            node.properties.tabindex = '0';
+          });
+        };
+      },
+      function rehypeLabelHeadingPermalinks() {
+        function textOf(node) {
+          if (!node) return '';
+          if (node.type === 'text') return node.value ?? '';
+          return (node.children ?? []).map(textOf).join('');
+        }
+
+        return (tree) => {
+          visit(tree, 'element', (node) => {
+            if (!/^h[2-6]$/.test(node.tagName)) return;
+            const headingText = textOf(node).trim();
+            if (!headingText) return;
+
+            for (const child of node.children ?? []) {
+              const className = child.properties?.className;
+              const classes = Array.isArray(className) ? className : [className];
+              if (child.tagName === 'a' && classes.includes('heading-anchor')) {
+                child.properties.ariaLabel = `Permalink to ${headingText}`;
+              }
+            }
+          });
+        };
+      },
     ],
   },
 });
diff --git a/package-lock.json b/package-lock.json
index c5b1d84..0ea3e53 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -6,9 +6,6 @@
     "": {
       "name": "schmelczer-dev",
       "license": "GPL-3.0-or-later",
-      "dependencies": {
-        "sharp": "^0.34.5"
-      },
       "devDependencies": {
         "@astrojs/check": "^0.9.9",
         "@astrojs/rss": "^4.0.18",
@@ -19,7 +16,12 @@
         "prettier-plugin-astro": "^0.14.1",
         "rehype-autolink-headings": "^7.1.0",
         "rehype-slug": "^6.0.0",
-        "typescript": "^5.9.3"
+        "sharp": "^0.34.5",
+        "typescript": "^5.9.3",
+        "unist-util-visit": "^5.1.0"
+      },
+      "engines": {
+        "node": ">=22.13.0"
       }
     },
     "node_modules/@astrojs/check": {
@@ -474,6 +476,7 @@
       "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": {
@@ -926,6 +929,7 @@
       "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"
@@ -938,6 +942,7 @@
       "cpu": [
         "arm64"
       ],
+      "dev": true,
       "license": "Apache-2.0",
       "optional": true,
       "os": [
@@ -960,6 +965,7 @@
       "cpu": [
         "x64"
       ],
+      "dev": true,
       "license": "Apache-2.0",
       "optional": true,
       "os": [
@@ -982,6 +988,7 @@
       "cpu": [
         "arm64"
       ],
+      "dev": true,
       "license": "LGPL-3.0-or-later",
       "optional": true,
       "os": [
@@ -998,6 +1005,7 @@
       "cpu": [
         "x64"
       ],
+      "dev": true,
       "license": "LGPL-3.0-or-later",
       "optional": true,
       "os": [
@@ -1014,9 +1022,7 @@
       "cpu": [
         "arm"
       ],
-      "libc": [
-        "glibc"
-      ],
+      "dev": true,
       "license": "LGPL-3.0-or-later",
       "optional": true,
       "os": [
@@ -1033,9 +1039,7 @@
       "cpu": [
         "arm64"
       ],
-      "libc": [
-        "glibc"
-      ],
+      "dev": true,
       "license": "LGPL-3.0-or-later",
       "optional": true,
       "os": [
@@ -1052,9 +1056,7 @@
       "cpu": [
         "ppc64"
       ],
-      "libc": [
-        "glibc"
-      ],
+      "dev": true,
       "license": "LGPL-3.0-or-later",
       "optional": true,
       "os": [
@@ -1071,9 +1073,7 @@
       "cpu": [
         "riscv64"
       ],
-      "libc": [
-        "glibc"
-      ],
+      "dev": true,
       "license": "LGPL-3.0-or-later",
       "optional": true,
       "os": [
@@ -1090,9 +1090,7 @@
       "cpu": [
         "s390x"
       ],
-      "libc": [
-        "glibc"
-      ],
+      "dev": true,
       "license": "LGPL-3.0-or-later",
       "optional": true,
       "os": [
@@ -1109,9 +1107,7 @@
       "cpu": [
         "x64"
       ],
-      "libc": [
-        "glibc"
-      ],
+      "dev": true,
       "license": "LGPL-3.0-or-later",
       "optional": true,
       "os": [
@@ -1128,9 +1124,7 @@
       "cpu": [
         "arm64"
       ],
-      "libc": [
-        "musl"
-      ],
+      "dev": true,
       "license": "LGPL-3.0-or-later",
       "optional": true,
       "os": [
@@ -1147,9 +1141,7 @@
       "cpu": [
         "x64"
       ],
-      "libc": [
-        "musl"
-      ],
+      "dev": true,
       "license": "LGPL-3.0-or-later",
       "optional": true,
       "os": [
@@ -1166,9 +1158,7 @@
       "cpu": [
         "arm"
       ],
-      "libc": [
-        "glibc"
-      ],
+      "dev": true,
       "license": "Apache-2.0",
       "optional": true,
       "os": [
@@ -1191,9 +1181,7 @@
       "cpu": [
         "arm64"
       ],
-      "libc": [
-        "glibc"
-      ],
+      "dev": true,
       "license": "Apache-2.0",
       "optional": true,
       "os": [
@@ -1216,9 +1204,7 @@
       "cpu": [
         "ppc64"
       ],
-      "libc": [
-        "glibc"
-      ],
+      "dev": true,
       "license": "Apache-2.0",
       "optional": true,
       "os": [
@@ -1241,9 +1227,7 @@
       "cpu": [
         "riscv64"
       ],
-      "libc": [
-        "glibc"
-      ],
+      "dev": true,
       "license": "Apache-2.0",
       "optional": true,
       "os": [
@@ -1266,9 +1250,7 @@
       "cpu": [
         "s390x"
       ],
-      "libc": [
-        "glibc"
-      ],
+      "dev": true,
       "license": "Apache-2.0",
       "optional": true,
       "os": [
@@ -1291,9 +1273,7 @@
       "cpu": [
         "x64"
       ],
-      "libc": [
-        "glibc"
-      ],
+      "dev": true,
       "license": "Apache-2.0",
       "optional": true,
       "os": [
@@ -1316,9 +1296,7 @@
       "cpu": [
         "arm64"
       ],
-      "libc": [
-        "musl"
-      ],
+      "dev": true,
       "license": "Apache-2.0",
       "optional": true,
       "os": [
@@ -1341,9 +1319,7 @@
       "cpu": [
         "x64"
       ],
-      "libc": [
-        "musl"
-      ],
+      "dev": true,
       "license": "Apache-2.0",
       "optional": true,
       "os": [
@@ -1366,6 +1342,7 @@
       "cpu": [
         "wasm32"
       ],
+      "dev": true,
       "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
       "optional": true,
       "dependencies": {
@@ -1385,6 +1362,7 @@
       "cpu": [
         "arm64"
       ],
+      "dev": true,
       "license": "Apache-2.0 AND LGPL-3.0-or-later",
       "optional": true,
       "os": [
@@ -1404,6 +1382,7 @@
       "cpu": [
         "ia32"
       ],
+      "dev": true,
       "license": "Apache-2.0 AND LGPL-3.0-or-later",
       "optional": true,
       "os": [
@@ -1423,6 +1402,7 @@
       "cpu": [
         "x64"
       ],
+      "dev": true,
       "license": "Apache-2.0 AND LGPL-3.0-or-later",
       "optional": true,
       "os": [
@@ -1590,9 +1570,6 @@
         "arm"
       ],
       "dev": true,
-      "libc": [
-        "glibc"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -1607,9 +1584,6 @@
         "arm"
       ],
       "dev": true,
-      "libc": [
-        "musl"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -1624,9 +1598,6 @@
         "arm64"
       ],
       "dev": true,
-      "libc": [
-        "glibc"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -1641,9 +1612,6 @@
         "arm64"
       ],
       "dev": true,
-      "libc": [
-        "musl"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -1658,9 +1626,6 @@
         "loong64"
       ],
       "dev": true,
-      "libc": [
-        "glibc"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -1675,9 +1640,6 @@
         "loong64"
       ],
       "dev": true,
-      "libc": [
-        "musl"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -1692,9 +1654,6 @@
         "ppc64"
       ],
       "dev": true,
-      "libc": [
-        "glibc"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -1709,9 +1668,6 @@
         "ppc64"
       ],
       "dev": true,
-      "libc": [
-        "musl"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -1726,9 +1682,6 @@
         "riscv64"
       ],
       "dev": true,
-      "libc": [
-        "glibc"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -1743,9 +1696,6 @@
         "riscv64"
       ],
       "dev": true,
-      "libc": [
-        "musl"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -1760,9 +1710,6 @@
         "s390x"
       ],
       "dev": true,
-      "libc": [
-        "glibc"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -1777,9 +1724,6 @@
         "x64"
       ],
       "dev": true,
-      "libc": [
-        "glibc"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -1794,9 +1738,6 @@
         "x64"
       ],
       "dev": true,
-      "libc": [
-        "musl"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -2217,6 +2158,19 @@
         "node": ">= 8"
       }
     },
+    "node_modules/anymatch/node_modules/picomatch": {
+      "version": "2.3.2",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+      "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/jonschlinkert"
+      }
+    },
     "node_modules/arg": {
       "version": "5.0.2",
       "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
@@ -2962,15 +2916,16 @@
       "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"
       }
     },
     "node_modules/devalue": {
-      "version": "5.8.0",
-      "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.0.tgz",
-      "integrity": "sha512-2zA9pFEsnp7vWBZbXF5JAgAq0fsUIt/1XPbRiAmRV3lp/2C3upzH+sADiyy66aFCihoLEsrQHxNM5w1gIDfsBg==",
+      "version": "5.8.1",
+      "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz",
+      "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==",
       "dev": true,
       "license": "MIT"
     },
@@ -4798,18 +4753,6 @@
       "dev": true,
       "license": "ISC"
     },
-    "node_modules/picomatch": {
-      "version": "2.3.1",
-      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
-      "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
-      "dev": true,
-      "engines": {
-        "node": ">=8.6"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/jonschlinkert"
-      }
-    },
     "node_modules/playwright": {
       "version": "1.59.1",
       "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
@@ -5322,6 +5265,7 @@
       "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"
@@ -5334,6 +5278,7 @@
       "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": {
@@ -5606,6 +5551,7 @@
       "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": {
@@ -6350,9 +6296,9 @@
       }
     },
     "node_modules/yaml": {
-      "version": "2.8.4",
-      "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz",
-      "integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==",
+      "version": "2.9.0",
+      "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
+      "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
       "dev": true,
       "license": "ISC",
       "bin": {
@@ -6434,19 +6380,6 @@
       "dev": true,
       "license": "MIT"
     },
-    "node_modules/yaml-language-server/node_modules/yaml": {
-      "version": "2.7.1",
-      "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.1.tgz",
-      "integrity": "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==",
-      "dev": true,
-      "license": "ISC",
-      "bin": {
-        "yaml": "bin.mjs"
-      },
-      "engines": {
-        "node": ">= 14"
-      }
-    },
     "node_modules/yargs": {
       "version": "17.7.2",
       "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
@@ -6712,7 +6645,7 @@
       "integrity": "sha512-PJzRmgQzUxI2uwpdX2lXSHtP4G8ocp24/t+bZyf5Fy0SZLSF9f9KXZoMlFM/XCGue+B0nH/2IZ7FpBYQATBsCg==",
       "dev": true,
       "requires": {
-        "yaml": "^2.8.2"
+        "yaml": "^2.9.0"
       }
     },
     "@babel/helper-string-parser": {
@@ -6838,6 +6771,7 @@
       "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"
@@ -7028,12 +6962,14 @@
     "@img/colour": {
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
-      "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="
+      "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
+      "dev": true
     },
     "@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"
@@ -7043,6 +6979,7 @@
       "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"
@@ -7052,66 +6989,77 @@
       "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"
@@ -7121,6 +7069,7 @@
       "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"
@@ -7130,6 +7079,7 @@
       "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"
@@ -7139,6 +7089,7 @@
       "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"
@@ -7148,6 +7099,7 @@
       "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"
@@ -7157,6 +7109,7 @@
       "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"
@@ -7166,6 +7119,7 @@
       "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"
@@ -7175,6 +7129,7 @@
       "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"
@@ -7184,6 +7139,7 @@
       "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"
@@ -7193,18 +7149,21 @@
       "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": {
@@ -7693,6 +7652,14 @@
       "requires": {
         "normalize-path": "^3.0.0",
         "picomatch": "^2.0.4"
+      },
+      "dependencies": {
+        "picomatch": {
+          "version": "2.3.2",
+          "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+          "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
+          "dev": true
+        }
       }
     },
     "arg": {
@@ -8140,12 +8107,13 @@
     "detect-libc": {
       "version": "2.1.2",
       "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
-      "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="
+      "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+      "dev": true
     },
     "devalue": {
-      "version": "5.8.0",
-      "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.0.tgz",
-      "integrity": "sha512-2zA9pFEsnp7vWBZbXF5JAgAq0fsUIt/1XPbRiAmRV3lp/2C3upzH+sADiyy66aFCihoLEsrQHxNM5w1gIDfsBg==",
+      "version": "5.8.1",
+      "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz",
+      "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==",
       "dev": true
     },
     "devlop": {
@@ -9340,12 +9308,6 @@
       "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
       "dev": true
     },
-    "picomatch": {
-      "version": "2.3.1",
-      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
-      "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
-      "dev": true
-    },
     "playwright": {
       "version": "1.59.1",
       "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
@@ -9694,12 +9656,14 @@
     "semver": {
       "version": "7.8.0",
       "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
-      "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="
+      "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
+      "dev": true
     },
     "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",
@@ -9880,6 +9844,7 @@
       "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": {
@@ -10328,9 +10293,9 @@
       "dev": true
     },
     "yaml": {
-      "version": "2.8.4",
-      "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz",
-      "integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==",
+      "version": "2.9.0",
+      "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
+      "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
       "dev": true
     },
     "yaml-language-server": {
@@ -10349,7 +10314,7 @@
         "vscode-languageserver-textdocument": "^1.0.1",
         "vscode-languageserver-types": "^3.16.0",
         "vscode-uri": "^3.0.2",
-        "yaml": "2.7.1"
+        "yaml": "^2.9.0"
       },
       "dependencies": {
         "ajv": {
@@ -10382,12 +10347,6 @@
           "resolved": "https://registry.npmjs.org/request-light/-/request-light-0.5.8.tgz",
           "integrity": "sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg==",
           "dev": true
-        },
-        "yaml": {
-          "version": "2.7.1",
-          "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.1.tgz",
-          "integrity": "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==",
-          "dev": true
         }
       }
     },
diff --git a/package.json b/package.json
index 1f56552..66171b0 100644
--- a/package.json
+++ b/package.json
@@ -3,6 +3,10 @@
   "description": "A static personal blog for Andras Schmelczer.",
   "private": true,
   "type": "module",
+  "packageManager": "npm@10.9.2",
+  "engines": {
+    "node": ">=22.13.0"
+  },
   "scripts": {
     "dev": "astro dev",
     "start": "astro dev",
@@ -11,9 +15,10 @@
     "format": "prettier --write \"astro.config.mjs\" \"src/**/*.{astro,ts,md,css}\" \"scripts/*.mjs\" \"*.md\" \"*.json\"",
     "build": "astro build",
     "preview": "astro preview",
+    "qa:links": "node scripts/check-links.mjs",
     "qa:no-js": "node scripts/check-no-js.mjs",
     "qa:overflow": "node scripts/install-playwright-deps.mjs && node scripts/check-overflow.mjs",
-    "qa": "npm run typecheck && npm run lint && npm run build && npm run qa:no-js && npm run qa:overflow"
+    "qa": "npm run typecheck && npm run lint && npm run build && npm run qa:links && npm run qa:no-js && npm run qa:overflow"
   },
   "repository": {
     "type": "git",
@@ -41,9 +46,11 @@
     "prettier-plugin-astro": "^0.14.1",
     "rehype-autolink-headings": "^7.1.0",
     "rehype-slug": "^6.0.0",
-    "typescript": "^5.9.3"
-  },
-  "dependencies": {
+    "typescript": "^5.9.3",
+    "unist-util-visit": "^5.1.0",
     "sharp": "^0.34.5"
+  },
+  "overrides": {
+    "yaml": "^2.9.0"
   }
 }
diff --git a/public/media/video/ad_astra.mp4 b/public/media/video/ad_astra.mp4
index 50e7274..9a094c9 100644
Binary files a/public/media/video/ad_astra.mp4 and b/public/media/video/ad_astra.mp4 differ
diff --git a/public/media/video/ad_astra.vtt b/public/media/video/ad_astra.vtt
new file mode 100644
index 0000000..578c7b6
--- /dev/null
+++ b/public/media/video/ad_astra.vtt
@@ -0,0 +1,13 @@
+WEBVTT
+
+00:00.000 --> 00:04.000
+No spoken dialogue. Game audio only.
+
+00:04.000 --> 00:35.000
+The Ad Astra handheld board runs the game on a small OLED display.
+
+00:35.000 --> 01:05.000
+The player controls the game through the IR input while the engine updates the display in real time.
+
+01:05.000 --> 01:34.600
+The clip continues showing gameplay on the custom ATtiny85-based board.
diff --git a/public/media/video/ad_astra.webm b/public/media/video/ad_astra.webm
index 444dba7..2ad0d44 100644
Binary files a/public/media/video/ad_astra.webm and b/public/media/video/ad_astra.webm differ
diff --git a/scripts/check-links.mjs b/scripts/check-links.mjs
new file mode 100644
index 0000000..b6f4301
--- /dev/null
+++ b/scripts/check-links.mjs
@@ -0,0 +1,136 @@
+import { readdir, readFile, stat } from 'node:fs/promises';
+import path from 'node:path';
+
+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;
+}
+
+try {
+  await stat(dist);
+} catch {
+  throw new Error('dist/ does not exist. Run npm run build first.');
+}
+
+const files = await walk(dist);
+const checkedFiles = files.filter((file) => /\.(html|xml|css|webmanifest)$/.test(file));
+
+function pagePathname(file) {
+  const rel = path.relative(dist, file).replaceAll(path.sep, '/');
+  if (rel === 'index.html') return '/';
+  if (rel.endsWith('/index.html')) return `/${rel.slice(0, -'index.html'.length)}`;
+  return `/${rel}`;
+}
+
+function collectUrlReferences(body, rel) {
+  const urls = [];
+
+  for (const match of body.matchAll(/\b(?:href|src|poster)=["']([^"']+)["']/g)) {
+    urls.push(match[1]);
+  }
+
+  for (const match of body.matchAll(/\bsrcset=["']([^"']+)["']/g)) {
+    for (const candidate of match[1].split(',')) {
+      const url = candidate.trim().split(/\s+/)[0];
+      if (url) urls.push(url);
+    }
+  }
+
+  if (rel.endsWith('.css')) {
+    for (const match of body.matchAll(/url\(\s*['"]?([^'")]+)['"]?\s*\)/g)) {
+      urls.push(match[1]);
+    }
+  }
+
+  if (rel.endsWith('.webmanifest')) {
+    try {
+      const manifest = JSON.parse(body);
+      for (const key of ['start_url', 'scope']) {
+        if (typeof manifest[key] === 'string') urls.push(manifest[key]);
+      }
+      for (const icon of manifest.icons ?? []) {
+        if (typeof icon?.src === 'string') urls.push(icon.src);
+      }
+      for (const screenshot of manifest.screenshots ?? []) {
+        if (typeof screenshot?.src === 'string') urls.push(screenshot.src);
+      }
+    } catch {
+      failures.push(`${rel}: invalid web manifest JSON`);
+    }
+  }
+
+  return urls;
+}
+
+for (const file of checkedFiles) {
+  const body = await readFile(file, 'utf8');
+  const rel = path.relative(dist, file);
+  const baseUrl = new URL(pagePathname(file), 'https://schmelczer.dev');
+
+  for (const raw of collectUrlReferences(body, rel)) {
+    if (/^(mailto:|tel:|data:)/i.test(raw)) continue;
+
+    let parsed;
+    try {
+      parsed = new URL(raw, baseUrl);
+    } catch {
+      failures.push(`${rel}: invalid URL ${raw}`);
+      continue;
+    }
+
+    if (parsed.origin !== 'https://schmelczer.dev') continue;
+    if (!(await targetExists(parsed.pathname))) {
+      failures.push(`${rel}: missing local target ${parsed.pathname}`);
+    }
+  }
+}
+
+if (failures.length > 0) {
+  console.error(failures.join('\n'));
+  process.exit(1);
+}
+
+console.log('No missing local href/src targets found in dist/.');
diff --git a/scripts/check-overflow.mjs b/scripts/check-overflow.mjs
index f867e8d..8b2e75a 100644
--- a/scripts/check-overflow.mjs
+++ b/scripts/check-overflow.mjs
@@ -1,14 +1,20 @@
 import { createServer } from 'node:http';
-import { readdir, readFile, stat } from 'node:fs/promises';
+import { mkdir, readdir, readFile, rm, stat } from 'node:fs/promises';
 import path from 'node:path';
 import { chromium } from 'playwright';
 
 const dist = path.resolve('dist');
+const browserTmp = path.resolve('.astro', 'playwright-overflow-tmp');
 const INDEX_FILE = 'index.html';
-const MAX_NAV_RETRIES = 3;
+const MAX_NAV_RETRIES = 4;
 // Common device widths: iPhone SE / Galaxy S / iPhone 14 / iPad portrait /
 // iPad landscape / common laptop / full HD desktop.
 const VIEWPORT_WIDTHS = [320, 390, 430, 768, 1024, 1440, 1920];
+const CLOSE_TIMEOUT_MS = 3000;
+const LAUNCH_TIMEOUT_MS = 10000;
+const CONTEXT_TIMEOUT_MS = 8000;
+const PAGE_TIMEOUT_MS = 15000;
+const MEASURE_TIMEOUT_MS = 25000;
 
 const MIME = {
   '.html': 'text/html; charset=utf-8',
@@ -25,6 +31,7 @@ const MIME = {
   '.woff2': 'font/woff2',
   '.mp4': 'video/mp4',
   '.webm': 'video/webm',
+  '.vtt': 'text/vtt; charset=utf-8',
   '.pdf': 'application/pdf',
 };
 
@@ -54,9 +61,6 @@ async function discoverRoutes() {
     if (!file.endsWith('.html')) continue;
     const rel = path.relative(dist, file).replaceAll(path.sep, '/');
     if (rel === '404.html') continue;
-    // /writing/* are meta-refresh redirect stubs to /articles/*, not real
-    // pages — measuring them would just remeasure /articles/.
-    if (rel.startsWith('writing/')) continue;
     if (rel === INDEX_FILE) {
       routes.add('/');
     } else if (rel.endsWith(`/${INDEX_FILE}`)) {
@@ -99,6 +103,16 @@ try {
   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;
+
 const routes = await discoverRoutes();
 
 const server = createServer(async (req, res) => {
@@ -115,51 +129,174 @@ const server = createServer(async (req, res) => {
 
 await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
 const { port } = server.address();
-const browser = await chromium.launch({ headless: true });
 const failures = [];
 
-async function measureViewport(page) {
-  for (let attempt = 0; attempt < MAX_NAV_RETRIES; attempt += 1) {
-    try {
-      await page.waitForLoadState('load');
-      return await page.evaluate(() => ({
-        scrollWidth: document.documentElement.scrollWidth,
-        clientWidth: document.documentElement.clientWidth,
-      }));
-    } catch (error) {
-      const message = error instanceof Error ? error.message : String(error);
-      const isLast = attempt === MAX_NAV_RETRIES - 1;
-      if (isLast || !/Execution context was destroyed|navigation/i.test(message)) {
-        throw error;
-      }
-      await page.waitForLoadState('load').catch(() => {});
+function launchBrowser() {
+  return chromium.launch({
+    headless: true,
+    env: {
+      ...process.env,
+      TMPDIR: browserTmp,
+      TMP: browserTmp,
+      TEMP: browserTmp,
+    },
+    args: ['--disable-dev-shm-usage', '--disable-gpu', '--no-sandbox'],
+  });
+}
+
+async function withTimeout(promise, timeoutMs, label) {
+  let timeout;
+  try {
+    return await Promise.race([
+      promise,
+      new Promise((_, reject) => {
+        timeout = setTimeout(() => reject(new Error(label)), timeoutMs);
+      }),
+    ]);
+  } finally {
+    clearTimeout(timeout);
+  }
+}
+
+async function safeClosePage(page) {
+  await withTimeout(
+    page.close(),
+    CLOSE_TIMEOUT_MS,
+    'Timed out while closing Playwright page'
+  ).catch(() => {});
+}
+
+async function safeCloseContext(context) {
+  await withTimeout(
+    context.close(),
+    CLOSE_TIMEOUT_MS,
+    'Timed out while closing Playwright context'
+  ).catch(() => {});
+}
+
+async function safeCloseBrowser(browser) {
+  const childProcess = browser.process?.();
+  try {
+    await withTimeout(
+      browser.close(),
+      CLOSE_TIMEOUT_MS,
+      'Timed out while closing Chromium'
+    );
+  } catch {
+    childProcess?.kill('SIGKILL');
+  }
+}
+
+async function openBrowser() {
+  return withTimeout(
+    launchBrowser(),
+    LAUNCH_TIMEOUT_MS,
+    'Timed out while launching Chromium'
+  );
+}
+
+async function newMeasurementContext(browser, width) {
+  const context = await browser.newContext({
+    viewport: { width, height: 900 },
+    javaScriptEnabled: true,
+  });
+  await context.route('**/*', (route) => {
+    const type = route.request().resourceType();
+    if (type === 'media') {
+      route.abort('blockedbyclient');
+    } else {
+      route.continue();
     }
+  });
+  return context;
+}
+
+async function openMeasurementContext(browser, width) {
+  return withTimeout(
+    newMeasurementContext(browser, width),
+    CONTEXT_TIMEOUT_MS,
+    `Timed out while creating ${width}px Playwright context`
+  );
+}
+
+async function measureViewport(page) {
+  await page.waitForLoadState('load', { timeout: 5000 }).catch(() => {});
+  return page.evaluate(() => ({
+    scrollWidth: document.documentElement.scrollWidth,
+    clientWidth: document.documentElement.clientWidth,
+  }));
+}
+
+function shouldRetryNavigation(error) {
+  const message = error instanceof Error ? error.message : String(error);
+  return /ERR_INSUFFICIENT_RESOURCES|Execution context was destroyed|Target.*closed|has been closed|Timed out while|navigation/i.test(
+    message
+  );
+}
+
+async function measureRoute(context, route) {
+  let page;
+  try {
+    page = await withTimeout(
+      context.newPage(),
+      PAGE_TIMEOUT_MS,
+      `Timed out while creating page for ${route}`
+    );
+    return await withTimeout(
+      (async () => {
+        await page.goto(`http://127.0.0.1:${port}${route}`, {
+          waitUntil: 'domcontentloaded',
+          timeout: 15000,
+        });
+        return measureViewport(page);
+      })(),
+      MEASURE_TIMEOUT_MS,
+      `Timed out while measuring ${route}`
+    );
+  } finally {
+    if (page) await safeClosePage(page);
   }
 }
 
 try {
   for (const width of VIEWPORT_WIDTHS) {
-    const page = await browser.newPage({
-      viewport: { width, height: 900 },
-      javaScriptEnabled: false,
-    });
+    let browser;
+    let context;
+    try {
+      browser = await openBrowser();
+      context = await openMeasurementContext(browser, width);
+      for (const route of routes) {
+        let result;
 
-    for (const route of routes) {
-      await page.goto(`http://127.0.0.1:${port}${route}`, { waitUntil: 'load' });
-      const result = await measureViewport(page);
+        for (let attempt = 0; attempt < MAX_NAV_RETRIES; attempt += 1) {
+          try {
+            result = await measureRoute(context, route);
+            break;
+          } catch (error) {
+            if (!shouldRetryNavigation(error) || attempt === MAX_NAV_RETRIES - 1) {
+              throw error;
+            }
+            await safeCloseContext(context);
+            await safeCloseBrowser(browser);
+            browser = await openBrowser();
+            context = await openMeasurementContext(browser, width);
+          }
+        }
 
-      if (result.scrollWidth > result.clientWidth + 1) {
-        failures.push(
-          `${route} overflows at ${width}px: ${result.scrollWidth}px > ${result.clientWidth}px`
-        );
+        if (result.scrollWidth > result.clientWidth + 1) {
+          failures.push(
+            `${route} overflows at ${width}px: ${result.scrollWidth}px > ${result.clientWidth}px`
+          );
+        }
       }
+    } finally {
+      if (context) await safeCloseContext(context);
+      if (browser) await safeCloseBrowser(browser);
     }
-
-    await page.close();
   }
 } finally {
-  await browser.close();
   server.close();
+  await rm(browserTmp, { recursive: true, force: true }).catch(() => {});
 }
 
 if (failures.length > 0) {
diff --git a/src/components/ArticleList.astro b/src/components/ArticleList.astro
index 1467019..791686e 100644
--- a/src/components/ArticleList.astro
+++ b/src/components/ArticleList.astro
@@ -7,11 +7,14 @@ import { ARTICLE_THUMBNAIL, articlePath, formatDate, formatDateShort } from '../
 interface Props {
   posts: CollectionEntry<'posts'>[];
   showYear?: boolean;
-  currentTag?: string;
   tagLimit?: number;
+  // Opt-in: eagerly load the first thumbnail. Only set when the list is
+  // reliably above the fold (home, tag pages). Lists below substantial
+  // content (related, archives by year, 404) should leave this off.
+  eagerFirstThumbnail?: boolean;
 }
 
-const { posts, showYear = true, currentTag, tagLimit = 3 } = Astro.props;
+const { posts, showYear = true, tagLimit = 3, eagerFirstThumbnail = false } = Astro.props;
 ---
 
 
    @@ -21,11 +24,13 @@ const { posts, showYear = true, currentTag, tagLimit = 3 } = Astro.props; return (
  1. ); diff --git a/src/components/EntryThumbnail.astro b/src/components/EntryThumbnail.astro index 4b9a51a..90eae9e 100644 --- a/src/components/EntryThumbnail.astro +++ b/src/components/EntryThumbnail.astro @@ -11,10 +11,11 @@ interface Props { sizes: string; loading?: 'lazy' | 'eager'; fetchpriority?: 'high' | 'low' | 'auto'; + ariaLabel?: string; // When the listing already has a focusable, screen-reader-visible title // link, the thumbnail link is visually duplicative. We keep it clickable - // for pointer users but drop it from the tab order and announce no alt - // text, so assistive tech doesn't read the same target twice. + // for pointer users but drop it from the tab order. The link still needs + // a name because some assistive tech exposes non-tabbable links. decorative?: boolean; } @@ -27,6 +28,7 @@ const { sizes, loading = 'lazy', fetchpriority, + ariaLabel, decorative = true, } = Astro.props; @@ -38,6 +40,7 @@ const isDecorativeLink = Boolean(href) && decorative; class:list={['entry-thumbnail', extraClass]} href={href} tabindex={isDecorativeLink ? -1 : undefined} + aria-label={isDecorativeLink ? (ariaLabel ?? alt) : undefined} > item.href !== '/'); } - + {/* address wraps only the author's contact details, per HTML spec. */} + + diff --git a/src/components/Header.astro b/src/components/Header.astro index 32d0937..115432c 100644 --- a/src/components/Header.astro +++ b/src/components/Header.astro @@ -1,7 +1,11 @@ --- import { navItems, site } from '../lib/site'; -const current = Astro.url.pathname; +const currentPath = Astro.url.pathname; +const current = + currentPath === '/' || currentPath.endsWith('/') || /\.[^/]+$/.test(currentPath) + ? currentPath + : `${currentPath}/`; // Exact match for the current page; section match (descendant URLs) for // ancestor links. `aria-current="page"` is reserved for the exact page, @@ -46,7 +50,13 @@ const headerNavItems = navItems.filter((item) => item.href !== '/' && !item.foot RSS feed - @@ -61,12 +71,19 @@ const headerNavItems = navItems.filter((item) => item.href !== '/' && !item.foot var switcher = document.getElementById('theme-switcher'); if (!switcher) return; + // Keep in sync with --color-bg in global.css and theme-init.js. + var THEME_BG = { light: '#fbfaf7', dark: '#151514' }; + var themeColorMetas = document.querySelectorAll('meta[name="theme-color"]'); + function sync(theme) { switcher.setAttribute('aria-pressed', String(theme === 'dark')); switcher.setAttribute( - 'aria-label', + 'title', theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme' ); + for (var i = 0; i < themeColorMetas.length; i += 1) { + themeColorMetas[i].setAttribute('content', THEME_BG[theme]); + } } sync(root.dataset.theme === 'dark' ? 'dark' : 'light'); diff --git a/src/components/PostMediaFigure.astro b/src/components/PostMediaFigure.astro index 4968cfb..ac7010d 100644 --- a/src/components/PostMediaFigure.astro +++ b/src/components/PostMediaFigure.astro @@ -10,30 +10,58 @@ interface Props { const { item } = Astro.props; -const fallbackFormatFor = (format: string | undefined): 'png' | 'jpg' => - format === 'png' ? 'png' : 'jpg'; +const videoWidth = item.type === 'video' ? (item.poster?.width ?? 1280) : undefined; +const videoHeight = item.type === 'video' ? (item.poster?.height ?? 720) : undefined; ---
    { item.type === 'video' ? ( - + // Decorative videos stay inert and hidden from assistive tech. Meaningful + // videos expose controls, captions, and an accessible name. + item.decorative ? ( + + ) : ( + + ) ) : ( item.src && ( ) } {item.caption && !item.decorative &&
    {item.caption}
    } - {item.transcript &&

    {item.transcript}

    } + { + item.transcript && ( +

    + Transcript: {item.transcript} +

    + ) + }
    diff --git a/src/components/ProjectList.astro b/src/components/ProjectList.astro index cf287c9..f738c60 100644 --- a/src/components/ProjectList.astro +++ b/src/components/ProjectList.astro @@ -3,13 +3,17 @@ import type { CollectionEntry } from 'astro:content'; import { getEntry } from 'astro:content'; import EntryThumbnail from './EntryThumbnail.astro'; import ProjectLinks from './ProjectLinks.astro'; -import { PROJECT_THUMBNAIL, articlePath, projectAnchor } from '../lib/site'; +import { PROJECT_THUMBNAIL, articlePath, entrySlug } from '../lib/site'; interface Props { projects: CollectionEntry<'projects'>[]; + // Opt-in: eagerly load the first thumbnail. Only set when the list is + // reliably above the fold. The home and projects-index lists sit below + // other sections, so leave this off there. + eagerFirstThumbnail?: boolean; } -const { projects } = Astro.props; +const { projects, eagerFirstThumbnail = false } = Astro.props; // The `essay` field is a `reference('posts')`, so when present it's always a // `{ collection, id }` shape that `getEntry` resolves to a CollectionEntry. @@ -25,7 +29,7 @@ for (const project of projects) {
      { projects.map((project, index) => { - const anchor = projectAnchor(project); + const anchor = entrySlug(project); const titleId = `${anchor}-title`; const essayHref = essayHrefs.get(project.id); const primaryHref = essayHref ?? project.data.links[0]?.url; @@ -39,8 +43,9 @@ for (const project of projects) { class="project-thumbnail" widths={PROJECT_THUMBNAIL.widths} sizes={PROJECT_THUMBNAIL.sizes} - loading={index === 0 ? 'eager' : 'lazy'} - fetchpriority={index === 0 ? 'high' : undefined} + ariaLabel={`Open project: ${project.data.title}`} + loading={eagerFirstThumbnail && index === 0 ? 'eager' : 'lazy'} + fetchpriority={eagerFirstThumbnail && index === 0 ? 'high' : undefined} />

      diff --git a/src/components/TagList.astro b/src/components/TagList.astro index 9a031bb..67e0875 100644 --- a/src/components/TagList.astro +++ b/src/components/TagList.astro @@ -19,7 +19,7 @@ const remaining = { visibleTags.map((tag) => (
    1. - + {tag} {counts && counts[tag] !== undefined && ( {counts[tag]} diff --git a/src/content.config.ts b/src/content.config.ts index 1e68fd3..46c0793 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -3,9 +3,38 @@ import type { SchemaContext } from 'astro:content'; import { glob } from 'astro/loaders'; import { z } from 'astro/zod'; +function isRootRelativeUrl(url: string) { + return url.startsWith('/') && !url.startsWith('//'); +} + +const linkUrl = z.string().refine( + (url) => { + if (isRootRelativeUrl(url)) return true; + try { + const parsed = new URL(url); + return ['https:', 'mailto:'].includes(parsed.protocol); + } catch { + return false; + } + }, + { message: 'URL must be an absolute https/mailto URL or a root-relative path.' } +); + +const mediaUrl = z.string().refine( + (url) => { + if (isRootRelativeUrl(url)) return true; + try { + return new URL(url).protocol === 'https:'; + } catch { + return false; + } + }, + { message: 'Media URL must be an absolute https URL or a root-relative path.' } +); + const linkSchema = z.object({ label: z.string(), - url: z.string(), + url: linkUrl, download: z.boolean().optional(), }); @@ -17,20 +46,47 @@ const thumbnailSchema = ({ image }: SchemaContext) => const mediaSchema = ({ image }: SchemaContext) => z - .object({ - type: z.enum(['image', 'video', 'diagram']), - src: image().optional(), - poster: image().optional(), - mp4: z.string().optional(), - webm: z.string().optional(), - alt: z.string().optional(), - decorative: z.boolean().optional(), - caption: z.string().optional(), - transcript: z.string().optional(), - }) + .discriminatedUnion('type', [ + z.object({ + type: z.enum(['image', 'diagram']), + src: image(), + alt: z.string().optional(), + decorative: z.boolean().optional(), + caption: z.string().optional(), + transcript: z.string().optional(), + }), + z + .object({ + type: z.literal('video'), + poster: image().optional(), + mp4: mediaUrl.optional(), + webm: mediaUrl.optional(), + captions: mediaUrl.optional(), + captionsLabel: z.string().default('English captions'), + alt: z.string().optional(), + decorative: z.boolean().optional(), + caption: z.string().optional(), + transcript: z.string().optional(), + }) + .refine((item) => Boolean(item.mp4) || Boolean(item.webm), { + message: 'Video media needs at least one mp4 or webm source.', + }), + ]) .refine((item) => item.decorative || (Boolean(item.alt) && Boolean(item.caption)), { message: 'Meaningful media needs both alt text and a caption.', - }); + }) + .refine( + (item) => item.type !== 'video' || item.decorative || Boolean(item.captions), + { + message: 'Meaningful video needs captions.', + } + ) + .refine( + (item) => item.type !== 'video' || item.decorative || Boolean(item.transcript), + { + message: 'Meaningful video needs a transcript.', + } + ); const posts = defineCollection({ loader: glob({ pattern: '**/*.md', base: './src/content/posts' }), @@ -72,7 +128,6 @@ const projects = defineCollection({ loader: glob({ pattern: '**/*.md', base: './src/content/projects' }), schema: ({ image }) => z.object({ - sourceProjectId: z.string(), title: z.string(), description: z.string().max(160), thumbnail: thumbnailSchema({ image }), diff --git a/src/content/posts/ad-astra-attiny85-game-engine.md b/src/content/posts/ad-astra-attiny85-game-engine.md index b2c5c51..0570b2d 100644 --- a/src/content/posts/ad-astra-attiny85-game-engine.md +++ b/src/content/posts/ad-astra-attiny85-game-engine.md @@ -20,8 +20,10 @@ media: poster: ./_assets/ad-astra.jpg webm: /media/video/ad_astra.webm mp4: /media/video/ad_astra.mp4 + captions: /media/video/ad_astra.vtt alt: Video demonstration of the embedded game running on a small OLED display. caption: The game engine ran on an ATtiny85V with an OLED display and IR input. + transcript: No spoken dialogue. The demonstration shows the Ad Astra handheld board running its OLED game, with the player moving through the small display while the IR input controls gameplay. --- Ad Astra came from wanting to combine graphics and microcontrollers without hiding behind a large development board. The result was a small embedded game engine and game built around an ATtiny85V, an OLED display, IR input, EEPROM persistence, and a custom PCB. diff --git a/src/content/posts/avoid-early-web-game.md b/src/content/posts/avoid-early-web-game.md index b411f5f..d556533 100644 --- a/src/content/posts/avoid-early-web-game.md +++ b/src/content/posts/avoid-early-web-game.md @@ -11,9 +11,6 @@ role: Game author stack: ['JavaScript', 'Canvas'] outcome: A small playable web game kept as an archive of early browser work audience: general -links: - - label: Demo - url: https://schmelczer.dev/avoid --- -I recently found my first web game. It is very simple, but I killed some time with it — feel free to try it out, and do not judge too harshly. +I recently found my first web game. It is very simple, but I killed some time with it. diff --git a/src/content/posts/declared-shared-simulation-code.md b/src/content/posts/declared-shared-simulation-code.md index 9ad1591..f26512c 100644 --- a/src/content/posts/declared-shared-simulation-code.md +++ b/src/content/posts/declared-shared-simulation-code.md @@ -15,8 +15,6 @@ audience: technical links: - label: Source url: https://github.com/schmelczer/decla.red - - label: Demo - url: https://decla.red - label: BSc thesis url: /media/downloads/sdf2d-andras-schmelczer.pdf download: true diff --git a/src/content/posts/fleeting-garden-webgpu-drawing.md b/src/content/posts/fleeting-garden-webgpu-drawing.md index aa9c858..3685455 100644 --- a/src/content/posts/fleeting-garden-webgpu-drawing.md +++ b/src/content/posts/fleeting-garden-webgpu-drawing.md @@ -14,9 +14,9 @@ outcome: A browser drawing toy where user input seeds an agent simulation that r audience: technical links: - label: Demo - url: https://schmelczer.dev/fleeting/ + url: /fleeting/ - label: Source - url: https://github.com/schmelczer/webgpu + url: https://home.schmelczer.dev/git/andras/webgpu media: - type: image src: ./_assets/fleeting-garden.jpg diff --git a/src/content/posts/life-towers-immutable-tries.md b/src/content/posts/life-towers-immutable-tries.md index d9478f3..9a90cf6 100644 --- a/src/content/posts/life-towers-immutable-tries.md +++ b/src/content/posts/life-towers-immutable-tries.md @@ -16,8 +16,6 @@ audience: recruiter-relevant links: - label: Source url: https://github.com/schmelczer/life-towers/ - - label: Demo - url: https://towers.schmelczer.dev media: - type: image src: ./_assets/towers.jpg diff --git a/src/content/posts/photo-site-generator.md b/src/content/posts/photo-site-generator.md index 0dca526..0fb796f 100644 --- a/src/content/posts/photo-site-generator.md +++ b/src/content/posts/photo-site-generator.md @@ -11,9 +11,7 @@ role: Site generator author stack: ['Webpack', 'Image processing', 'Static site generation'] outcome: A generated static photo site for publishing photography with responsive image output audience: general -links: - - label: Site - url: https://photo.schmelczer.dev +links: [] --- Photos was a small webpage where you could view my photos. diff --git a/src/content/posts/reconcile-text-3-way-merge.md b/src/content/posts/reconcile-text-3-way-merge.md index 2652e38..0645ebe 100644 --- a/src/content/posts/reconcile-text-3-way-merge.md +++ b/src/content/posts/reconcile-text-3-way-merge.md @@ -15,7 +15,7 @@ outcome: A small, well-tested library that fills a gap between git, CRDTs, and p audience: recruiter-relevant links: - label: Demo - url: https://schmelczer.dev/reconcile + url: /reconcile/ - label: Source url: https://github.com/schmelczer/reconcile - label: crates.io diff --git a/src/content/posts/sdf-2d-ray-tracing.md b/src/content/posts/sdf-2d-ray-tracing.md index 0263659..0486298 100644 --- a/src/content/posts/sdf-2d-ray-tracing.md +++ b/src/content/posts/sdf-2d-ray-tracing.md @@ -16,8 +16,6 @@ audience: recruiter-relevant links: - label: NPM package url: https://www.npmjs.com/package/sdf-2d - - label: Demo - url: https://sdf2d.schmelczer.dev - label: Video url: https://www.youtube.com/watch?v=K3cEtnZUNR0 - label: BSc thesis diff --git a/src/content/projects/ad-astra.md b/src/content/projects/ad-astra.md index d488e22..6b30932 100644 --- a/src/content/projects/ad-astra.md +++ b/src/content/projects/ad-astra.md @@ -1,5 +1,4 @@ --- -sourceProjectId: ad-astra title: Ad Astra description: A tiny embedded game engine and custom PCB built around an ATtiny85V. thumbnail: diff --git a/src/content/projects/avoid.md b/src/content/projects/avoid.md index 46193de..280b85f 100644 --- a/src/content/projects/avoid.md +++ b/src/content/projects/avoid.md @@ -1,5 +1,4 @@ --- -sourceProjectId: avoid title: Avoid description: A small early web game, kept as an archive of first experiments on the web. thumbnail: @@ -10,7 +9,4 @@ sortDate: 2018-01-01 technologies: ['JavaScript', 'Canvas'] selected: false essay: avoid-early-web-game -links: - - label: Demo - url: https://schmelczer.dev/avoid --- diff --git a/src/content/projects/city-simulation.md b/src/content/projects/city-simulation.md index 07fbea3..40b4fb8 100644 --- a/src/content/projects/city-simulation.md +++ b/src/content/projects/city-simulation.md @@ -1,5 +1,4 @@ --- -sourceProjectId: city-simulation title: City Simulation description: A Unity traffic simulation where REST-controlled traffic lights could produce visible consequences for a cybersecurity challenge. thumbnail: diff --git a/src/content/projects/colors.md b/src/content/projects/colors.md index 3121e3c..2d51055 100644 --- a/src/content/projects/colors.md +++ b/src/content/projects/colors.md @@ -1,5 +1,4 @@ --- -sourceProjectId: colors title: Photo Colour Grader description: A proof-of-concept colour grading UI based on selecting colours and transforming nearby colour ranges. thumbnail: diff --git a/src/content/projects/declared.md b/src/content/projects/declared.md index 36f7893..8c24a1f 100644 --- a/src/content/projects/declared.md +++ b/src/content/projects/declared.md @@ -1,5 +1,4 @@ --- -sourceProjectId: declared title: decla.red description: A team-based mobile multiplayer browser game with shared client/server game logic. thumbnail: @@ -13,8 +12,6 @@ essay: declared-shared-simulation-code links: - label: Source url: https://github.com/schmelczer/decla.red - - label: Demo - url: https://decla.red - label: BSc thesis url: /media/downloads/sdf2d-andras-schmelczer.pdf download: true diff --git a/src/content/projects/fleeting-garden.md b/src/content/projects/fleeting-garden.md index 9945dcc..8e2385d 100644 --- a/src/content/projects/fleeting-garden.md +++ b/src/content/projects/fleeting-garden.md @@ -1,5 +1,4 @@ --- -sourceProjectId: fleeting-garden title: Fleeting Garden description: A WebGPU drawing toy where coloured strokes spawn agents that follow them, branch off, and slowly rewrite the patch you laid down. thumbnail: @@ -12,7 +11,7 @@ selected: true essay: fleeting-garden-webgpu-drawing links: - label: Demo - url: https://schmelczer.dev/fleeting/ + url: /fleeting/ - label: Source - url: https://github.com/schmelczer/webgpu + url: https://home.schmelczer.dev/git/andras/webgpu --- diff --git a/src/content/projects/forex.md b/src/content/projects/forex.md index 0631203..672c7d1 100644 --- a/src/content/projects/forex.md +++ b/src/content/projects/forex.md @@ -1,5 +1,4 @@ --- -sourceProjectId: forex title: Foreign Exchange Prediction Experiment description: A frequency-domain prediction experiment using smoothing, differentiation, STFT, extrapolation, and inverse transforms. thumbnail: diff --git a/src/content/projects/great-ai.md b/src/content/projects/great-ai.md index 0720c71..7d07a1c 100644 --- a/src/content/projects/great-ai.md +++ b/src/content/projects/great-ai.md @@ -1,5 +1,4 @@ --- -sourceProjectId: great-ai title: GreatAI description: A Python framework and research project for making AI deployment best practices easier to adopt. thumbnail: diff --git a/src/content/projects/leds.md b/src/content/projects/leds.md index 045703e..28349c4 100644 --- a/src/content/projects/leds.md +++ b/src/content/projects/leds.md @@ -1,5 +1,4 @@ --- -sourceProjectId: leds title: Lights Synchronized to Music description: A Raspberry Pi music player that drove RGB LED strips from audio analysis. thumbnail: diff --git a/src/content/projects/my-notes.md b/src/content/projects/my-notes.md index 95dd1c2..5a08ad3 100644 --- a/src/content/projects/my-notes.md +++ b/src/content/projects/my-notes.md @@ -1,5 +1,4 @@ --- -sourceProjectId: my-notes title: My Notes description: A minimalist Android markdown note organizer and editor powered by Markwon. thumbnail: diff --git a/src/content/projects/nuclear-editor.md b/src/content/projects/nuclear-editor.md index 0f886bf..6be1da3 100644 --- a/src/content/projects/nuclear-editor.md +++ b/src/content/projects/nuclear-editor.md @@ -1,5 +1,4 @@ --- -sourceProjectId: nuclear-editor title: Graph Editor description: A JavaFX editor for creating and editing input graphs for the cooling system simulator. thumbnail: diff --git a/src/content/projects/nuclear-simulation.md b/src/content/projects/nuclear-simulation.md index 2bec042..e34a72c 100644 --- a/src/content/projects/nuclear-simulation.md +++ b/src/content/projects/nuclear-simulation.md @@ -1,5 +1,4 @@ --- -sourceProjectId: nuclear-simulation title: Cooling System Simulation description: A graph-based process simulation with a monitoring client and JavaFX input editor. thumbnail: diff --git a/src/content/projects/photos.md b/src/content/projects/photos.md index da74af9..a29a6fe 100644 --- a/src/content/projects/photos.md +++ b/src/content/projects/photos.md @@ -1,5 +1,4 @@ --- -sourceProjectId: photos title: Photo Site Generator description: A static photo site generated from a directory of images, with automatic resizing to multiple quality settings. thumbnail: @@ -10,7 +9,5 @@ sortDate: 2016-07-01 technologies: ['Webpack', 'Image processing', 'Static site generation'] selected: false essay: photo-site-generator -links: - - label: Site - url: https://photo.schmelczer.dev +links: [] --- diff --git a/src/content/projects/platform-game.md b/src/content/projects/platform-game.md index 81dd071..e90790b 100644 --- a/src/content/projects/platform-game.md +++ b/src/content/projects/platform-game.md @@ -1,5 +1,4 @@ --- -sourceProjectId: platform-game title: Platform Game description: An early 3D game in C with SDL 1.2, random maps, destructible voxels, enemies, powerups, and time slowdown. thumbnail: diff --git a/src/content/projects/reconcile.md b/src/content/projects/reconcile.md index 33760c9..24fabae 100644 --- a/src/content/projects/reconcile.md +++ b/src/content/projects/reconcile.md @@ -1,5 +1,4 @@ --- -sourceProjectId: reconcile title: reconcile-text description: A Rust library that auto-resolves conflicting text edits without conflict markers, with WebAssembly and Python bindings. thumbnail: @@ -12,7 +11,7 @@ selected: true essay: reconcile-text-3-way-merge links: - label: Demo - url: https://schmelczer.dev/reconcile + url: /reconcile/ - label: Source url: https://github.com/schmelczer/reconcile - label: crates.io diff --git a/src/content/projects/sdf-2d.md b/src/content/projects/sdf-2d.md index b12a188..cb3a24d 100644 --- a/src/content/projects/sdf-2d.md +++ b/src/content/projects/sdf-2d.md @@ -1,5 +1,4 @@ --- -sourceProjectId: sdf-2d title: SDF-2D description: A browser rendering library for optimized 2D ray tracing with signed distance fields. thumbnail: @@ -13,8 +12,6 @@ essay: sdf-2d-ray-tracing links: - label: NPM package url: https://www.npmjs.com/package/sdf-2d - - label: Demo - url: https://sdf2d.schmelczer.dev - label: Video url: https://www.youtube.com/watch?v=K3cEtnZUNR0 - label: BSc thesis diff --git a/src/content/projects/towers.md b/src/content/projects/towers.md index eea4e2b..051a1c7 100644 --- a/src/content/projects/towers.md +++ b/src/content/projects/towers.md @@ -1,5 +1,4 @@ --- -sourceProjectId: towers title: Life Towers description: A multi-device goal tracker where the lasting idea was syncing state with immutable tries. thumbnail: @@ -13,6 +12,4 @@ essay: life-towers-immutable-tries links: - label: Source url: https://github.com/schmelczer/life-towers/ - - label: Demo - url: https://towers.schmelczer.dev --- diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro index 93ece4f..5861f59 100644 --- a/src/layouts/Base.astro +++ b/src/layouts/Base.astro @@ -30,7 +30,7 @@ interface Props { const { title = site.title, description = site.description, - canonicalPath = Astro.url.pathname, + canonicalPath: rawCanonicalPath = Astro.url.pathname, ogImage, ogImageAlt = "Andras Schmelczer's personal site", ogImageWidth, @@ -45,6 +45,12 @@ const { const isRoot = title === site.title; const pageTitle = isRoot ? site.title : `${title} · ${site.name}`; const ogTitle = isRoot ? site.title : title; +const canonicalPath = + rawCanonicalPath === '/' || + rawCanonicalPath.endsWith('/') || + /\.[^/]+$/.test(rawCanonicalPath) + ? rawCanonicalPath + : `${rawCanonicalPath}/`; const canonical = absoluteUrl(canonicalPath); let resolvedOgImage = ogImage; @@ -75,68 +81,13 @@ const ogImageType = ? 'image/svg+xml' : 'image/jpeg'; const jsonLdEntries = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : []; - -// Head meta tags built as a single HTML string so prettier-plugin-astro -// doesn't shuffle them outside `` when reformatting (it has trouble -// with mixed JSX-expression and raw element siblings inside ). -const attr = (value: string) => - value - .replace(/&/g, '&') - .replace(/"/g, '"') - .replace(//g, '>'); - -const articleMetaParts = article - ? [ - ``, - article.modifiedTime - ? `` - : '', - ``, - ...(article.tags ?? []).map( - (tag) => `` - ), - ] - : []; - -const monoPreloadHtml = preloadMono - ? '' - : ''; - -const headHtml = [ - monoPreloadHtml, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ...articleMetaParts, - ``, - ``, - ``, - ``, - ``, - ...jsonLdEntries.map( - (entry) => - `` - ), -].join(''); +const jsonLdStrings = jsonLdEntries.map((entry) => + JSON.stringify(entry).replace(/ - + - - {noindex && } {!noindex && } + +