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.