Files
website/src/lib/errors.ts

143 lines
4.9 KiB
TypeScript

// Copy and "did you mean" suggestions for the global error page (src/routes/+error.svelte,
// rendered by #lib/components/ErrorView.svelte). Pure functions, unit tested.
import { projects, visibleTrackers } from '#lib/projects.js';
export type ErrorTone = 'missing' | 'locked' | 'broken' | 'down';
export type ErrorCopy = { title: string; message: string; tone: ErrorTone };
export function errorCopy(status: number): ErrorCopy {
if (status === 404 || status === 410)
return {
title: 'Page not found',
message: 'This page does not exist (anymore), or the link has a typo.',
tone: 'missing'
};
if (status === 401)
return {
title: 'Sign in to continue',
message: 'This page is only for signed-in visitors.',
tone: 'locked'
};
if (status === 403)
return {
title: 'Owner only',
message: 'This part of the site is private.',
tone: 'locked'
};
if (status === 405)
return {
title: 'Not allowed',
message: 'This address does not accept that kind of request.',
tone: 'missing'
};
if (status === 429)
return {
title: 'Slow down a bit',
message: 'Too many requests in a short time. Try again in a minute.',
tone: 'down'
};
if (status === 502 || status === 503 || status === 504)
return {
title: 'Temporarily unavailable',
message:
'Something this page depends on is not responding right now. It is usually back within a minute.',
tone: 'down'
};
if (status >= 500)
return {
title: 'Something broke',
message: 'An unexpected error happened on the server. It has been logged.',
tone: 'broken'
};
return {
title: 'That did not work',
message: 'The request could not be completed.',
tone: 'missing'
};
}
export type Suggestion = {
name: string;
href: string;
kind: 'tracker' | 'project' | 'page';
subtitle: string;
locked?: boolean;
};
const pages: Suggestion[] = [
{ name: 'home', href: '/', kind: 'page', subtitle: 'the front page' },
{ name: 'trackers', href: '/trackers', kind: 'page', subtitle: 'every tracker' },
{ name: 'projects', href: '/projects', kind: 'page', subtitle: 'tools and experiments' },
{ name: 'blog', href: '/blog', kind: 'page', subtitle: 'articles and notes' }
];
/** Every internal destination the error page can suggest (private trackers only for the owner). */
export function destinations(isOwner: boolean): Suggestion[] {
const out: Suggestion[] = [...pages];
for (const p of projects)
if (!p.external)
out.push({ name: p.name, href: p.href, kind: 'project', subtitle: p.subtitle });
for (const t of visibleTrackers(isOwner)) {
out.push({
name: t.name,
href: t.href,
kind: 'tracker',
subtitle: t.subtitle,
...(t.locked ? { locked: true } : {})
});
for (const c of t.children ?? [])
out.push({ name: c.name, href: c.href, kind: 'tracker', subtitle: c.subtitle });
}
const seen = new Set<string>();
return out.filter((d) => !seen.has(d.href) && seen.add(d.href));
}
function levenshtein(a: string, b: string): number {
if (a === b) return 0;
let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
for (let i = 1; i <= a.length; i++) {
const cur = [i];
for (let j = 1; j <= b.length; j++)
cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
prev = cur;
}
return prev[b.length];
}
const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, '');
function similarity(a: string, b: string): number {
if (!a || !b) return 0;
if (a === b) return 1;
if (a.length >= 3 && b.length >= 3 && (a.includes(b) || b.includes(a))) return 0.85;
return 1 - levenshtein(a, b) / Math.max(a.length, b.length);
}
/**
* Up to `limit` destinations that look like what `pathname` was meant to be: the closest
* existing parent section (/trackers/cars for /trackers/cars/nope), then fuzzy matches of
* any path segment against tracker/project names and URL slugs ("/trakcers/bokos" → books).
*/
export function suggestPages(pathname: string, isOwner: boolean, limit = 3): Suggestion[] {
const path = pathname.replace(/\/+$/, '') || '/';
const segments = path.split('/').map(norm).filter(Boolean);
if (!segments.length) return [];
const scored = destinations(isOwner)
.filter((d) => d.href !== path)
.map((d) => {
const slug = norm(d.href.split('/').filter(Boolean).at(-1) ?? '');
const name = norm(d.name.replace(/\(.*\)/, ''));
let score = 0;
for (const s of segments) score = Math.max(score, similarity(s, name), similarity(s, slug));
// Generic section names only count when spelled out: they match too much otherwise.
if (d.kind === 'page') score = path.startsWith(`${d.href}/`) ? 0 : score * 0.8;
// The closest real parent (/trackers/cars for /trackers/cars/nope); sections like
// /trackers are already in the navigation tiles.
if (d.kind !== 'page' && path.startsWith(`${d.href}/`))
score = Math.max(score, 0.9 + d.href.length / 1000);
return { d, score };
})
.filter((x) => x.score >= 0.6)
.sort((a, b) => b.score - a.score);
return scored.slice(0, limit).map((x) => x.d);
}