From 4ce8a4f41d5b7f7832b459005d79d1cfa4166577 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Thu, 11 Jun 2026 21:35:45 +0100 Subject: [PATCH] Fable clean up --- .forgejo/workflows/deploy.yml | 31 ++- README.md | 8 +- package-lock.json | 99 ++------ package.json | 11 +- scripts/check-links.mjs | 52 +---- scripts/check-no-em-dashes.mjs | 22 +- scripts/check-no-js.mjs | 28 +-- scripts/check-overflow.mjs | 213 ++--------------- scripts/check-preview-cropping.mjs | 215 ++---------------- scripts/export-astro-audit.mjs | 88 ++----- scripts/lib/browser.mjs | 89 ++++++++ scripts/lib/dist.mjs | 108 +++++++++ scripts/lib/walk.mjs | 23 ++ src/components/Header.astro | 15 +- src/components/ProjectLinks.astro | 5 +- src/components/ProjectList.astro | 5 +- src/content.config.ts | 8 +- .../work/ad-astra-attiny85-game-engine.md | 43 ++-- src/content/work/avoid-early-web-game.md | 10 +- .../work/backup-container-btrfs-borg.md | 11 +- .../work/city-simulation-unity-traffic.md | 10 +- .../work/declared-shared-simulation-code.md | 34 +-- .../work/fizika-erettsegi-practice-app.md | 22 +- .../work/fleeting-garden-webgpu-drawing.md | 58 ++--- .../foreign-exchange-prediction-experiment.md | 14 +- src/content/work/frame-eink-photo-display.md | 8 +- .../graph-editor-javafx-simulation-input.md | 8 +- src/content/work/greatai-ai-deployment-api.md | 27 ++- .../work/life-towers-immutable-tries.md | 49 ++-- .../work/lights-synchronized-to-music.md | 8 +- .../work/my-notes-android-markdown-app.md | 4 +- .../work/nuclear-cooling-simulation.md | 29 +-- .../perfect-postcode-rust-property-server.md | 13 +- src/content/work/photo-colour-grader.md | 6 +- src/content/work/photo-site-generator.md | 16 +- src/content/work/platform-game-c-sdl.md | 8 +- .../work/reconcile-text-3-way-merge.md | 12 +- src/content/work/sdf-2d-ray-tracing.md | 31 +-- src/content/work/vault-link-obsidian-sync.md | 26 +-- src/layouts/Base.astro | 33 ++- src/layouts/Post.astro | 6 +- src/lib/site.ts | 26 +++ src/pages/tags/[tag].astro | 6 +- src/scripts/analytics.ts | 2 + src/scripts/theme-init.js | 11 +- src/styles/global.css | 2 + 46 files changed, 642 insertions(+), 911 deletions(-) create mode 100644 scripts/lib/browser.mjs create mode 100644 scripts/lib/dist.mjs create mode 100644 scripts/lib/walk.mjs diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index e2c0649..517df26 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -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' diff --git a/README.md b/README.md index 64893ae..71ab187 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/package-lock.json b/package-lock.json index 1d3867b..3351e85 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/package.json b/package.json index c282e3d..34fea5e 100644 --- a/package.json +++ b/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" } } diff --git a/scripts/check-links.mjs b/scripts/check-links.mjs index b6f4301..13cf634 100644 --- a/scripts/check-links.mjs +++ b/scripts/check-links.mjs @@ -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)); diff --git a/scripts/check-no-em-dashes.mjs b/scripts/check-no-em-dashes.mjs index edb1606..92b25aa 100644 --- a/scripts/check-no-em-dashes.mjs +++ b/scripts/check-no-em-dashes.mjs @@ -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'); diff --git a/scripts/check-no-js.mjs b/scripts/check-no-js.mjs index 051a252..4fd82ac 100644 --- a/scripts/check-no-js.mjs +++ b/scripts/check-no-js.mjs @@ -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 = [ diff --git a/scripts/check-overflow.mjs b/scripts/check-overflow.mjs index 8b2e75a..88c4531 100644 --- a/scripts/check-overflow.mjs +++ b/scripts/check-overflow.mjs @@ -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) { diff --git a/scripts/check-preview-cropping.mjs b/scripts/check-preview-cropping.mjs index 7804e41..b1bc7bd 100644 --- a/scripts/check-preview-cropping.mjs +++ b/scripts/check-preview-cropping.mjs @@ -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) { diff --git a/scripts/export-astro-audit.mjs b/scripts/export-astro-audit.mjs index 62c4113..34e9e56 100644 --- a/scripts/export-astro-audit.mjs +++ b/scripts/export-astro-audit.mjs @@ -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); } diff --git a/scripts/lib/browser.mjs b/scripts/lib/browser.mjs new file mode 100644 index 0000000..71355c8 --- /dev/null +++ b/scripts/lib/browser.mjs @@ -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 + ); +} diff --git a/scripts/lib/dist.mjs b/scripts/lib/dist.mjs new file mode 100644 index 0000000..16e9137 --- /dev/null +++ b/scripts/lib/dist.mjs @@ -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 }; +} diff --git a/scripts/lib/walk.mjs b/scripts/lib/walk.mjs new file mode 100644 index 0000000..8424373 --- /dev/null +++ b/scripts/lib/walk.mjs @@ -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; +} diff --git a/src/components/Header.astro b/src/components/Header.astro index 43dac2f..f1dd058 100644 --- a/src/components/Header.astro +++ b/src/components/Header.astro @@ -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 -