Initial SvelteKit frontend port of windwiderstand.de
Full parity with Astro site: content rows, post/tag routes, pagination, event badges + OSM map, comments, Live-Search via /api/search-index, CMS image proxy, RSS, sitemap. Deploy: Dockerfile + docker-compose.prod.yml + Gitea Actions workflow to build + push to git.pm86.de registry and ssh-deploy to Contabo.
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { invalidateAll, invalidateCollection } from '$lib/cms';
|
||||
|
||||
/**
|
||||
* RustyCMS Webhook Receiver.
|
||||
*
|
||||
* Erwartet Payload mit `event` (z.B. "content.updated") und optional `collection` + `slug`.
|
||||
* Auth via X-Revalidate-Token Header (shared secret aus REVALIDATE_TOKEN env var).
|
||||
* Purged TTL-Cache: per-collection wenn collection bekannt, sonst komplett.
|
||||
*/
|
||||
|
||||
type WebhookPayload = {
|
||||
event?: string;
|
||||
collection?: string;
|
||||
slug?: string;
|
||||
[k: string]: unknown;
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async ({ request, url }) => {
|
||||
const token = env.REVALIDATE_TOKEN;
|
||||
if (!token) {
|
||||
throw error(500, 'REVALIDATE_TOKEN not configured');
|
||||
}
|
||||
const provided =
|
||||
request.headers.get('x-revalidate-token') ?? url.searchParams.get('token');
|
||||
if (provided !== token) {
|
||||
throw error(401, 'invalid token');
|
||||
}
|
||||
|
||||
let payload: WebhookPayload = {};
|
||||
try {
|
||||
payload = (await request.json()) as WebhookPayload;
|
||||
} catch {
|
||||
// empty body = purge all
|
||||
}
|
||||
|
||||
const event = payload.event ?? '';
|
||||
const collection = payload.collection;
|
||||
|
||||
if (event.startsWith('schema.') || event === 'webhook.test') {
|
||||
invalidateAll();
|
||||
return json({ ok: true, purged: 'all', reason: event || 'no-event' });
|
||||
}
|
||||
|
||||
if (event.startsWith('asset.')) {
|
||||
// Assets referenziert überall → safe bet: purge all collections mit resolve
|
||||
invalidateAll();
|
||||
return json({ ok: true, purged: 'all', reason: event });
|
||||
}
|
||||
|
||||
if (collection) {
|
||||
const n = invalidateCollection(collection);
|
||||
// Page/Post Listen cachen oft referenzen → auch "page" + "post" purgen wenn image/tag geändert
|
||||
if (collection === 'img' || collection === 'image' || collection === 'tag') {
|
||||
invalidateCollection('page');
|
||||
invalidateCollection('post');
|
||||
}
|
||||
return json({ ok: true, purged: collection, entries: n, event });
|
||||
}
|
||||
|
||||
invalidateAll();
|
||||
return json({ ok: true, purged: 'all', reason: 'no collection in payload' });
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { getPages, getPosts } from '$lib/cms';
|
||||
import {
|
||||
filterHiddenPosts,
|
||||
sortPostsByDate,
|
||||
getPostImageUrl,
|
||||
postHref,
|
||||
} from '$lib/blog-utils';
|
||||
import { ensureTransformedImage } from '$lib/rusty-image';
|
||||
|
||||
export type SearchHit = {
|
||||
type: 'page' | 'post';
|
||||
slug: string;
|
||||
href: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
excerpt: string;
|
||||
image: string | null;
|
||||
created: string | null;
|
||||
isEvent?: boolean;
|
||||
eventDate?: string | null;
|
||||
};
|
||||
|
||||
function pageHref(p: { slug?: string; _slug?: string }): string {
|
||||
const raw = (p.slug ?? p._slug ?? '').replace(/^\//, '').trim();
|
||||
if (!raw || raw === 'home') return '/';
|
||||
return '/' + raw.split('/').filter(Boolean).map(encodeURIComponent).join('/');
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async () => {
|
||||
let pages: Awaited<ReturnType<typeof getPages>> = [];
|
||||
let posts: Awaited<ReturnType<typeof getPosts>> = [];
|
||||
try {
|
||||
[pages, posts] = await Promise.all([
|
||||
getPages(),
|
||||
getPosts({ resolve: ['postImage'] }),
|
||||
]);
|
||||
} catch {
|
||||
return json({ hits: [] satisfies SearchHit[] });
|
||||
}
|
||||
|
||||
posts = filterHiddenPosts(sortPostsByDate(posts));
|
||||
|
||||
const postHits: SearchHit[] = await Promise.all(
|
||||
posts.map(async (p) => {
|
||||
const raw = getPostImageUrl(p.postImage);
|
||||
let image: string | null = null;
|
||||
if (raw) {
|
||||
try {
|
||||
image = await ensureTransformedImage(raw, {
|
||||
width: 160,
|
||||
height: 106,
|
||||
fit: 'cover',
|
||||
format: 'webp',
|
||||
});
|
||||
} catch {
|
||||
/* optional */
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: 'post',
|
||||
slug: p.slug ?? p._slug ?? '',
|
||||
href: postHref(p),
|
||||
title: p.headline ?? p.linkName ?? p._slug ?? '',
|
||||
subtitle: p.subheadline ?? '',
|
||||
excerpt: p.excerpt ?? '',
|
||||
image,
|
||||
created: p.created ?? null,
|
||||
isEvent: Boolean((p as { isEvent?: boolean }).isEvent),
|
||||
eventDate: (p as { eventDate?: string }).eventDate ?? null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const pageHits: SearchHit[] = pages
|
||||
.filter((p) => !(p as { hideFromSearch?: boolean }).hideFromSearch)
|
||||
.map((p) => ({
|
||||
type: 'page',
|
||||
slug: p.slug ?? p._slug ?? '',
|
||||
href: pageHref(p),
|
||||
title:
|
||||
(p as { headline?: string }).headline ??
|
||||
(p as { title?: string }).title ??
|
||||
p._slug ??
|
||||
'',
|
||||
subtitle: (p as { subheadline?: string }).subheadline ?? '',
|
||||
excerpt: (p as { excerpt?: string }).excerpt ?? '',
|
||||
image: null,
|
||||
created: (p as { created?: string }).created ?? null,
|
||||
}));
|
||||
|
||||
return json(
|
||||
{ hits: [...pageHits, ...postHits] satisfies SearchHit[] },
|
||||
{ headers: { 'cache-control': 'public, max-age=60, s-maxage=60' } },
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { getCachedTransform, setCachedTransform } from '$lib/transform-cache';
|
||||
|
||||
const CACHE_HEADERS = {
|
||||
'cache-control': 'public, max-age=31536000, immutable',
|
||||
} as const;
|
||||
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
const params = url.searchParams.toString();
|
||||
const cached = await getCachedTransform(params);
|
||||
if (cached) {
|
||||
return new Response(cached.buffer, {
|
||||
headers: {
|
||||
'content-type': cached.contentType,
|
||||
...CACHE_HEADERS,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const cmsUrl = (env.PUBLIC_CMS_URL || import.meta.env.PUBLIC_CMS_URL || 'http://localhost:3000').replace(/\/$/, '');
|
||||
const transformUrl = `${cmsUrl}/api/transform?${params}`;
|
||||
const res = await fetch(transformUrl);
|
||||
if (!res.ok) {
|
||||
return new Response('Image transform failed', { status: res.status });
|
||||
}
|
||||
|
||||
const buffer = await res.arrayBuffer();
|
||||
const contentType = res.headers.get('content-type') ?? 'image/jpeg';
|
||||
await setCachedTransform(params, buffer, contentType);
|
||||
|
||||
return new Response(buffer, {
|
||||
headers: {
|
||||
'content-type': contentType,
|
||||
...CACHE_HEADERS,
|
||||
},
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user