diff --git a/__tests__/not-found-redirects.test.ts b/__tests__/not-found-redirects.test.ts new file mode 100644 index 0000000..86a05f3 --- /dev/null +++ b/__tests__/not-found-redirects.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from 'vitest' +import { REDIRECTS, SECTIONS } from '../components/not-found/NotFoundClient' +import { hasPosts } from '../lib/content/writing' + +// BrutalistLanding only ever renders these ids, and 'writing' only once +// hasPosts() is true (see SECTION_IDS in components/landing/BrutalistLanding.tsx). +// A 404 redirect or section link pointing anywhere else is a dead anchor. +const RENDERED_SECTION_IDS = ['hero', 'projects', 'resume', 'travel', 'contact', ...(hasPosts() ? ['writing'] : [])] + +function targetId(to: string): string | null { + if (to === '/') return null + const match = to.match(/^\/#([a-z]+)$/) + return match ? match[1] : null +} + +describe('not-found redirect table', () => { + it('every redirect target is "/" or an id that actually renders on the page', () => { + for (const { to, label } of REDIRECTS) { + if (to === '/') continue + const id = targetId(to) + expect(id, `redirect "${label}" -> ${to}`).not.toBeNull() + expect(RENDERED_SECTION_IDS, `redirect "${label}" -> ${to}`).toContain(id) + } + }) + + it('every section link points at an id that actually renders on the page', () => { + for (const [label, href] of SECTIONS) { + const id = targetId(href) + expect(id, `section "${label}" -> ${href}`).not.toBeNull() + expect(RENDERED_SECTION_IDS, `section "${label}" -> ${href}`).toContain(id) + } + }) + + it('does not offer or redirect to Writing when there are no posts to show', () => { + if (!hasPosts()) { + expect(SECTIONS.some(([label]) => label === 'Writing')).toBe(false) + expect(REDIRECTS.find((r) => r.test.test('/blog'))).toBeUndefined() + } + }) +}) diff --git a/app/globals.css b/app/globals.css index 4f5f13c..b1a9948 100644 --- a/app/globals.css +++ b/app/globals.css @@ -792,6 +792,30 @@ input, .writing-all:hover, .writing-all:focus-visible { color: var(--accent); border-color: var(--accent); } +/* ---- 404 --------------------------------------------------- */ +.nf { display: grid; grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr); gap: var(--s-6); align-items: start; } +.nf-title { font-family: var(--font-display); font-size: clamp(44px, 8vw, 110px); line-height: .88; letter-spacing: -.04em; margin: 10px 0 0; } +.nf-lead { font-family: var(--font-mono); font-size: 13px; line-height: 1.6; letter-spacing: .04em; color: var(--fg-muted); max-width: 52ch; margin: var(--s-4) 0 0; overflow-wrap: anywhere; } +.nf-lead code { display: inline-block; max-width: 100%; overflow-wrap: anywhere; color: var(--fg); background: var(--bg-elevated); padding: 2px 4px; } +.nf-count { font-family: var(--font-mono); font-size: 13px; color: var(--accent); letter-spacing: .06em; margin: var(--s-3) 0 0; min-height: 1.6em; } +.nf-actions { margin-top: var(--s-5); display: flex; align-items: center; gap: var(--s-4); flex-wrap: wrap; } +.nf-stay { + background: none; + border: none; + font-family: var(--font-mono); + font-size: 12px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--fg-muted); + text-decoration: underline; + cursor: pointer; + padding: 0; +} +.nf-stay:hover, +.nf-stay:focus-visible { color: var(--accent); } +.nf-map { list-style: none; margin: 0; padding: var(--s-5); border: 1px solid var(--rule); display: flex; flex-direction: column; gap: var(--s-3); } +.nf-map .writing-all { margin-top: 0; } + /* ---- Contact --------------------------------------------- */ .contact-grid { display: grid; @@ -1126,6 +1150,7 @@ input, .timeline-row { grid-template-columns: 1fr; gap: 6px; } .continent-row { grid-template-columns: 120px 1fr 36px; } .hero-portrait { max-width: 100%; justify-self: stretch; } + .nf { grid-template-columns: 1fr; } } /* reCAPTCHA renders a fixed 304px iframe that Google gives no way to resize, diff --git a/app/not-found.tsx b/app/not-found.tsx new file mode 100644 index 0000000..12c70c2 --- /dev/null +++ b/app/not-found.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from 'next' +import { NotFoundClient } from '@/components/not-found/NotFoundClient' + +// A server component so it can own its own metadata — without this, a +// 'use client' not-found.tsx silently inherits the homepage's and +// canonical URL from app/layout.tsx. +export const metadata: Metadata = { + title: 'Page not found', + robots: { index: false, follow: true }, + alternates: { canonical: null }, +} + +export default function NotFound() { + return <NotFoundClient /> +} diff --git a/components/not-found/NotFoundClient.tsx b/components/not-found/NotFoundClient.tsx new file mode 100644 index 0000000..24e858f --- /dev/null +++ b/components/not-found/NotFoundClient.tsx @@ -0,0 +1,122 @@ +'use client' + +// Custom 404 body. Old multi-page URLs redirect to their section on the +// single page; anything else gets a plain 404. `app/not-found.tsx` (a +// server component, for metadata) renders this. Exported as out/404.html +// by `output: 'export'`, which GitHub Pages serves for every unknown path. +import { useEffect, useState, useSyncExternalStore } from 'react' +import Link from 'next/link' +import { BinaryRule } from '@/components/landing/BinaryRule' +import { hasPosts } from '@/lib/content/writing' + +// Writing only renders on the page (and only gets an #writing anchor) once +// there's a post to show — same predicate BrutalistLanding/Nav use. Until +// then there's nowhere to rescue a blog URL to, so it isn't a "match" at +// all: it falls through to the plain, no-guessing 404 below. +export const REDIRECTS: Array<{ test: RegExp; to: string; label: string }> = [ + { test: /^\/(career|skills|education|resume)\/?$/i, to: '/#resume', label: 'Career' }, + { test: /^\/travel\/?$/i, to: '/#travel', label: 'Travel' }, + { test: /^\/contact\/?$/i, to: '/#contact', label: 'Contact' }, + ...(hasPosts() + ? [{ test: /^\/(blog|writing|posts)(\/.*)?$/i, to: '/#writing', label: 'Writing' }] + : []), + { test: /^\/projects?(\/.*)?$/i, to: '/#projects', label: 'Projects' }, +] + +export const SECTIONS: ReadonlyArray<readonly [string, string]> = [ + ['Projects', '/#projects'], + ...(hasPosts() ? ([['Writing', '/#writing']] as const) : []), + ['Career', '/#resume'], + ['Travel', '/#travel'], + ['Contact', '/#contact'], +] + +const MAX_SHOWN_PATH = 64 + +// Not a real store: nothing here can change the pathname without unmounting +// this component (a same-page hash link doesn't unmount, but it can't match +// a REDIRECTS entry either), so subscribe() is a no-op. useSyncExternalStore +// (rather than useState+useEffect) because it reads the client value during +// the post-hydration render instead of setting state in an effect, which +// react-hooks/set-state-in-effect rejects — and it's the documented pattern +// for reading a browser-only value without a hydration mismatch. +function subscribe() { + return () => {} +} +function getPath() { + return window.location.pathname +} +function getServerPath() { + return '' +} + +function displayPath(path: string) { + let decoded = path + try { + decoded = decodeURIComponent(path) + } catch { + // Malformed percent-encoding — show it raw rather than throwing. + } + return decoded.length > MAX_SHOWN_PATH ? `${decoded.slice(0, MAX_SHOWN_PATH)}…` : decoded +} + +export function NotFoundClient() { + const path = useSyncExternalStore(subscribe, getPath, getServerPath) + const [seconds, setSeconds] = useState(3) + const [cancelled, setCancelled] = useState(false) + const match = REDIRECTS.find((r) => r.test.test(path)) + const redirecting = Boolean(match) && !cancelled + + useEffect(() => { + if (!redirecting) return + if (seconds <= 0) { window.location.replace(match!.to); return } + const id = setTimeout(() => setSeconds((s) => s - 1), 1000) + return () => clearTimeout(id) + }, [redirecting, match, seconds]) + + return ( + <div className="brutalist-root" data-accent="gold" data-contrast="default"> + <div className="page"> + <nav className="site-nav" aria-label="Main"> + <Link href="/" className="brand" aria-label="Home"><span className="brand-mark">DM</span><span className="brand-word">Dom Mangonon</span></Link> + </nav> + <main className="section"> + <BinaryRule seed={404} accent /> + {/* A click on any link below means the visitor chose where to go; + stop the pending auto-redirect so it can't override that choice. */} + <div className="nf" onClickCapture={() => setCancelled(true)}> + <div> + <span className="ds-eyebrow">404</span> + <h1 className="nf-title">{match ? <>That page<br />moved.</> : <>Nothing<br />here.</>}</h1> + <p className="nf-lead"> + {path && <>You asked for <code>{displayPath(path)}</code>. </>} + {match + ? <>It now lives on the front page, under <strong>{match.label}</strong>.</> + : <>There’s nothing at that address — no guessing where you meant.</>} + </p> + {redirecting && ( + <p className="nf-count"> + <span role="status">Redirecting to {match!.label}.</span>{' '} + <span aria-hidden="true">Taking you there in {Math.max(seconds, 1)}…</span> + </p> + )} + <div className="nf-actions"> + <Link className="btn-primary" href={match ? match.to : '/'}>{match ? 'Go there now →' : 'Start at the top →'}</Link> + {redirecting && ( + <button type="button" className="nf-stay" onClick={() => setCancelled(true)}> + Stay on this page + </button> + )} + </div> + </div> + <ul className="nf-map" aria-label="Sections"> + {SECTIONS.map(([label, href]) => ( + <li key={href}><a className="writing-all" href={href}>{label} →</a></li> + ))} + </ul> + </div> + </main> + </div> + </div> + ) +} diff --git a/e2e/not-found.spec.ts b/e2e/not-found.spec.ts new file mode 100644 index 0000000..43570bd --- /dev/null +++ b/e2e/not-found.spec.ts @@ -0,0 +1,54 @@ +import { test, expect } from "@playwright/test"; + +// This suite runs against `npm run dev` (see playwright.config.ts), so it +// exercises app/not-found.tsx directly, not the static `out/404.html` that +// GitHub Pages actually serves for an unknown path. `npm run build && ls +// out/404.html` confirms that file exists; it necessarily contains only the +// pre-hydration "Nothing here" shell (see NotFoundClient's getServerPath), +// so don't grep it for redirect copy — the behaviour these tests check only +// exists after client JS runs, same as it would on the live site. +test.describe("Custom 404", () => { + test("old /career URL redirects to the career section", async ({ page }) => { + await page.goto("/career"); + await expect(page.getByRole("heading", { name: /that page moved/i })).toBeVisible(); + await expect(page.getByRole("status")).toContainText(/redirecting to career/i); + await page.waitForURL(/\/#resume$/, { timeout: 10000 }); + await expect(page.locator("#resume")).toBeInViewport(); + }); + + test("old /blog URL gets a plain 404 when there are no posts yet", async ({ page }) => { + // There's no Writing section to rescue this to until a post ships + // (see lib/content/writing.ts POSTS), so it must not redirect to a + // dangling #writing anchor. + await page.goto("/blog"); + await expect(page.getByRole("heading", { name: /nothing here/i })).toBeVisible(); + await expect(page.getByRole("link", { name: "Writing →" })).toHaveCount(0); + await page.waitForTimeout(1500); + expect(page.url()).toContain("/blog"); + }); + + test("unknown paths get a plain 404 with no redirect", async ({ page }) => { + await page.goto("/definitely-not-a-page"); + await expect(page.getByRole("heading", { name: /nothing here/i })).toBeVisible(); + // No match means no timer is ever started, so a short wait is enough to + // prove nothing fires — it isn't racing a real 3s countdown. + await page.waitForTimeout(1500); + expect(page.url()).toContain("/definitely-not-a-page"); + }); + + test("clicking a section link cancels the pending auto-redirect", async ({ page }) => { + await page.goto("/career"); + await page.getByRole("link", { name: "Projects →" }).click(); + await page.waitForURL(/\/#projects$/); + // The /career -> /#resume timer would otherwise fire ~3s after load and + // override the user's own navigation. + await page.waitForTimeout(3500); + await expect(page).toHaveURL(/\/#projects$/); + }); + + test("404 is branded, not the Next default", async ({ page }) => { + await page.goto("/career"); + await expect(page.locator(".brutalist-root .brand-mark")).toBeVisible(); + await expect(page.getByText("This page could not be found.")).toHaveCount(0); + }); +});