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:
@@ -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't
|
||||
type a page that doesn'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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user