Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion src/components/sections/Download.astro
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ import Icon from '../Icon.astro'
<div class="col-span-12 border-t border-cd-border pt-12" data-reveal>
<h3 class="text-xl font-semibold tracking-[-0.01em] text-cd-text">Apple could not verify SpreadPaper is free of malware</h3>
<p class="mt-3 max-w-[70ch] text-base leading-relaxed text-pretty text-cd-text">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 <a class="cd-inline-link focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cd-accent" href="https://github.com/spreadpaper/SpreadPaper">source is public</a> and every release is <a class="cd-inline-link focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cd-accent" href="https://github.com/spreadpaper/SpreadPaper/blob/main/.github/workflows/release.yml">built from it by GitHub Actions</a>.</p>
<p class="mt-4 max-w-[70ch] text-base leading-relaxed text-pretty text-cd-text">There are two ways past it, one through System Settings and one in Terminal. Both are written out step by step in <a class="cd-inline-link focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cd-accent" href="/help">how to open SpreadPaper when macOS blocks it</a>. It takes a minute, and only the first time.</p>
<p class="mt-4 max-w-[70ch] text-base leading-relaxed text-pretty text-cd-text">There are two ways past it, one through System Settings and one in Terminal. Both are written out step by step in <a class="cd-inline-link focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cd-accent" href="/help/">how to open SpreadPaper when macOS blocks it</a>. It takes a minute, and only the first time.</p>
</div>
</div>
</div>
Expand Down
12 changes: 6 additions & 6 deletions src/components/sections/Footer.astro
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,12 @@ const columns = [
id: 'footer-site',
title: 'This site',
links: [
{ href: '/help', label: 'Help' },
{ href: '/guides', label: 'Guides' },
{ href: '/about', label: 'About' },
{ href: '/alternatives', label: 'Alternatives' },
{ href: '/privacy', label: 'Privacy Policy' },
{ href: '/terms', label: 'Terms of Service' },
{ href: '/help/', label: 'Help' },
{ href: '/guides/', label: 'Guides' },
{ href: '/about/', label: 'About' },
{ href: '/alternatives/', label: 'Alternatives' },
{ href: '/privacy/', label: 'Privacy Policy' },
{ href: '/terms/', label: 'Terms of Service' },
],
},
{
Expand Down
6 changes: 3 additions & 3 deletions src/components/sections/Nav.astro
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ const anchor = (id: string) => (isHome ? `#${id}` : `/#${id}`)
// /guides/bezel-compensation/ still marks Guides.
const links = [
{ href: anchor('types'), label: 'Features', current: isHome },
{ href: '/help', label: 'Help', current: path.startsWith('/help') },
{ href: '/guides', label: 'Guides', current: path.startsWith('/guides') },
{ href: '/about', label: 'About', current: path.startsWith('/about') },
{ href: '/help/', label: 'Help', current: path.startsWith('/help') },
{ href: '/guides/', label: 'Guides', current: path.startsWith('/guides') },
{ href: '/about/', label: 'About', current: path.startsWith('/about') },
]
---

Expand Down
12 changes: 6 additions & 6 deletions src/pages/404.astro
Original file line number Diff line number Diff line change
Expand Up @@ -21,37 +21,37 @@ const destinations: Destination[] = [
},
{
name: 'Help',
path: '/help',
path: '/help/',
what: 'Getting past the first launch warning, and what else goes wrong',
words: 'install open warning gatekeeper unsigned damaged malware first launch troubleshooting support',
},
{
name: 'Guides',
path: '/guides',
path: '/guides/',
what: 'Spanning two monitors, and how bezel compensation works',
words: 'bezel compensation span two monitors gap seam tutorial how to step by step',
},
{
name: 'About',
path: '/about',
path: '/about/',
what: 'Who builds it and everyone who has shaped it',
words: 'who robin contributors open source credits curacao project team',
},
{
name: 'Alternatives',
path: '/alternatives',
path: '/alternatives/',
what: 'Which other apps genuinely span a wallpaper',
words: 'superpaper spanright multiscape fresco compare comparison free paid competitors',
},
{
name: 'Privacy Policy',
path: '/privacy',
path: '/privacy/',
what: 'The one request the app makes',
words: 'data tracking analytics telemetry request github update check gdpr cookies',
},
{
name: 'Terms of Service',
path: '/terms',
path: '/terms/',
what: 'The MIT licence, in short',
words: 'licence license mit warranty fork name legal terms conditions',
},
Expand Down
2 changes: 1 addition & 1 deletion src/pages/about.astro
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ const facts = [
socialDescription={`One wallpaper across every display, built by ${people(credited.length)}.`}
breadcrumb={[
{ name: 'Home', path: '/' },
{ name: 'About', path: '/about' },
{ name: 'About', path: '/about/' },
]}
>
<Nav />
Expand Down
2 changes: 1 addition & 1 deletion src/pages/alternatives.astro
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ const perDisplay = [
<h2 id="behind">Where SpreadPaper is behind</h2>
<p>
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 <a href="/help">help page</a> covers that in
first time and you allow it once by hand. The <a href="/help/">help page</a> 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.
Expand Down
2 changes: 1 addition & 1 deletion src/pages/guides/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ const questions: Question[] = [

<p>
Ask for your own setup on <a href="https://github.com/spreadpaper/SpreadPaper/issues">GitHub</a
> and it becomes the next guide. For installing and first launch, the <a href="/help"
> and it becomes the next guide. For installing and first launch, the <a href="/help/"
>help page</a
> has it.
</p>
Expand Down
4 changes: 2 additions & 2 deletions src/pages/guides/span-wallpaper-across-two-monitors-mac.astro
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ import TextPage from '../../layouts/TextPage.astro'
</li>
<li>
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 <a href="/help">help page</a> 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 <a href="/help/">help page</a> covers the two ways past it, and it only happens once.
</li>
<li>
Open the app and choose your image. The preview draws your displays in their real
Expand Down Expand Up @@ -162,7 +162,7 @@ import TextPage from '../../layouts/TextPage.astro'
<h2 id="next">Where to go next</h2>
<p>
<a href="/guides/bezel-compensation/">Bezel compensation</a> is the detail behind the step that
does most of the work. <a href="/help">Help</a> walks the first launch warning through in three steps. <a href="/guides">The guides</a> have the rest.
does most of the work. <a href="/help/">Help</a> walks the first launch warning through in three steps. <a href="/guides/">The guides</a> have the rest.
</p>
</Prose>
</TextPage>
2 changes: 1 addition & 1 deletion src/pages/help.astro
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ const steps: Step[] = [

const breadcrumb = [
{ name: 'Home', path: '/' },
{ name: 'Help', path: '/help' },
{ name: 'Help', path: '/help/' },
]
---

Expand Down
2 changes: 1 addition & 1 deletion src/pages/privacy.astro
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ const updatedLabel = '15 September 2026'
<h2>The small print</h2>
<p>
Run from Curaçao, Dutch Caribbean, by <a href="https://robinvanbaalen.nl">Robin van Baalen</a>. If any of this changes, so does the
date at the top. See also the <a href="/terms">Terms of Service</a>.
date at the top. See also the <a href="/terms/">Terms of Service</a>.
</p>
</Prose>
</TextPage>
2 changes: 1 addition & 1 deletion src/pages/terms.astro
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ const updatedLabel = '15 September 2026'
</p>

<p class="see-also">
See also the <a href="/privacy">Privacy Policy</a>.
See also the <a href="/privacy/">Privacy Policy</a>.
</p>
</Prose>
</TextPage>
15 changes: 14 additions & 1 deletion test/page.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
}
Expand Down Expand Up @@ -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)
Loading