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:
+56
-4
@@ -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
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user