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
@@ -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>
);
}