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:
6
src/app.d.ts
vendored
6
src/app.d.ts
vendored
@@ -4,7 +4,11 @@
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
interface Error {
|
||||
message: string;
|
||||
/** Short id of an unexpected (5xx) error, also in the server log line. */
|
||||
id?: string;
|
||||
}
|
||||
interface Locals {
|
||||
user: typeof import('#lib/server/auth.js').auth.$Infer.Session.user | null;
|
||||
session: typeof import('#lib/server/auth.js').auth.$Infer.Session.session | null;
|
||||
|
||||
67
src/error.html
Normal file
67
src/error.html
Normal file
@@ -0,0 +1,67 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="robots" content="noindex" />
|
||||
<title>%sveltekit.status% · Stan</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark light;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #0a0a0a;
|
||||
color: #fafafa;
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
body {
|
||||
background: #fff;
|
||||
color: #0a0a0a;
|
||||
}
|
||||
}
|
||||
main {
|
||||
padding: 2rem;
|
||||
max-width: 32rem;
|
||||
}
|
||||
.code {
|
||||
font-size: 6rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.05em;
|
||||
line-height: 1;
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, #f472b6, #7c3aed);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
p {
|
||||
opacity: 0.7;
|
||||
}
|
||||
a {
|
||||
display: inline-block;
|
||||
margin-top: 1rem;
|
||||
padding: 0.5rem 0.9rem;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 0.6rem;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
a:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<p class="code">%sveltekit.status%</p>
|
||||
<h1>Something went wrong</h1>
|
||||
<p>%sveltekit.error.message%. Try again in a moment.</p>
|
||||
<a href="/">go home</a>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
10
src/hooks.client.ts
Normal file
10
src/hooks.client.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { HandleClientError } from '@sveltejs/kit';
|
||||
|
||||
// Errors thrown while rendering/loading in the browser: same shape as the server (short id,
|
||||
// generic message) so the error page looks the same either way.
|
||||
export const handleError: HandleClientError = ({ error, status, message }) => {
|
||||
if (status === 404) return { message: 'Not Found' };
|
||||
const id = crypto.randomUUID().slice(0, 8);
|
||||
console.error(`[error ${id}] ${status}`, error);
|
||||
return { message: status >= 500 ? 'Internal Error' : message, id };
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Handle, ServerInit } from '@sveltejs/kit/hooks';
|
||||
import type { Handle, HandleServerError, ServerInit } from '@sveltejs/kit/hooks';
|
||||
import { startMusicPoller } from '#lib/server/music/poller.js';
|
||||
import { building } from '$app/env';
|
||||
import { auth } from '#lib/server/auth.js';
|
||||
@@ -41,3 +41,19 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
}
|
||||
return response;
|
||||
};
|
||||
|
||||
// Unexpected errors (anything not thrown with error()) and unknown routes. 5xx get a short id
|
||||
// that the error page shows and the log line starts with ("[error ab12cd34]"), so a screenshot
|
||||
// or report can be matched to the stack trace. The client only gets a generic message: no
|
||||
// stack, no internals. Query strings are left out of the log (they can carry tokens).
|
||||
export const handleError: HandleServerError = ({ error, event, status, message }) => {
|
||||
const route = event.route.id ?? '(no route)';
|
||||
const where = `${event.request.method} ${event.url.pathname} route=${route}`;
|
||||
if (status === 404) {
|
||||
console.warn(`[404] ${where}`);
|
||||
return { message: 'Not Found' };
|
||||
}
|
||||
const id = crypto.randomUUID().slice(0, 8);
|
||||
console.error(`[error ${id}] ${status} ${where}`, error);
|
||||
return { message: status >= 500 ? 'Internal Error' : message, id };
|
||||
};
|
||||
|
||||
315
src/lib/components/ErrorView.svelte
Normal file
315
src/lib/components/ErrorView.svelte
Normal file
@@ -0,0 +1,315 @@
|
||||
<script lang="ts">
|
||||
// The site-wide error screen (rendered by src/routes/+error.svelte inside the normal
|
||||
// header/footer). Status-specific copy, the requested path, a short error id for 5xx
|
||||
// (matches the server log line from handleError), "did you mean" tiles for 404s and
|
||||
// quick navigation tiles. The owner can file the error as a task in one click.
|
||||
import { resolve } from '$app/paths';
|
||||
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
||||
import ArrowRight from 'lucide-svelte/icons/arrow-right';
|
||||
import Bug from 'lucide-svelte/icons/bug';
|
||||
import Check from 'lucide-svelte/icons/check';
|
||||
import Copy from 'lucide-svelte/icons/copy';
|
||||
import FileText from 'lucide-svelte/icons/file-text';
|
||||
import FolderKanban from 'lucide-svelte/icons/folder-kanban';
|
||||
import House from 'lucide-svelte/icons/house';
|
||||
import LogIn from 'lucide-svelte/icons/log-in';
|
||||
import ChartColumn from 'lucide-svelte/icons/chart-column';
|
||||
import ProjectIcon from '#lib/components/ProjectIcon.svelte';
|
||||
import TrackerIcon from '#lib/components/TrackerIcon.svelte';
|
||||
import { errorCopy, suggestPages } from '#lib/errors.js';
|
||||
|
||||
let {
|
||||
status,
|
||||
message = '',
|
||||
errorId,
|
||||
path,
|
||||
signedIn = false,
|
||||
isOwner = false
|
||||
}: {
|
||||
status: number;
|
||||
message?: string;
|
||||
errorId?: string;
|
||||
path: string;
|
||||
signedIn?: boolean;
|
||||
isOwner?: boolean;
|
||||
} = $props();
|
||||
|
||||
const copy = $derived(errorCopy(status));
|
||||
// Show a thrown error's own message ("Spotify is not configured…"), not the defaults.
|
||||
const detail = $derived(
|
||||
message && !/^(not found|internal error|error)$/i.test(message.trim()) ? message : ''
|
||||
);
|
||||
const suggestions = $derived(status === 404 ? suggestPages(path, isOwner) : []);
|
||||
const showSignIn = $derived(!signedIn && (status === 401 || status === 403 || status === 404));
|
||||
const loginHref = $derived(`${resolve('/login')}?returnTo=${encodeURIComponent(path)}`);
|
||||
|
||||
let report = $state<'idle' | 'sending' | 'failed' | number>('idle');
|
||||
async function reportIt() {
|
||||
if (report === 'sending' || typeof report === 'number') return;
|
||||
report = 'sending';
|
||||
const notes = [
|
||||
`Status: ${status}`,
|
||||
`Path: ${path}`,
|
||||
errorId ? `Error id: ${errorId} (grep the app logs for "[error ${errorId}]")` : '',
|
||||
detail ? `Message: ${detail}` : '',
|
||||
`When: ${new Date().toISOString()}`,
|
||||
document.referrer ? `From: ${document.referrer}` : ''
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
try {
|
||||
const res = await fetch(resolve('/trackers/tasks/quick'), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ title: `Fix ${status} on ${path}`.slice(0, 500), notes })
|
||||
});
|
||||
report = res.ok ? ((await res.json()) as { id: number }).id : 'failed';
|
||||
} catch {
|
||||
report = 'failed';
|
||||
}
|
||||
}
|
||||
|
||||
let copied = $state(false);
|
||||
async function copyId() {
|
||||
if (!errorId) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(errorId);
|
||||
copied = true;
|
||||
setTimeout(() => (copied = false), 1500);
|
||||
} catch {
|
||||
// clipboard blocked
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
const sameOrigin = document.referrer.startsWith(location.origin);
|
||||
if (history.length > 1 && sameOrigin) history.back();
|
||||
else location.href = resolve('/');
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="mx-auto max-w-6xl px-4 py-14 sm:px-6 sm:py-20" aria-labelledby="error-title">
|
||||
<p class="code tone-{copy.tone} text-8xl font-semibold tracking-tighter tabular-nums sm:text-9xl">
|
||||
{status}
|
||||
</p>
|
||||
<h1 id="error-title" class="mt-4 text-3xl font-semibold tracking-tight sm:text-4xl">
|
||||
{copy.title}
|
||||
</h1>
|
||||
<p class="mt-3 max-w-2xl text-[var(--site-muted)]">
|
||||
{copy.message}
|
||||
{#if detail}<span class="mt-1 block text-foreground">{detail}</span>{/if}
|
||||
</p>
|
||||
|
||||
<div class="mt-6 flex flex-wrap items-center gap-2 text-sm">
|
||||
<code
|
||||
class="max-w-full truncate rounded-md border border-[var(--site-border)] bg-[var(--site-surface)] px-2.5 py-1 font-mono text-xs"
|
||||
title={path}>{path}</code
|
||||
>
|
||||
{#if errorId}
|
||||
<button
|
||||
type="button"
|
||||
class="chip flex items-center gap-1.5 px-2.5 py-1 font-mono text-xs"
|
||||
onclick={copyId}
|
||||
title="Copy error id"
|
||||
>error id {errorId}
|
||||
{#if copied}<Check size={12} aria-hidden="true" />{:else}<Copy
|
||||
size={12}
|
||||
aria-hidden="true"
|
||||
/>{/if}</button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if suggestions.length}
|
||||
<h2 class="mt-12 mb-3 text-sm font-medium text-[var(--site-muted)]">did you mean</h2>
|
||||
<ul class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each suggestions as s (s.href)}
|
||||
<li>
|
||||
<a href={resolve(s.href as '/')} class="tile flex h-full items-center gap-3 p-4">
|
||||
{#if s.kind === 'tracker'}
|
||||
<TrackerIcon name={s.name} class="tile-icon size-10" />
|
||||
{:else if s.kind === 'project'}
|
||||
<span
|
||||
class="tile-icon flex size-10 shrink-0 items-center justify-center rounded-lg border border-border bg-[var(--site-surface)] text-[var(--site-muted)]"
|
||||
><ProjectIcon name={s.name} class="size-5" /></span
|
||||
>
|
||||
{:else}
|
||||
<span
|
||||
class="tile-icon flex size-10 shrink-0 items-center justify-center rounded-lg border border-border bg-[var(--site-surface)] text-[var(--site-muted)]"
|
||||
><FileText size={18} aria-hidden="true" /></span
|
||||
>
|
||||
{/if}
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block font-medium">{s.name}</span>
|
||||
<span class="block truncate text-sm text-muted-foreground">{s.subtitle}</span>
|
||||
</span>
|
||||
<ArrowRight
|
||||
size={16}
|
||||
aria-hidden="true"
|
||||
class="tile-arrow shrink-0 text-[var(--site-muted)]"
|
||||
/>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<h2 class="mt-12 mb-3 text-sm font-medium text-[var(--site-muted)]">go somewhere else</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#if showSignIn}
|
||||
<a href={loginHref} class="tile flex items-center gap-2 px-3 py-2 text-sm font-medium"
|
||||
><span class="tile-icon accent accent-signin"><LogIn size={14} aria-hidden="true" /></span
|
||||
>sign in</a
|
||||
>
|
||||
{/if}
|
||||
<a href={resolve('/')} class="tile flex items-center gap-2 px-3 py-2 text-sm"
|
||||
><span class="tile-icon accent accent-home"><House size={14} aria-hidden="true" /></span
|
||||
>home</a
|
||||
>
|
||||
<a href={resolve('/trackers')} class="tile flex items-center gap-2 px-3 py-2 text-sm"
|
||||
><span class="tile-icon accent accent-trackers"
|
||||
><ChartColumn size={14} aria-hidden="true" /></span
|
||||
>trackers</a
|
||||
>
|
||||
<a href={resolve('/projects')} class="tile flex items-center gap-2 px-3 py-2 text-sm"
|
||||
><span class="tile-icon accent accent-projects"
|
||||
><FolderKanban size={14} aria-hidden="true" /></span
|
||||
>projects</a
|
||||
>
|
||||
<button type="button" class="tile flex items-center gap-2 px-3 py-2 text-sm" onclick={goBack}
|
||||
><span class="tile-icon accent accent-back"><ArrowLeft size={14} aria-hidden="true" /></span
|
||||
>go back</button
|
||||
>
|
||||
{#if isOwner}
|
||||
<button
|
||||
type="button"
|
||||
class="tile flex items-center gap-2 px-3 py-2 text-sm"
|
||||
onclick={reportIt}
|
||||
disabled={report === 'sending' || typeof report === 'number'}
|
||||
aria-live="polite"
|
||||
><span class="tile-icon accent accent-report"><Bug size={14} aria-hidden="true" /></span
|
||||
>{#if report === 'sending'}reporting…{:else if typeof report === 'number'}added as task #{report}{:else if report === 'failed'}could
|
||||
not report, retry{:else}report it{/if}</button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.code {
|
||||
line-height: 1;
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
color: transparent;
|
||||
background-image: linear-gradient(135deg, var(--from), var(--to));
|
||||
}
|
||||
.tone-missing {
|
||||
--from: #fbbf24;
|
||||
--to: #e11d48;
|
||||
}
|
||||
.tone-locked {
|
||||
--from: #38bdf8;
|
||||
--to: #6366f1;
|
||||
}
|
||||
.tone-broken {
|
||||
--from: #f472b6;
|
||||
--to: #7c3aed;
|
||||
}
|
||||
.tone-down {
|
||||
--from: #fdba74;
|
||||
--to: #dc2626;
|
||||
}
|
||||
.tile,
|
||||
.chip {
|
||||
border: 1px solid color-mix(in oklab, var(--foreground) 10%, transparent);
|
||||
border-radius: 0.7rem;
|
||||
background: color-mix(in oklab, var(--foreground) 2%, transparent);
|
||||
color: var(--foreground);
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition:
|
||||
transform 160ms cubic-bezier(0.2, 0.8, 0.2, 1),
|
||||
background-color 160ms ease,
|
||||
border-color 160ms ease,
|
||||
box-shadow 160ms ease;
|
||||
}
|
||||
.chip {
|
||||
border-radius: 999px;
|
||||
}
|
||||
.tile:hover,
|
||||
.chip:hover {
|
||||
background: color-mix(in oklab, var(--foreground) 6%, transparent);
|
||||
border-color: color-mix(in oklab, var(--foreground) 24%, transparent);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 8px 20px -12px rgb(0 0 0 / 0.5);
|
||||
}
|
||||
.tile:active,
|
||||
.chip:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: none;
|
||||
}
|
||||
.tile:focus-visible,
|
||||
.chip:focus-visible {
|
||||
outline: 2px solid var(--foreground);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.tile:disabled {
|
||||
cursor: default;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
.tile :global(.tile-icon),
|
||||
.tile :global(.tile-arrow) {
|
||||
transition: transform 160ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
.tile:hover :global(.tile-icon) {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
.tile:hover :global(.tile-arrow) {
|
||||
transform: translateX(2px);
|
||||
}
|
||||
.accent {
|
||||
display: inline-flex;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 0.4rem;
|
||||
color: #fff;
|
||||
}
|
||||
.accent-home {
|
||||
background-image: linear-gradient(135deg, #34d399, #059669);
|
||||
}
|
||||
.accent-trackers {
|
||||
background-image: linear-gradient(135deg, #60a5fa, #2563eb);
|
||||
}
|
||||
.accent-projects {
|
||||
background-image: linear-gradient(135deg, #a78bfa, #7c3aed);
|
||||
}
|
||||
.accent-back {
|
||||
background-image: linear-gradient(135deg, #a1a1aa, #52525b);
|
||||
}
|
||||
.accent-signin {
|
||||
background-image: linear-gradient(135deg, #38bdf8, #6366f1);
|
||||
}
|
||||
.accent-report {
|
||||
background-image: linear-gradient(135deg, #fb7185, #e11d48);
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.tile,
|
||||
.chip,
|
||||
.tile :global(.tile-icon),
|
||||
.tile :global(.tile-arrow) {
|
||||
transition:
|
||||
background-color 160ms ease,
|
||||
border-color 160ms ease;
|
||||
}
|
||||
.tile:hover,
|
||||
.chip:hover,
|
||||
.tile:hover :global(.tile-icon),
|
||||
.tile:hover :global(.tile-arrow) {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
34
src/lib/errors.test.ts
Normal file
34
src/lib/errors.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { errorCopy, suggestPages } from './errors';
|
||||
|
||||
const hrefs = (path: string, owner = false) => suggestPages(path, owner).map((s) => s.href);
|
||||
|
||||
describe('errorCopy', () => {
|
||||
it('has copy per status family', () => {
|
||||
expect(errorCopy(404).tone).toBe('missing');
|
||||
expect(errorCopy(403).title).toBe('Owner only');
|
||||
expect(errorCopy(500).tone).toBe('broken');
|
||||
expect(errorCopy(503).tone).toBe('down');
|
||||
});
|
||||
});
|
||||
|
||||
describe('suggestPages', () => {
|
||||
it('suggests the parent tracker for a missing detail page', () => {
|
||||
expect(hrefs('/trackers/cars/does-not-exist')[0]).toBe('/trackers/cars');
|
||||
});
|
||||
it('fixes typos in tracker names', () => {
|
||||
expect(hrefs('/trackers/bokos')).toContain('/trackers/books');
|
||||
expect(hrefs('/trakcers/trians')).toContain('/trackers/trains');
|
||||
});
|
||||
it('finds a tracker by name under the wrong section', () => {
|
||||
expect(hrefs('/projects/planes')).toContain('/trackers/planes');
|
||||
});
|
||||
it('never suggests private trackers to visitors', () => {
|
||||
expect(hrefs('/trackers/helth')).not.toContain('/trackers/health');
|
||||
expect(hrefs('/trackers/helth', true)).toContain('/trackers/health');
|
||||
});
|
||||
it('returns nothing for the home page or noise', () => {
|
||||
expect(hrefs('/')).toEqual([]);
|
||||
expect(hrefs('/zzqxj')).toEqual([]);
|
||||
});
|
||||
});
|
||||
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);
|
||||
}
|
||||
@@ -34,4 +34,18 @@ describe('daisy task webhook', () => {
|
||||
);
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('takes only the name from a full "Name: value" header setting', () => {
|
||||
const base = {
|
||||
DAISY_TASK_WEBHOOK_URL: 'https://example.test/hook',
|
||||
DAISY_TASK_WEBHOOK_KEY: 'k1'
|
||||
};
|
||||
const cfg = daisyHookConfig({
|
||||
...base,
|
||||
DAISY_TASK_WEBHOOK_HEADER: 'Authorization: Bearer old'
|
||||
});
|
||||
expect(cfg?.headers.Authorization).toBe('Bearer k1');
|
||||
expect(Object.keys(cfg?.headers ?? {})).toEqual(['content-type', 'Authorization']);
|
||||
const bad = daisyHookConfig({ ...base, DAISY_TASK_WEBHOOK_HEADER: 'bad header' });
|
||||
expect(bad?.headers.Authorization).toBe('Bearer k1');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,10 @@ export function daisyHookConfig(env: Record<string, string | undefined> = proces
|
||||
const url = env.DAISY_TASK_WEBHOOK_URL?.trim();
|
||||
const key = env.DAISY_TASK_WEBHOOK_KEY?.trim();
|
||||
if (!url || !key) return null;
|
||||
const header = env.DAISY_TASK_WEBHOOK_HEADER?.trim() || 'Authorization';
|
||||
// Header name only. Tolerate a full "Name: value" line (the value part is ignored; the key
|
||||
// comes from DAISY_TASK_WEBHOOK_KEY) and fall back to Authorization for anything invalid.
|
||||
const rawHeader = env.DAISY_TASK_WEBHOOK_HEADER?.split(':')[0].trim() ?? '';
|
||||
const header = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(rawHeader) ? rawHeader : 'Authorization';
|
||||
const value = header.toLowerCase() === 'authorization' ? `Bearer ${key}` : key;
|
||||
return { url, headers: { 'content-type': 'application/json', [header]: value } };
|
||||
}
|
||||
@@ -33,6 +36,12 @@ export function notifyDaisy(
|
||||
.then((res) => {
|
||||
if (!res.ok) console.error(`[daisy-hook] task ${task.id}: HTTP ${res.status}`);
|
||||
})
|
||||
.catch((err) => console.error(`[daisy-hook] task ${task.id}: ${String(err)}`));
|
||||
.catch((err: unknown) => {
|
||||
// Name/code only: some fetch errors echo request headers (and so the key).
|
||||
const e = err as { name?: string; cause?: { code?: string } };
|
||||
console.error(
|
||||
`[daisy-hook] task ${task.id}: ${e?.name ?? 'Error'} ${e?.cause?.code ?? ''}`.trim()
|
||||
);
|
||||
});
|
||||
return 'sent';
|
||||
}
|
||||
|
||||
18
src/routes/+error.svelte
Normal file
18
src/routes/+error.svelte
Normal file
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
// Global error page: every 404/5xx anywhere on the site renders here, inside the root
|
||||
// layout (header + footer). Errors in the root layout load itself fall back to
|
||||
// src/error.html.
|
||||
import { page } from '$app/state';
|
||||
import ErrorView from '#lib/components/ErrorView.svelte';
|
||||
|
||||
const data = $derived(page.data as { user?: unknown; isOwner?: boolean });
|
||||
</script>
|
||||
|
||||
<ErrorView
|
||||
status={page.status}
|
||||
message={page.error?.message}
|
||||
errorId={page.error?.id}
|
||||
path={page.url.pathname}
|
||||
signedIn={!!data.user}
|
||||
isOwner={!!data.isOwner}
|
||||
/>
|
||||
@@ -2,12 +2,19 @@
|
||||
import '../app.css';
|
||||
import { dev } from '$app/env';
|
||||
import { page } from '$app/state';
|
||||
import { errorCopy } from '#lib/errors.js';
|
||||
import { getSeo } from '#lib/seo.js';
|
||||
import { formatTitle } from '#lib/title.js';
|
||||
|
||||
// The header/footer are in #lib/components/SiteChrome.svelte, which +layout.ts loads only
|
||||
// for pages that show them (not the full-screen book reader).
|
||||
let { data, children } = $props();
|
||||
const seo = $derived(getSeo(page.url.pathname, page.data, data.environment));
|
||||
const seo = $derived.by(() => {
|
||||
const seo = getSeo(page.url.pathname, page.data, data.environment);
|
||||
if (!page.error) return seo;
|
||||
const title = `${page.status} ${errorCopy(page.status).title}`;
|
||||
return { ...seo, title: formatTitle(title, data.environment), indexable: false };
|
||||
});
|
||||
const Chrome = $derived(data.Chrome);
|
||||
</script>
|
||||
|
||||
|
||||
@@ -3,22 +3,27 @@ import type { Actions, PageServerLoad } from './$types';
|
||||
import { db } from '#lib/server/db/index.js';
|
||||
import { tasks, tasksToTopics, topics } from '#lib/server/db/schema/index.js';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { error } from '@sveltejs/kit';
|
||||
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
const { params } = event;
|
||||
return {
|
||||
task: await db.query.tasks.findFirst({
|
||||
with: {
|
||||
tasksToTopics: {
|
||||
with: {
|
||||
topic: true
|
||||
}
|
||||
const id = Number(event.params.id);
|
||||
// Non-numeric ids used to reach Postgres as NaN and 500.
|
||||
if (!Number.isSafeInteger(id) || id < 1) error(404, 'Not Found');
|
||||
const task = await db.query.tasks.findFirst({
|
||||
with: {
|
||||
tasksToTopics: {
|
||||
with: {
|
||||
topic: true
|
||||
}
|
||||
},
|
||||
where: {
|
||||
id: Number(params.id)
|
||||
}
|
||||
}),
|
||||
},
|
||||
where: {
|
||||
id
|
||||
}
|
||||
});
|
||||
if (!task) error(404, 'Not Found');
|
||||
return {
|
||||
task,
|
||||
topics: await db.query.topics.findMany(),
|
||||
editable: isAdmin(event)
|
||||
};
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { db } from '#lib/server/db/index.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load = async ({ params }) => {
|
||||
return {
|
||||
topic: await db.query.topics.findFirst({
|
||||
with: {
|
||||
topicsToTasks: {
|
||||
with: {
|
||||
task: true
|
||||
}
|
||||
export const load: PageServerLoad = async ({ params }) => {
|
||||
const id = Number(params.id);
|
||||
// Non-numeric ids used to reach Postgres as NaN and 500.
|
||||
if (!Number.isSafeInteger(id) || id < 1) error(404, 'Not Found');
|
||||
const topic = await db.query.topics.findFirst({
|
||||
with: {
|
||||
topicsToTasks: {
|
||||
with: {
|
||||
task: true
|
||||
}
|
||||
},
|
||||
where: {
|
||||
id: Number(params.id)
|
||||
}
|
||||
})
|
||||
};
|
||||
},
|
||||
where: { id }
|
||||
});
|
||||
if (!topic) error(404, 'Not Found');
|
||||
return { topic };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user