Global error page: ErrorView with per-status copy, did-you-mean suggestions, error ids from handleError, static error.html; 404 instead of 500 for bad progress ids; tolerate full header line in DAISY_TASK_WEBHOOK_HEADER and stop logging raw fetch errors
All checks were successful
Deploy production / deploy (push) Successful in 1m9s
All checks were successful
Deploy production / deploy (push) Successful in 1m9s
This commit is contained in:
133
src/lib/errors.ts
Normal file
133
src/lib/errors.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
// 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 *= 0.8;
|
||||
if (d.href !== '/' && 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);
|
||||
}
|
||||
Reference in New Issue
Block a user