feat(web): Linux-flavoured 404 page that sends visitors back (#1040)

* feat(web): Linux-flavoured 404 page that sends visitors back

The static export served Next's default "404: This page could not be
found." with no way back into the site.

- "This page is not in your $PATH" heading, plus three true facts about
  404 (chmod 404, exit 404 -> $? = 148, 0x194 / 0o624 / 0b110010100).
- "Did you mean ...?" suggests the closest page the site links to, by edit
  distance after lowercasing. Catches typos and case slips, which matter
  because GitHub Pages paths are case-sensitive.
- Back to home and Install buttons, plus quick links to the install
  guides, FAQ, engine comparison, troubleshooting and changelog.
- Title "Page not found | Vocalinux", noindex.

* fix(web): keep the 404 page working on malformed URLs

decodeURIComponent throws on escapes like /%FF/, which replaced the 404
page with an error screen. Fall back to the raw path, and add mounted
tests for the suggestion and the malformed case.
This commit is contained in:
Jatin K Malik
2026-11-01 01:44:17 -07:00
committed by GitHub
parent f7147bdb44
commit 122a5e69e6
4 changed files with 296 additions and 0 deletions
+88
View File
@@ -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 (
<SiteChrome variant="page">
<div className="page-main">
<div className="shell nf">
<section className="nf-hero">
<p className="nf-kicker">HTTP 404 · No such file or directory</p>
<h1>
This page is <em>not in your $PATH</em>.
</h1>
<p className="nf-lede">
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&apos;t
type a page that doesn&apos;t exist.
</p>
</section>
<section className="nf-stats" aria-labelledby="nf-stats-title">
<h2 id="nf-stats-title">Fun facts about 404</h2>
<div className="nf-stat-grid">
{stats.map((stat) => (
<article className="nf-stat" key={stat.title}>
<h3>{stat.title}</h3>
<p>{stat.body}</p>
</article>
))}
</div>
</section>
<div className="nf-next">
<DidYouMean />
<div className="nf-actions">
<Link className="btn btn-primary" href="/">
Back to home
</Link>
<Link className="btn btn-secondary" href="/install/">
Install Vocalinux
</Link>
</div>
<nav className="nf-links" aria-label="Popular pages">
{quickLinks.map((link) => (
<Link key={link.href} href={link.href}>
{link.label}
</Link>
))}
</nav>
</div>
</div>
</div>
</SiteChrome>
);
}
@@ -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(<DidYouMean />);
// 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(<DidYouMean />)).not.toThrow();
});
});
+76
View File
@@ -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<string | null>(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 (
<p className="nf-suggest">
Did you mean <Link href={suggestion}>{suggestion}</Link>?
</p>
);
}
+97
View File
@@ -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); }