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
40 changes: 40 additions & 0 deletions __tests__/not-found-redirects.test.ts
Original file line number Diff line number Diff line change
@@ -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()
}
})
})
25 changes: 25 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
15 changes: 15 additions & 0 deletions app/not-found.tsx
Original file line number Diff line number Diff line change
@@ -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 <title> 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 />
}
122 changes: 122 additions & 0 deletions components/not-found/NotFoundClient.tsx
Original file line number Diff line number Diff line change
@@ -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>
)
}
54 changes: 54 additions & 0 deletions e2e/not-found.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading