From 2e0240aca6b1d574f9d6f60735fdd824d74af984 Mon Sep 17 00:00:00 2001
From: Robin van Baalen
Date: Mon, 28 Sep 2026 11:38:51 -0400
Subject: [PATCH] fix: end internal links in a slash so crawlers skip the 307
---
CLAUDE.md | 5 +++++
src/components/sections/Download.astro | 2 +-
src/components/sections/Footer.astro | 12 ++++++------
src/components/sections/Nav.astro | 6 +++---
src/pages/404.astro | 12 ++++++------
src/pages/about.astro | 2 +-
src/pages/alternatives.astro | 2 +-
src/pages/guides/index.astro | 2 +-
.../span-wallpaper-across-two-monitors-mac.astro | 4 ++--
src/pages/help.astro | 2 +-
src/pages/privacy.astro | 2 +-
src/pages/terms.astro | 2 +-
test/page.test.ts | 15 ++++++++++++++-
13 files changed, 43 insertions(+), 25 deletions(-)
create mode 100644 CLAUDE.md
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..17a2477
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,5 @@
+# SpreadPaper website
+
+## Internal links end in a slash
+
+Pages build to directories (`/help/index.html`), and Cloudflare's `auto-trailing-slash` answers `/help` with a 307 to `/help/`. Every internal link, footer entry, nav item and 404 destination is written as `/help/`, never `/help`. A bare path works in the browser but sends every crawler through a redirect, which Ahrefs reports as "Page has links to redirect". Files keep their extension (`/favicon-32.png`). `npm test` checks every built page and fails on a link that redirects.
diff --git a/src/components/sections/Download.astro b/src/components/sections/Download.astro
index 96723af..7b55cee 100644
--- a/src/components/sections/Download.astro
+++ b/src/components/sections/Download.astro
@@ -78,7 +78,7 @@ import Icon from '../Icon.astro'
Apple could not verify SpreadPaper is free of malware
That is the wording macOS uses for any app not signed with a paid Apple Developer ID, and SpreadPaper is not signed with one. Nothing has been found in SpreadPaper, and nothing has been scanned: the check is for a signature, not for malware. You do not have to take that on trust, because the source is public and every release is built from it by GitHub Actions.
-
There are two ways past it, one through System Settings and one in Terminal. Both are written out step by step in how to open SpreadPaper when macOS blocks it. It takes a minute, and only the first time.
+
There are two ways past it, one through System Settings and one in Terminal. Both are written out step by step in how to open SpreadPaper when macOS blocks it. It takes a minute, and only the first time.
It is not on the Mac App Store and has no paid signing certificate, so macOS warns you the
- first time and you allow it once by hand. The help page covers that in
+ first time and you allow it once by hand. The help page covers that in
three steps. It also brings no pictures of its own: you supply the photograph, where Hologram
Desktop and Wallpaperer hand you a library. And it wants macOS 15 Sequoia, which is a higher
bar than anything else on this page.
diff --git a/src/pages/guides/index.astro b/src/pages/guides/index.astro
index d13df0c..232d027 100644
--- a/src/pages/guides/index.astro
+++ b/src/pages/guides/index.astro
@@ -75,7 +75,7 @@ const questions: Question[] = [
diff --git a/src/pages/guides/span-wallpaper-across-two-monitors-mac.astro b/src/pages/guides/span-wallpaper-across-two-monitors-mac.astro
index d8291a2..bb854c2 100644
--- a/src/pages/guides/span-wallpaper-across-two-monitors-mac.astro
+++ b/src/pages/guides/span-wallpaper-across-two-monitors-mac.astro
@@ -70,7 +70,7 @@ import TextPage from '../../layouts/TextPage.astro'
Download SpreadPaper and drag it to your Applications folder. macOS shows a warning the
- first time you open it, because the app is not signed with a paid Apple Developer ID. The help page covers the two ways past it, and it only happens once.
+ first time you open it, because the app is not signed with a paid Apple Developer ID. The help page covers the two ways past it, and it only happens once.
Open the app and choose your image. The preview draws your displays in their real
@@ -162,7 +162,7 @@ import TextPage from '../../layouts/TextPage.astro'
Where to go next
Bezel compensation is the detail behind the step that
- does most of the work. Help walks the first launch warning through in three steps. The guides have the rest.
+ does most of the work. Help walks the first launch warning through in three steps. The guides have the rest.
diff --git a/test/page.test.ts b/test/page.test.ts
index 34acb99..727e972 100644
--- a/test/page.test.ts
+++ b/test/page.test.ts
@@ -446,7 +446,7 @@ console.log('\nthe footer columns')
// Once the top nav has taken its four, these pages are reachable from nowhere
// else, so a column that drifts back to all GitHub orphans them.
const footerHrefs = [...doc.querySelectorAll('#site-footer a')].map((a) => a.getAttribute('href')!)
-for (const href of ['/help', '/guides', '/about', '/alternatives', '/privacy', '/terms']) {
+for (const href of ['/help/', '/guides/', '/about/', '/alternatives/', '/privacy/', '/terms/']) {
const hits = footerHrefs.filter((candidate) => candidate === href).length
check(`${href} is linked from the footer exactly once`, hits === 1, `${hits} links`)
}
@@ -1303,5 +1303,18 @@ check('and names both architectures the binary carries',
check('while the system requirement stays at Sequoia',
/15\.0 Sequoia/.test(app?.softwareRequirements ?? ''), app?.softwareRequirements ?? 'nothing')
+console.log('\ninternal links')
+// Pages build to directories and Cloudflare 307s `/help` to `/help/`, so a
+// link without the slash sends every crawler through a redirect. Files keep
+// their extension and are left alone.
+const redirecting = builtPages.flatMap((file) =>
+ [...readFileSync(file, 'utf8').matchAll(/href="(\/[^"#?]*)/g)]
+ .map((m) => m[1])
+ .filter((href) => !href.endsWith('/') && !/\.[a-z0-9]+$/i.test(href))
+ .map((href) => `${file.slice(site.length)} → ${href}`)
+)
+check('every internal link ends where the page lives', redirecting.length === 0,
+ [...new Set(redirecting)].join(', '))
+
console.log(failures ? `\n${failures} FAILED` : '\nall checks passed')
process.exit(failures ? 1 : 0)