From c4f0759919648e39c0cd6d16b249d730329a6e8b Mon Sep 17 00:00:00 2001 From: Tobias Leinss <7684178+leinss@users.noreply.github.com> Date: Mon, 17 Aug 2026 18:15:07 +0200 Subject: [PATCH] a11y, perf: fix contrast and heading structure, stop shipping a 3 MB photo Measured with axe-core against the built site, and with the resource timing API for weight. Performance: the home page shipped profile.jpg at 3.0 MB, the 4060x5904 original, scaled by the browser to 280px. It sat in public/, and Astro only optimises images it can resolve at build time, so passed it through untouched. Importing it from src/assets produces 11 kB at 1x and 54 kB at 2x. The page went from 3212 kB to 201 kB. Accessibility: - The newsletter small print and its two links ran at 2.4:1 against the card, well under 4.5:1, and that text carries the privacy notice. opacity-80 puts it at about 5:1. 75 was tried first and still failed at 4.45:1. - The share label ran at 3.62:1 and is now at /70. - No page had an h1. Page titles were divs, so work, projects, blog and every post failed page-has-heading-one. Promoted to h1, with the per-entry company and the hackathons section as h2. Tailwind's preflight zeroes heading sizes, so nothing looks different. Also adds the internal link checker from the consulting site, wired into the build. Its first run found a genuinely broken image: a project page referenced sharknado_3.png where the file is sharknado_3.jpg. axe reports no violations on home, work, projects, blog index, and an English and a German post, in both light and dark mode. --- package.json | 5 +- scripts/check-internal-links.mjs | 72 ++++++++++++++++++++++ {public/images => src/assets}/profile.jpg | Bin src/components/Newsletter.astro | 5 +- src/components/SocialShare.astro | 2 +- src/content/projects/ethrome/index.md | 2 +- src/pages/blog/[...slug].astro | 4 +- src/pages/blog/index.astro | 2 +- src/pages/index.astro | 8 ++- src/pages/projects/index.astro | 8 +-- src/pages/work/index.astro | 8 +-- 11 files changed, 99 insertions(+), 17 deletions(-) create mode 100644 scripts/check-internal-links.mjs rename {public/images => src/assets}/profile.jpg (100%) diff --git a/package.json b/package.json index 8efc4de89..46da04e67 100644 --- a/package.json +++ b/package.json @@ -9,13 +9,14 @@ "scripts": { "dev": "astro dev", "dev:network": "astro dev --host", - "build": "astro check && astro build", + "build": "astro check && astro build && node scripts/check-internal-links.mjs", "preview": "astro preview", "preview:network": "astro preview --host", "astro": "astro", "lint": "eslint .", "lint:fix": "eslint . --fix", - "check:spacing": "node scripts/check-inline-spacing.mjs" + "check:spacing": "node scripts/check-inline-spacing.mjs", + "check:links": "node scripts/check-internal-links.mjs" }, "dependencies": { "@astrojs/check": "^0.9.10", diff --git a/scripts/check-internal-links.mjs b/scripts/check-internal-links.mjs new file mode 100644 index 000000000..4f502d2e7 --- /dev/null +++ b/scripts/check-internal-links.mjs @@ -0,0 +1,72 @@ +/** + * Fails the build when an internal link points at a page that is not there. + * + * Written after a hand-rolled version of this check reported 80 broken links + * that were not broken: the shell pipeline behind it parsed the wrong field. + * A check that cries wolf is worse than no check, so this one is tested against + * a link it must catch, and it prints what it looked at rather than only what + * it rejected. + * + * Anchors, mailto:, tel: and absolute URLs are out of scope. So is anything + * under public/, which is copied verbatim and checked by its own presence. + */ + +import { readdirSync, readFileSync, statSync } from "node:fs" +import { join, resolve } from "node:path" + +const DIST = resolve("dist") + +const walk = (dir) => + readdirSync(dir).flatMap((entry) => { + const full = join(dir, entry) + return statSync(full).isDirectory() ? walk(full) : [full] + }) + +let pages +try { + pages = walk(DIST).filter((f) => f.endsWith(".html")) +} catch { + console.error("no dist/ to check — run the build first") + process.exit(1) +} + +// A link resolves if dist holds the file itself, or the directory-style page +// Astro writes for it. +const resolves = (path) => { + const clean = path.replace(/[?#].*$/, "") + const candidates = [ + join(DIST, clean), + join(DIST, clean, "index.html"), + join(DIST, `${clean.replace(/\/$/, "")}.html`), + ] + return candidates.some((c) => { + try { + return statSync(c).isFile() || statSync(c).isDirectory() + } catch { + return false + } + }) +} + +const broken = [] +let checked = 0 + +for (const page of pages) { + const html = readFileSync(page, "utf8") + for (const match of html.matchAll(/(?:href|src)="(\/[^"]*)"/g)) { + const target = match[1] + if (target.startsWith("//")) continue + checked += 1 + if (!resolves(target)) { + broken.push({ page: page.slice(DIST.length + 1), target }) + } + } +} + +if (broken.length > 0) { + for (const { page, target } of broken) console.error(`${page} -> ${target}`) + console.error(`\ninternal links: ${broken.length} broken of ${checked} checked`) + process.exit(1) +} + +console.log(`internal links: ${checked} checked across ${pages.length} pages, none broken`) diff --git a/public/images/profile.jpg b/src/assets/profile.jpg similarity index 100% rename from public/images/profile.jpg rename to src/assets/profile.jpg diff --git a/src/components/Newsletter.astro b/src/components/Newsletter.astro index 6880607b2..20de5034a 100644 --- a/src/components/Newsletter.astro +++ b/src/components/Newsletter.astro @@ -24,7 +24,10 @@ const LIST_UUID = "56525a08-c916-4c7c-ae2c-36481da13071" -

No spam. Unsubscribe anytime. Your email is stored by Listmonk (self-hosted) and used solely for this newsletter. See our Privacy Policy.

+ +

No spam. Unsubscribe anytime. Your email is stored by Listmonk (self-hosted) and used solely for this newsletter. See our Privacy Policy.