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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user