All checks were successful
Deploy production / deploy (push) Successful in 1m10s
136 lines
4.9 KiB
TypeScript
136 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;
|
|
};
|
|
|
|
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 });
|
|
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);
|
|
}
|