diff --git a/web/src/app/not-found.tsx b/web/src/app/not-found.tsx new file mode 100644 index 0000000..b9d4e48 --- /dev/null +++ b/web/src/app/not-found.tsx @@ -0,0 +1,88 @@ +import Link from "next/link"; +import { type Metadata } from "next"; +import { SiteChrome } from "@/components/site-chrome"; +import { DidYouMean } from "@/components/not-found-extras"; + +export const metadata: Metadata = { + title: "Page not found", + description: "This page does not exist. Head back to the Vocalinux home page.", + // Overrides the layout's "index, follow" so it agrees with Next's noindex. + robots: { index: false, follow: true }, +}; + +const quickLinks = [ + { href: "/install/", label: "Install guides" }, + { href: "/faq/", label: "FAQ" }, + { href: "/compare/", label: "Compare engines" }, + { href: "/troubleshooting/", label: "Troubleshooting" }, + { href: "/changelog/", label: "Changelog" }, +]; + +// 404 is a fine number. These are all true; check them in a shell. +const stats = [ + { + title: "chmod 404", + body: "r-- --- r--. Owner and others can read, the group gets nothing, and nobody can write. Fitting for a page that isn't there.", + }, + { + title: "exit 404", + body: "Exit statuses are 8 bits, so a shell reports $? = 148 (404 mod 256). Even the error code got truncated.", + }, + { + title: "0x194 · 0o624 · 0b110010100", + body: "404 in hex, octal and binary. None of them is a valid URL on this site either.", + }, +]; + +export default function NotFound() { + return ( + +
+
+
+

HTTP 404 · No such file or directory

+

+ This page is not in your $PATH. +

+

+ The link is broken, the page moved, or the URL picked up a typo + on the way here. Vocalinux can type what you say, but it can't + type a page that doesn't exist. +

+
+ +
+

Fun facts about 404

+
+ {stats.map((stat) => ( +
+

{stat.title}

+

{stat.body}

+
+ ))} +
+
+ +
+ +
+ + Back to home + + + Install Vocalinux + +
+ +
+
+
+
+ ); +} diff --git a/web/src/components/__tests__/not-found-extras.test.tsx b/web/src/components/__tests__/not-found-extras.test.tsx new file mode 100644 index 0000000..8eff0f0 --- /dev/null +++ b/web/src/components/__tests__/not-found-extras.test.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { DidYouMean, suggestPage } from '../not-found-extras'; + +const pages = ['/install/', '/install/ubuntu/', '/install/fedora/', '/faq/', '/compare/']; + +describe('suggestPage', () => { + it('suggests the closest page for a typo', () => { + expect(suggestPage('/install/ubunt', pages)).toBe('/install/ubuntu/'); + expect(suggestPage('/INSTALL/FEDORA', pages)).toBe('/install/fedora/'); + expect(suggestPage('/compar', pages)).toBe('/compare/'); + }); + + it('suggests nothing for an unrelated path', () => { + expect(suggestPage('/wp-admin/setup-config.php', pages)).toBeNull(); + }); +}); + +describe('DidYouMean', () => { + afterEach(() => { + window.history.pushState({}, '', '/'); + }); + + it('links to the closest page for the current URL', async () => { + window.history.pushState({}, '', '/install/ubunt'); + render(); + // Jest's next/link ignores trailingSlash, so check the visible path. + expect(await screen.findByRole('link')).toHaveTextContent('/install/ubuntu/'); + }); + + it('does not crash on a malformed percent-escape', () => { + window.history.pushState({}, '', '/%FF/'); + expect(() => render()).not.toThrow(); + }); +}); diff --git a/web/src/components/not-found-extras.tsx b/web/src/components/not-found-extras.tsx new file mode 100644 index 0000000..90f1f40 --- /dev/null +++ b/web/src/components/not-found-extras.tsx @@ -0,0 +1,76 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import Link from "next/link"; +import { footerGroups, mobileExtraNav, pageNav } from "@/lib/site-nav"; + +// Static export serves one 404.html for every missing path, so the requested +// URL is only known after mount. The first render shows nothing extra to keep +// hydration clean. + +function useMissingPath(): string | null { + const [path, setPath] = useState(null); + useEffect(() => { + const { pathname } = window.location; + if (pathname && pathname !== "/404/" && pathname !== "/404.html") { + // Malformed escapes such as /%FF/ make decodeURIComponent throw. + let decoded = pathname; + try { + decoded = decodeURIComponent(pathname); + } catch { + // Keep the raw path; it still gets a suggestion attempt. + } + setPath(decoded.slice(0, 80)); + } + }, []); + return path; +} + +// Every internal page the site links from its header and footer. +const knownPages = Array.from( + new Set( + [...pageNav, ...mobileExtraNav, ...footerGroups.flatMap((g) => g.links)] + .map((link) => link.href) + .filter((href) => href.startsWith("/") && !href.startsWith("/#") && href !== "/"), + ), +); + +function editDistance(a: string, b: string): number { + const row = Array.from({ length: b.length + 1 }, (_, i) => i); + for (let i = 1; i <= a.length; i++) { + let prev = row[0]; + row[0] = i; + for (let j = 1; j <= b.length; j++) { + const current = row[j]; + row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1)); + prev = current; + } + } + return row[b.length]; +} + +const normalize = (path: string) => `/${path.toLowerCase().replace(/^\/+|\/+$/g, "")}/`; + +export function suggestPage(path: string, pages: string[] = knownPages): string | null { + const target = normalize(path); + let best: { href: string; distance: number } | null = null; + for (const href of pages) { + const distance = editDistance(target, href); + if (!best || distance < best.distance) best = { href, distance }; + } + if (!best) return null; + // Distance 0 is a case or trailing-slash slip (Pages is case-sensitive). + // Otherwise it must be close enough to be a typo, not a different page. + return best.distance <= Math.max(2, Math.floor(best.href.length / 3)) ? best.href : null; +} + +export function DidYouMean() { + const path = useMissingPath(); + const suggestion = path ? suggestPage(path) : null; + if (!suggestion) return null; + return ( +

+ Did you mean {suggestion}? +

+ ); +} diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css index e816755..5d2c6c0 100644 --- a/web/src/styles/globals.css +++ b/web/src/styles/globals.css @@ -1741,6 +1741,103 @@ font-size: 0.95rem; } + /* 404 page */ + .nf { + display: grid; + gap: 3rem; + padding: 3.5rem 0 5.5rem; + } + + .nf-kicker { + margin: 0 0 1rem; + color: var(--brand-dark); + font-family: var(--font-mono); + font-size: 0.75rem; + letter-spacing: 0.08em; + text-transform: uppercase; + } + + .nf-hero h1 { + margin: 0; + font-size: clamp(2.6rem, 7vw, 5.2rem); + } + + .nf-lede { + margin: 1.2rem 0 0; + color: var(--muted-copy); + font-size: 1.1rem; + line-height: 1.65; + } + + .nf-suggest { + margin: 1rem 0 0; + font-size: 1.05rem; + } + + .nf-next > :first-child { + margin-top: 0; + } + + .nf-suggest a, + .nf-links a { + color: var(--brand); + font-weight: 600; + } + + .nf-actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-top: 1.8rem; + } + + .nf-links { + display: flex; + flex-wrap: wrap; + gap: 0.4rem 1.2rem; + margin-top: 1.4rem; + font-size: 0.95rem; + } + + .nf-stats h2 { + margin: 0; + font-size: clamp(1.5rem, 3vw, 1.85rem); + } + + .nf-stat-grid { + display: grid; + gap: 1rem; + margin-top: 1.2rem; + } + + .nf-stat { + padding: 1.1rem 1.2rem 1.2rem; + background: var(--paper-bright); + border: 1px solid var(--line); + border-radius: var(--radius-sm); + } + + .nf-stat h3 { + margin: 0; + font-family: var(--font-mono); + font-size: 1rem; + font-weight: 600; + letter-spacing: 0; + overflow-wrap: anywhere; + } + + .nf-stat p { + margin: 0.5rem 0 0; + color: var(--muted-copy); + font-size: 0.95rem; + } + + @media (min-width: 760px) { + .nf-stat-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + } + @keyframes halo { 0% { opacity: 0.85; transform: scale(0.55); } 70%, 100% { opacity: 0; transform: scale(1.7); }