feat(hikes): route-builder, overview map + cards, SAC-coloured pipeline

Build pipeline (scripts/build-hikes.ts) parses per-hike GPX, encodes
images via sharp, reverse-geocodes the centroid against Swisstopo and
emits a typed manifest under src/lib/data/hikes.generated.ts (gitignored).
Track JSON + image binaries live outside /static; served in dev by a
small hike-images plugin in vite.config.ts, in prod by nginx (private/
images proxied through Node + X-Accel-Redirect for auth-gating).

/hikes overview: full-bleed Swisstopo hero map (HikesOverviewMap) sits
under the sticky nav, drawing one polyline per route coloured by SAC
tier (T1 yellow Wegweiser, T2/T3 white-red-white, T4-T6 white-blue-
white). Click navigates, hover thickens + tooltips. Layer toggle,
recenter, GPS controls mirror the detail map (minus images toggle).
Cards drop the trail SVG, gain a per-route icon + SAC marker
pictogram on the cover, altitude range, season label, and "Neu" badge
for recently-published hikes. Filter bar + totals strip recompute over
the currently-visible set.

/hikes/[slug]: hero map with elevation profile, photo strip with map
sync, scroll-position pin, GPX download, SAC marker stats + min/max
altitude + season.

Route-builder (/hikes/route-builder): client-side draft persisted to
localStorage, EXIF-driven image placement, snap-to-route via BRouter
(OSRM + linear fallback) and Swisstopo profile.json elevation
enrichment that handles degenerate same-coord segments via the height
endpoint.

Filter init switched from a script-time snapshot of data.hikes (which
sporadically returned a one-hike subset during dev hydration and
locked the page to that single hike) to a post-mount \$effect.

Content under src/content/hikes/ intentionally not included (WIP).
This commit is contained in:
2026-05-18 21:13:00 +02:00
parent 928774084f
commit f3d16d5187
52 changed files with 8817 additions and 103 deletions
+56 -4
View File
@@ -25,6 +25,29 @@ async function htmlLang({ event, resolve }: Parameters<Handle>[0]) {
});
}
/** Apply headers to a response, transparently cloning it if the original
* has immutable headers. Auth.js (and certain fetch error/redirect responses)
* hand back frozen Headers, and a direct `.set()` on those throws
* `TypeError: immutable` — which would mask the underlying error and 500
* the request. Cloning preserves the body stream and status. */
function applyHeaders(response: Response, entries: Array<[string, string]>): Response {
try {
for (const [k, v] of entries) response.headers.set(k, v);
return response;
} catch (err) {
if (err instanceof TypeError) {
const headers = new Headers(response.headers);
for (const [k, v] of entries) headers.set(k, v);
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers
});
}
throw err;
}
}
/** Routes that must never appear in search-engine indexes. Search-results pages
* are thin/duplicate content; admin/edit/auth-walled pages have no public value
* and shouldn't burn crawl budget. Sets X-Robots-Tag rather than per-page meta
@@ -46,11 +69,34 @@ const NOINDEX_PATTERNS: RegExp[] = [
async function noindex({ event, resolve }: Parameters<Handle>[0]) {
const response = await resolve(event);
if (NOINDEX_PATTERNS.some((p) => p.test(event.url.pathname))) {
response.headers.set('X-Robots-Tag', 'noindex, nofollow');
return applyHeaders(response, [['X-Robots-Tag', 'noindex, nofollow']]);
}
return response;
}
/** Baseline security headers, set on every response.
*
* - X-Frame-Options + CSP frame-ancestors block this site from being
* iframed onto attacker pages (clickjacking on /login, /cospend,
* /fitness, etc.). Both directives are sent: modern browsers honour
* frame-ancestors and ignore the legacy header; older ones (IE11) only
* understand X-Frame-Options.
* - Strict-Transport-Security tells browsers to refuse plain-HTTP for
* bocken.org and any subdomain for one year, preventing protocol
* downgrade. Browsers ignore the header on http:// loads, so dev on
* localhost is unaffected. `preload` deliberately omitted — the HSTS
* preload list is hard to leave; revisit only after a stable production
* deployment.
*/
async function securityHeaders({ event, resolve }: Parameters<Handle>[0]) {
const response = await resolve(event);
return applyHeaders(response, [
['X-Frame-Options', 'DENY'],
['Content-Security-Policy', "frame-ancestors 'none'"],
['Strict-Transport-Security', 'max-age=31536000; includeSubDomains']
]);
}
async function timing({ event, resolve }: Parameters<Handle>[0]) {
const marks: Record<string, number> = {};
event.locals.timing = {
@@ -72,8 +118,7 @@ async function timing({ event, resolve }: Parameters<Handle>[0]) {
const header = Object.entries(marks)
.map(([k, v]) => `${k};dur=${v.toFixed(1)}`)
.join(', ');
response.headers.set('Server-Timing', header);
return response;
return applyHeaders(response, [['Server-Timing', header]]);
}
export const init: ServerInit = async () => {
@@ -172,8 +217,14 @@ async function authorization({ event, resolve }: Parameters<Handle>[0]) {
return resolve(event);
}
/** Browser/crawler probes for these paths are routine 404s — not bugs.
* Skip the noisy console.error so real errors stay visible. */
const SILENT_404_PATHS = new Set(['/favicon.ico', '/apple-touch-icon.png', '/apple-touch-icon-precomposed.png']);
export const handleError: HandleServerError = async ({ error, event, status, message }) => {
console.error('Error occurred:', { error, status, message, url: event.url.pathname });
if (!(status === 404 && SILENT_404_PATHS.has(event.url.pathname))) {
console.error('Error occurred:', { error, status, message, url: event.url.pathname });
}
const bibleQuote = await getRandomVerse(event.fetch, event.url.pathname);
const isEnglish = event.url.pathname.startsWith('/faith/') || event.url.pathname.startsWith('/recipes/');
@@ -189,6 +240,7 @@ export const handle: Handle = sequence(
timing,
htmlLang,
noindex,
securityHeaders,
auth.handle,
authorization
);