Initial SvelteKit frontend port of windwiderstand.de
Deploy / verify (push) Failing after 32s
Deploy / deploy (push) Has been skipped

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:
Peter Meier
2026-04-17 22:01:30 +02:00
parent 852cef0980
commit 2fef91a548
137 changed files with 28585 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
<script lang="ts">
import { page } from '$app/stores';
import { useTranslate } from '$lib/translations';
const t = useTranslate();
const status = $derived($page.status);
const message = $derived($page.error?.message ?? '');
</script>
<svelte:head>
<title>{status === 404 ? t('page_404_title') : `Fehler ${status}`}</title>
</svelte:head>
<main class="mx-auto max-w-3xl px-4 py-12 text-center">
<h1 class="text-3xl font-bold text-zinc-900">{status}</h1>
{#if status === 404}
<p class="mt-2 text-zinc-600">{t('page_404_text')}</p>
{:else if message}
<p class="mt-2 text-zinc-600">{message}</p>
{/if}
<a
href="/"
class="mt-6 inline-block rounded bg-zinc-900 px-4 py-2 text-white hover:bg-zinc-800"
>
{t('page_404_back')}
</a>
</main>
+238
View File
@@ -0,0 +1,238 @@
import type { LayoutServerLoad } from './$types';
import {
getNavigationByKey,
NavigationKeys,
getPages,
getPosts,
getFooterBySlug,
getPageConfigBySlug,
} from '$lib/cms';
import type { NavLinkEntry } from '$lib/cms';
import { ensureTransformedImage } from '$lib/rusty-image';
import {
DEFAULT_SOCIAL_IMAGE_URL,
ROW_RESOLVE,
SITE_NAME,
SOCIAL_IMAGE_TRANSFORM,
} from '$lib/constants';
import { env } from '$env/dynamic/public';
interface NavLink {
href: string;
label: string;
}
interface SocialLink {
href: string;
icon: string;
label?: string;
}
/** Führenden Slash ignorieren (CMS liefert z. B. "slug": "/about"). */
function normalizeSlug(s: string | undefined): string {
return (s ?? '').replace(/^\//, '').trim();
}
function getSlugFromEntry(entry: NavLinkEntry): string {
if (typeof entry === 'string') return entry;
const e = entry as { _slug?: string; slug?: string };
return e.slug ?? e._slug ?? '';
}
export const load: LayoutServerLoad = async ({ locals }) => {
const translations = locals.translations ?? {};
// Header: Navigation laden
let headerLinks: NavLink[] = [];
try {
const nav = await getNavigationByKey(NavigationKeys.header, {
locale: 'de',
resolve: 'links',
});
const rawLinks = nav?.links ?? [];
const [pages, posts] = await Promise.all([getPages(), getPosts()]);
const slugToLabel = new Map<string, string>();
const slugToPageHref = new Map<string, string>();
for (const p of pages) {
const label = p.linkName ?? p.name ?? p.headline ?? p.slug ?? p._slug ?? '';
const canonicalSlug = normalizeSlug(p.slug ?? p._slug) || 'home';
const keySlug = normalizeSlug(p.slug);
const keyUnderscore = normalizeSlug(p._slug);
if (keySlug) {
slugToLabel.set(keySlug, label);
slugToPageHref.set(keySlug, canonicalSlug);
}
if (keyUnderscore && keyUnderscore !== keySlug) {
slugToLabel.set(keyUnderscore, label);
slugToPageHref.set(keyUnderscore, canonicalSlug);
}
}
const slugToPostHref = new Map<string, string>();
for (const p of posts) {
const label = p.linkName ?? p.headline ?? p.slug ?? p._slug ?? '';
const urlSlug = normalizeSlug(p.slug ?? p._slug);
const canonical = urlSlug ? `/post/${encodeURIComponent(urlSlug)}` : '';
const keySlug = normalizeSlug(p.slug);
const keyUnderscore = normalizeSlug(p._slug);
if (keySlug) {
slugToLabel.set(keySlug, label);
slugToPostHref.set(keySlug, canonical);
}
if (keyUnderscore && keyUnderscore !== keySlug) {
slugToLabel.set(keyUnderscore, label);
slugToPostHref.set(keyUnderscore, canonical);
}
}
for (const entry of rawLinks) {
const asObj =
typeof entry === 'object' && entry !== null
? (entry as { url?: string; linkName?: string; name?: string })
: null;
if (asObj?.url) {
const href =
asObj.url.startsWith('/') || asObj.url.startsWith('http')
? asObj.url
: `/${asObj.url.replace(/^\//, '')}`;
const label = asObj.linkName ?? asObj.name ?? asObj.url ?? '';
headerLinks.push({ href, label });
continue;
}
const raw = getSlugFromEntry(entry);
const slug = normalizeSlug(raw) || 'home';
const label = slugToLabel.get(slug) ?? slug;
const postHref = slugToPostHref.get(slug);
const pageSlug = slugToPageHref.get(slug) ?? slug;
const hrefSlug = normalizeSlug(pageSlug) || 'home';
const pageHref =
hrefSlug === 'home'
? '/'
: '/' +
hrefSlug
.split('/')
.filter(Boolean)
.map((seg) => encodeURIComponent(seg))
.join('/');
const href = postHref ?? pageHref;
headerLinks.push({ href, label });
}
} catch {
// CMS nicht erreichbar
}
// Social-Media-Links
let socialLinks: SocialLink[] = [];
try {
const socialNav = await getNavigationByKey(NavigationKeys.socialMedia, {
locale: 'de',
resolve: 'all',
});
const rawSocialLinks = socialNav?.links ?? [];
for (const entry of rawSocialLinks) {
const asObj =
typeof entry === 'object' && entry !== null
? (entry as { url?: string; icon?: string; linkName?: string; name?: string })
: null;
if (!asObj?.url) continue;
const href =
asObj.url.startsWith('/') || asObj.url.startsWith('http')
? asObj.url
: `/${asObj.url.replace(/^\//, '')}`;
const icon = asObj.icon ?? 'mdi:link';
const label = asObj.linkName ?? asObj.name ?? '';
socialLinks.push({ href, icon, label });
}
} catch {
/* Social-Links optional */
}
// Footer laden (mit Row-Resolve)
let footerData = null;
try {
footerData = await getFooterBySlug('footer-main', {
locale: 'de',
resolve: ROW_RESOLVE,
});
} catch {
// CMS nicht erreichbar
}
// Logo und SEO-Templates aus page_config
let logoUrl: string | null = null;
let logoSvgHtml: string | null = null;
let pageConfig = null;
try {
pageConfig =
(await getPageConfigBySlug('page-config-default', {
locale: 'de',
resolve: 'logo',
})) ??
(await getPageConfigBySlug('default', { locale: 'de', resolve: 'logo' }));
const rawLogo = pageConfig?.logo;
const rawSrc =
typeof rawLogo === 'string'
? rawLogo
: (rawLogo as { src?: string } | undefined)?.src ?? null;
if (rawSrc) {
const cmsBase = (env.PUBLIC_CMS_URL || 'http://localhost:3000').replace(/\/$/, '');
// Absolute URL aufbauen
const u = rawSrc.startsWith('//')
? `https:${rawSrc}`
: rawSrc.startsWith('/')
? `${cmsBase}${rawSrc}`
: rawSrc.startsWith('http')
? rawSrc
: `${cmsBase}/api/assets/${rawSrc}`;
const isSvg = u.split('?')[0].toLowerCase().endsWith('.svg');
if (isSvg) {
// SVGs können nicht transformiert werden — direkt verwenden und inline einbetten
logoUrl = u;
try {
const res = await fetch(u);
if (res.ok) {
const text = (await res.text()).trim();
if (text.includes('<svg')) logoSvgHtml = text;
}
} catch { /* Fallback: img mit logoUrl */ }
} else {
logoUrl = await ensureTransformedImage(u, {
height: 56,
fit: 'contain',
format: 'webp',
});
}
}
} catch {
/* Logo optional */
}
const siteName =
(import.meta.env.PUBLIC_SITE_NAME as string | undefined) || SITE_NAME;
const siteBaseUrl = (env.PUBLIC_SITE_URL || '').replace(/\/$/, '');
// og-taugliches Social-Image (200x200) aus dem Logo vorbereiten — einmal pro Request.
let logoSocialImage: string | null = null;
try {
const source = logoUrl ?? DEFAULT_SOCIAL_IMAGE_URL;
const transformed = await ensureTransformedImage(source, SOCIAL_IMAGE_TRANSFORM);
logoSocialImage = transformed.startsWith('/')
? `${siteBaseUrl}${transformed}`
: transformed;
} catch {
logoSocialImage = null;
}
return {
headerLinks,
socialLinks,
footerData,
logoUrl,
logoSvgHtml,
logoSocialImage,
siteName,
siteBaseUrl,
translations,
pageConfig,
seoTitleTemplate: pageConfig?.seoTitle ?? null,
seoDescriptionTemplate: pageConfig?.seoDescription ?? null,
};
};
+172
View File
@@ -0,0 +1,172 @@
<script lang="ts">
import '../app.css';
import '$lib/iconify-offline';
import { page } from '$app/stores';
import Header from '$lib/components/Header.svelte';
import HeaderOverlay from '$lib/components/HeaderOverlay.svelte';
import Footer from '$lib/components/Footer.svelte';
import TopBanner from '$lib/components/TopBanner.svelte';
import TranslationProvider from '$lib/components/TranslationProvider.svelte';
import Breadcrumbs from '$lib/ui/Breadcrumbs.svelte';
import type { LayoutData } from './$types';
import { ensureTransformedImage } from '$lib/rusty-image';
import { DEFAULT_SOCIAL_IMAGE_URL, SOCIAL_IMAGE_TRANSFORM } from '$lib/constants';
let { data, children }: { data: LayoutData; children: import('svelte').Snippet } = $props();
// Per-page SEO data from $page.data
const pageData = $derived($page.data as {
seoTitle?: string;
seoDescription?: string;
socialImage?: string;
breadcrumbItems?: { href?: string; label: string }[];
topBanner?: { banner: unknown; resolvedImages: string[]; headline?: string; subheadline?: string } | null;
});
const baseUrl = $derived(data.siteBaseUrl ?? '');
const canonical = $derived(`${baseUrl}${$page.url.pathname}`);
function applyTemplate(tpl: string | null | undefined, value: string): string {
if (!tpl) return value;
return tpl.replace(/\$1/g, value);
}
const rawSeoTitle = $derived(pageData?.seoTitle ?? data.siteName);
const rawSeoDescription = $derived(pageData?.seoDescription ?? '');
const seoTitle = $derived(applyTemplate(data.seoTitleTemplate, rawSeoTitle));
const seoDescription = $derived(applyTemplate(data.seoDescriptionTemplate, rawSeoDescription));
const socialImageUrl = $derived.by(() => {
const raw = pageData?.socialImage ?? data.logoSocialImage ?? data.logoUrl ?? DEFAULT_SOCIAL_IMAGE_URL;
if (!raw) return '';
if (raw.startsWith('//')) return `https:${raw}`;
if (raw.startsWith('http://') || raw.startsWith('https://')) return raw;
if (raw.startsWith('/')) return `${baseUrl}${raw}`;
return raw;
});
const breadcrumbItems = $derived(pageData?.breadcrumbItems ?? []);
const topBannerData = $derived(pageData?.topBanner ?? null);
const jsonLdScript = $derived(
`<script type="application/ld+json">${JSON.stringify({
'@context': 'https://schema.org',
'@type': 'WebSite',
name: data.siteName,
url: baseUrl,
inLanguage: 'de-DE',
})}<\/script>`,
);
const formbricksAppUrl = String(import.meta.env.PUBLIC_FORMBRICKS_APP_URL ?? '').replace(/\/$/, '');
const formbricksEnvId = String(import.meta.env.PUBLIC_FORMBRICKS_ENVIRONMENT_ID ?? '').trim();
const formbricksScript =
formbricksAppUrl && formbricksEnvId
? `<script>(function(){var appUrl=${JSON.stringify(formbricksAppUrl)};var environmentId=${JSON.stringify(formbricksEnvId)};if(!appUrl||!environmentId)return;var t=document.createElement('script');t.type='text/javascript';t.async=true;t.src=appUrl+'/js/formbricks.umd.cjs';var e=document.getElementsByTagName('script')[0];if(e&&e.parentNode)e.parentNode.insertBefore(t,e);setTimeout(function(){if(window.formbricks&&typeof window.formbricks.setup==='function'){window.formbricks.setup({environmentId:environmentId,appUrl:appUrl});}},500);})();<\/script>`
: '';
</script>
<svelte:head>
<meta name="theme-color" content="#ffffff" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="alternate" type="application/rss+xml" title="Beiträge" href="/posts/rss.xml" />
<link rel="sitemap" href="/sitemap.xml" />
<link rel="icon" href="/favicons/favicon.ico" sizes="any" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicons/favicon-16x16.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicons/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="96x96" href="/favicons/favicon-96x96.png" />
<link rel="icon" type="image/png" sizes="192x192" href="/favicons/android-icon-192x192.png" />
<link rel="apple-touch-icon" sizes="57x57" href="/favicons/apple-icon-57x57.png" />
<link rel="apple-touch-icon" sizes="60x60" href="/favicons/apple-icon-60x60.png" />
<link rel="apple-touch-icon" sizes="72x72" href="/favicons/apple-icon-72x72.png" />
<link rel="apple-touch-icon" sizes="76x76" href="/favicons/apple-icon-76x76.png" />
<link rel="apple-touch-icon" sizes="114x114" href="/favicons/apple-icon-114x114.png" />
<link rel="apple-touch-icon" sizes="120x120" href="/favicons/apple-icon-120x120.png" />
<link rel="apple-touch-icon" sizes="144x144" href="/favicons/apple-icon-144x144.png" />
<link rel="apple-touch-icon" sizes="152x152" href="/favicons/apple-icon-152x152.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/favicons/apple-icon-180x180.png" />
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#2d7a45" />
<meta name="msapplication-TileColor" content="#ffffff" />
<meta name="msapplication-TileImage" content="/favicons/ms-icon-144x144.png" />
<title>{seoTitle}</title>
{#if seoDescription}
<meta name="description" content={seoDescription} />
{/if}
<link rel="canonical" href={canonical} />
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:url" content={canonical} />
<meta property="og:title" content={seoTitle} />
{#if seoDescription}
<meta property="og:description" content={seoDescription} />
{/if}
{#if socialImageUrl}
<meta property="og:image" content={socialImageUrl} />
<meta property="og:image:width" content={String(SOCIAL_IMAGE_TRANSFORM.width)} />
<meta property="og:image:height" content={String(SOCIAL_IMAGE_TRANSFORM.height)} />
{/if}
<meta property="og:locale" content="de_DE" />
<meta property="og:site_name" content={data.siteName} />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:url" content={canonical} />
<meta name="twitter:title" content={seoTitle} />
{#if seoDescription}
<meta name="twitter:description" content={seoDescription} />
{/if}
{#if socialImageUrl}
<meta name="twitter:image" content={socialImageUrl} />
{/if}
<!-- JSON-LD WebSite -->
{@html jsonLdScript}
{#if import.meta.env.PUBLIC_UMAMI_SCRIPT_URL && import.meta.env.PUBLIC_UMAMI_WEBSITE_ID}
<script
defer
src={import.meta.env.PUBLIC_UMAMI_SCRIPT_URL}
data-website-id={import.meta.env.PUBLIC_UMAMI_WEBSITE_ID}
></script>
{/if}
{#if import.meta.env.PUBLIC_ANALYTICS_PM86_SITE_ID}
<script
defer
src="https://analytics.pm86.de/api/script.js"
data-site-id={import.meta.env.PUBLIC_ANALYTICS_PM86_SITE_ID}
></script>
{/if}
{#if formbricksScript}
{@html formbricksScript}
{/if}
</svelte:head>
<TranslationProvider translations={data.translations}>
<div class="flex min-h-screen flex-col text-zinc-900">
<Header
links={data.headerLinks}
socialLinks={data.socialLinks}
logoUrl={data.logoUrl}
logoSvgHtml={data.logoSvgHtml}
translations={data.translations}
/>
<HeaderOverlay />
{#if topBannerData}
<TopBanner
banner={topBannerData.banner as import('$lib/cms').FullwidthBannerEntry | null}
resolvedImages={topBannerData.resolvedImages}
headline={topBannerData.headline}
subheadline={topBannerData.subheadline}
/>
{/if}
<main class="flex-1 min-h-[60em]">
<div class="container-custom pb-12">
{#if breadcrumbItems.length > 0}
<Breadcrumbs items={breadcrumbItems} />
{/if}
{@render children()}
</div>
</main>
<Footer footer={data.footerData} translations={data.translations} />
</div>
</TranslationProvider>
+84
View File
@@ -0,0 +1,84 @@
import type { PageServerLoad } from './$types';
import { getPageBySlug, getPages, getHomePageSlugFromConfig } from '$lib/cms';
import { resolveContentImages, resolveImageUrls } from '$lib/rusty-image';
import {
resolvePostOverviewBlocks,
resolveSearchableTextBlocks,
resolveCalendarBlocks,
} from '$lib/blog-utils';
import { DEFAULT_HOME_PAGE_SLUG, PAGE_RESOLVE, SITE_NAME } from '$lib/constants';
export const load: PageServerLoad = async ({ locals }) => {
const translations = locals.translations ?? {};
let homeSlug = DEFAULT_HOME_PAGE_SLUG;
try {
homeSlug = (await getHomePageSlugFromConfig({ locale: 'de' })) ?? DEFAULT_HOME_PAGE_SLUG;
} catch {
// fallback
}
let page = null;
let pages: Awaited<ReturnType<typeof getPages>> = [];
try {
const [homePage, allPages] = await Promise.all([
getPageBySlug(homeSlug, { locale: 'de', resolve: PAGE_RESOLVE }),
getPages(),
]);
page = homePage;
pages = allPages ?? [];
if (page) {
await resolveContentImages(page);
const tagsMap = await resolvePostOverviewBlocks(page);
await resolveSearchableTextBlocks(page, tagsMap);
await resolveCalendarBlocks(page);
}
} catch {
// CMS nicht erreichbar
}
const siteName = (import.meta.env.PUBLIC_SITE_NAME as string | undefined) || SITE_NAME;
let topBanner: {
banner: import('$lib/cms').FullwidthBannerEntry | null;
resolvedImages: string[];
headline?: string;
subheadline?: string;
} | null = null;
if (page) {
const bannerRaw = (page as { topFullwidthBanner?: unknown }).topFullwidthBanner;
if (bannerRaw && typeof bannerRaw === 'object' && 'image' in bannerRaw) {
const bannerImages = Array.isArray((bannerRaw as { image?: unknown }).image)
? ((bannerRaw as { image?: unknown[] }).image ?? [])
: (bannerRaw as { image?: unknown }).image != null
? [(bannerRaw as { image?: unknown }).image]
: [];
let resolvedImages: string[] = [];
if (bannerImages.length > 0) {
resolvedImages = await resolveImageUrls(bannerImages as string[], {
width: 2000,
height: 750,
fit: 'cover',
format: 'webp',
});
}
topBanner = {
banner: bannerRaw as import('$lib/cms').FullwidthBannerEntry | null,
resolvedImages,
headline: page.headline ?? undefined,
subheadline: page.subheadline ?? undefined,
};
}
}
return {
page,
pages,
translations,
topBanner,
seoTitle: page
? (page.seoTitle ?? page.headline ?? page.linkName ?? siteName)
: siteName,
seoDescription: page ? (page.seoDescription ?? page.subheadline ?? '') : '',
};
};
+37
View File
@@ -0,0 +1,37 @@
<script lang="ts">
import ContentRows from '$lib/components/ContentRows.svelte';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
const page = $derived(data.page);
</script>
{#if page}
{#if !data.topBanner && (page.headline || page.subheadline)}
<div class="mb-6 pt-10 pageTitle">
{#if page.headline}<h1>{page.headline}</h1>{/if}
{#if page.subheadline}<h2>{page.subheadline}</h2>{/if}
</div>
{/if}
<article>
<ContentRows layout={page} translations={data.translations} />
</article>
{:else}
<div class="mb-6 pt-10 pageTitle">
<h1>Pages</h1>
</div>
<ul class="space-y-3 mt-4">
{#each data.pages as p}
<li>
<a
href={'/' + ((p.slug ?? p._slug ?? '').replace(/^\//, '').trim() || '').split('/').filter(Boolean).map(encodeURIComponent).join('/')}
class="block rounded-sm border border-zinc-200 bg-white px-4 py-3 shadow-sm transition hover:border-green-700/30 hover:shadow"
>
<span class="font-medium text-zinc-900">{p.linkName ?? p.name ?? p.slug ?? p._slug}</span>
{#if p.headline}<span class="mt-1 block text-sm text-zinc-500">{p.headline}</span>{/if}
</a>
</li>
{/each}
</ul>
{/if}
+67
View File
@@ -0,0 +1,67 @@
import type { PageServerLoad } from './$types';
import { getPageBySlug } from '$lib/cms';
import { resolveContentImages, resolveImageUrls } from '$lib/rusty-image';
import {
resolvePostOverviewBlocks,
resolveSearchableTextBlocks,
resolveCalendarBlocks,
} from '$lib/blog-utils';
import { PAGE_RESOLVE } from '$lib/constants';
import { error } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ params, locals }) => {
const { slug } = params;
const translations = locals.translations ?? {};
const page = await getPageBySlug(slug, {
locale: 'de',
resolve: PAGE_RESOLVE,
});
if (!page) {
error(404, 'Seite nicht gefunden');
}
await resolveContentImages(page);
const tagsMap = await resolvePostOverviewBlocks(page);
await resolveSearchableTextBlocks(page, tagsMap);
await resolveCalendarBlocks(page);
// Resolve top banner if present
let topBannerResolvedImages: string[] = [];
const bannerRaw = (page as { topFullwidthBanner?: unknown }).topFullwidthBanner;
if (bannerRaw && typeof bannerRaw === 'object' && 'image' in bannerRaw) {
const bannerImages = Array.isArray((bannerRaw as { image?: unknown }).image)
? ((bannerRaw as { image?: unknown[] }).image ?? [])
: (bannerRaw as { image?: unknown }).image != null
? [(bannerRaw as { image?: unknown }).image]
: [];
if (bannerImages.length > 0) {
topBannerResolvedImages = await resolveImageUrls(bannerImages as string[], {
width: 2000,
height: 750,
fit: 'cover',
format: 'webp',
});
}
}
return {
page,
translations,
seoTitle: page.seoTitle ?? page.headline ?? page.linkName ?? slug,
seoDescription: page.seoDescription ?? page.subheadline ?? '',
breadcrumbItems: [
{ href: '/', label: 'Start' },
{ label: page.headline ?? page.linkName ?? page.slug ?? page._slug ?? slug },
],
topBanner: bannerRaw
? {
banner: bannerRaw as import('$lib/cms').FullwidthBannerEntry | null,
resolvedImages: topBannerResolvedImages,
headline: page.headline ?? undefined,
subheadline: page.subheadline ?? undefined,
}
: null,
};
};
+17
View File
@@ -0,0 +1,17 @@
<script lang="ts">
import ContentRows from '$lib/components/ContentRows.svelte';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
{#if !data.topBanner && (data.page.headline || data.page.subheadline)}
<div class="mb-6 pt-10 pageTitle">
{#if data.page.headline}<h1>{data.page.headline}</h1>{/if}
{#if data.page.subheadline}<h2>{data.page.subheadline}</h2>{/if}
</div>
{/if}
<article>
<ContentRows layout={data.page} translations={data.translations} />
</article>
+65
View File
@@ -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' });
};
+97
View File
@@ -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' } },
);
};
+38
View File
@@ -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,
},
});
};
+6
View File
@@ -0,0 +1,6 @@
import type { PageServerLoad } from './$types';
import { redirect } from '@sveltejs/kit';
export const load: PageServerLoad = async () => {
redirect(301, '/posts');
};
@@ -0,0 +1,6 @@
import type { PageServerLoad } from './$types';
import { redirect } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ params }) => {
redirect(301, `/posts/page/${encodeURIComponent(params.page)}`);
};
@@ -0,0 +1,6 @@
import type { PageServerLoad } from './$types';
import { redirect } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ params }) => {
redirect(301, `/posts/tag/${encodeURIComponent(params.tag)}`);
};
+106
View File
@@ -0,0 +1,106 @@
import type { RequestHandler } from './$types';
import { env } from '$env/dynamic/public';
import {
buildCacheKey,
getCachedImage,
cacheImage,
type ImageTransformParams,
} from '$lib/image-cache.server';
const IMMUTABLE_HEADERS = {
'cache-control': 'public, max-age=31536000, immutable',
} as const;
function getCmsBaseUrl(): string {
return (
env.PUBLIC_CMS_URL ||
import.meta.env.PUBLIC_CMS_URL ||
'http://localhost:3000'
).replace(/\/$/, '');
}
/**
* Wenn src kein vollständiger URL ist, wird er als CMS-Asset-Dateiname behandelt
* und zu {CMS_URL}/api/assets/{src} aufgelöst.
* Absolute Pfade (z.B. /api/assets/logo/logo3.svg) werden direkt an die CMS-URL angehängt.
*/
function resolveSourceUrl(src: string): string {
if (src.startsWith('http://') || src.startsWith('https://')) return src;
if (src.startsWith('//')) return `https:${src}`;
if (src.startsWith('/')) return `${getCmsBaseUrl()}${src}`;
return `${getCmsBaseUrl()}/api/assets/${encodeURIComponent(src)}`;
}
export const GET: RequestHandler = async ({ url }) => {
const src = url.searchParams.get('src');
if (!src) {
return new Response('Missing "src" parameter', { status: 400 });
}
const params: ImageTransformParams = {};
const w = url.searchParams.get('w');
const h = url.searchParams.get('h');
const q = url.searchParams.get('q');
const format = url.searchParams.get('format');
const fit = url.searchParams.get('fit');
const ar = url.searchParams.get('ar');
if (w) {
const n = Number(w);
if (Number.isFinite(n)) params.w = n;
}
if (h) {
const n = Number(h);
if (Number.isFinite(n)) params.h = n;
}
if (q) {
const n = Number(q);
if (Number.isFinite(n)) params.q = n;
}
if (format) params.format = format;
if (fit) params.fit = fit;
if (ar) params.ar = ar;
const key = buildCacheKey(src, params);
const cached = await getCachedImage(key, params);
if (cached) {
return new Response(new Uint8Array(cached.buffer), {
headers: { 'content-type': cached.contentType, ...IMMUTABLE_HEADERS },
});
}
const sourceUrl = resolveSourceUrl(src);
const cmsBase = getCmsBaseUrl();
const transformParams = new URLSearchParams();
transformParams.set('url', sourceUrl);
if (params.w != null) transformParams.set('w', String(params.w));
if (params.h != null) transformParams.set('h', String(params.h));
if (params.q != null) transformParams.set('quality', String(params.q));
if (params.format) transformParams.set('format', params.format);
if (params.fit) transformParams.set('fit', params.fit);
if (params.ar) transformParams.set('ar', params.ar);
const transformUrl = `${cmsBase}/api/transform?${transformParams.toString()}`;
let res: Response;
try {
res = await fetch(transformUrl);
} catch (err) {
console.error('[cms-images] CMS fetch failed:', err);
return new Response('CMS not reachable', { status: 502 });
}
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';
const result = await cacheImage(key, buffer, contentType);
return new Response(new Uint8Array(result.buffer), {
headers: { 'content-type': result.contentType, ...IMMUTABLE_HEADERS },
});
};
+7
View File
@@ -0,0 +1,7 @@
import type { PageServerLoad } from './$types';
import { redirect } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ params }) => {
const { slug } = params;
redirect(301, `/post/${encodeURIComponent(slug)}`);
};
+128
View File
@@ -0,0 +1,128 @@
import type { PageServerLoad } from './$types';
import { getPostBySlug, getPageConfigBySlug, getPageConfigs } from '$lib/cms';
import {
resolveContentImages,
ensureTransformedImage,
processMarkdownHtmlImages,
} from '$lib/rusty-image';
import {
resolvePostTagsInPost,
resolvePostOverviewBlocks,
resolveSearchableTextBlocks,
getPostImageUrl,
formatPostDate,
} from '$lib/blog-utils';
import { POST_RESOLVE } from '$lib/constants';
import { error } from '@sveltejs/kit';
import { marked } from 'marked';
marked.setOptions({ gfm: true });
export const load: PageServerLoad = async ({ params, locals, url }) => {
const { slug } = params;
const translations = locals.translations ?? {};
const post = await getPostBySlug(slug, {
locale: 'de',
resolve: POST_RESOLVE,
});
if (!post) {
error(404, 'Beitrag nicht gefunden');
}
await resolveContentImages(post);
const tagsMap = await resolvePostOverviewBlocks(post);
await resolveSearchableTextBlocks(post, tagsMap);
resolvePostTagsInPost(post, tagsMap);
const rawPostImageUrl = getPostImageUrl(post.postImage);
const postImageUrl = rawPostImageUrl
? await ensureTransformedImage(rawPostImageUrl, {
width: 150,
height: 200,
fit: 'cover',
})
: null;
const postDate = formatPostDate(post.created);
const postContent = (post as { content?: string }).content;
let contentHtml =
typeof postContent === 'string' && postContent.trim()
? (marked.parse(postContent) as string)
: '';
if (contentHtml) {
contentHtml = await processMarkdownHtmlImages(contentHtml);
}
const hasRowContent =
((post as { row1Content?: unknown[] }).row1Content?.length ?? 0) > 0 ||
((post as { row2Content?: unknown[] }).row2Content?.length ?? 0) > 0 ||
((post as { row3Content?: unknown[] }).row3Content?.length ?? 0) > 0;
const isEvent = !!(post as { isEvent?: boolean }).isEvent;
const eventDateRaw = (post as { eventDate?: string }).eventDate ?? null;
const eventDateLabel =
isEvent && eventDateRaw
? new Date(eventDateRaw).toLocaleDateString('de-DE', {
weekday: 'long',
day: '2-digit',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
: null;
const eventLocation = isEvent
? ((post as { eventLocation?: { text?: string; lat?: number; lng?: number } }).eventLocation ?? null)
: null;
const hasCoords = !!(eventLocation?.lat && eventLocation?.lng);
const _lat = eventLocation?.lat ?? 0;
const _lng = eventLocation?.lng ?? 0;
const mapEmbedUrl = hasCoords
? `https://www.openstreetmap.org/export/embed.html?bbox=${_lng - 0.01},${_lat - 0.007},${_lng + 0.01},${_lat + 0.007}&layer=mapnik&marker=${_lat},${_lng}`
: null;
const mapLinkUrl = hasCoords
? `https://www.openstreetmap.org/?mlat=${eventLocation!.lat}&mlon=${eventLocation!.lng}#map=15/${eventLocation!.lat}/${eventLocation!.lng}`
: eventLocation?.text
? `https://www.openstreetmap.org/search?query=${encodeURIComponent(eventLocation.text)}`
: null;
const pageConfig =
(await getPageConfigBySlug('page-config-default', { locale: 'de' })) ??
(await getPageConfigs({ locale: 'de', per_page: 1 }))[0] ??
null;
const commentSlotRaw =
(pageConfig as { postCommentSlot?: string } | null)?.postCommentSlot ?? '';
const commentSlot = commentSlotRaw
.replace(/\{\{PAGE_ID\}\}/g, slug)
.replace(/\{\{PAGE_URL\}\}/g, url.href)
.replace(/\{\{PAGE_TITLE\}\}/g, post.headline ?? post.linkName ?? slug);
const showCommentSection = (post as { showCommentSection?: boolean }).showCommentSection !== false;
return {
post,
postImageUrl,
postDate,
contentHtml,
hasRowContent,
isEvent,
eventDateRaw,
eventDateLabel,
eventLocationText: eventLocation?.text ?? null,
mapEmbedUrl,
mapLinkUrl,
commentSlot: showCommentSection ? commentSlot : '',
translations,
seoTitle: post.seoTitle ?? post.headline ?? post.linkName ?? slug,
seoDescription: post.seoDescription ?? post.subheadline ?? post.excerpt ?? '',
socialImage: postImageUrl ?? undefined,
breadcrumbItems: [
{ href: '/', label: 'Start' },
{ href: '/posts', label: 'Beiträge' },
{ label: post.headline ?? post.linkName ?? post.slug ?? post._slug ?? slug },
],
};
};
+87
View File
@@ -0,0 +1,87 @@
<script lang="ts">
import ContentRows from '$lib/components/ContentRows.svelte';
import Tag from '$lib/components/Tag.svelte';
import Tags from '$lib/components/Tags.svelte';
import EventBadges from '$lib/components/EventBadges.svelte';
import EventMap from '$lib/components/EventMap.svelte';
import Accordion from '$lib/components/Accordion.svelte';
import { t, T } from '$lib/translations';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
<svelte:head>
<title>{data.seoTitle}</title>
{#if data.seoDescription}
<meta name="description" content={data.seoDescription} />
{/if}
{#if data.postImageUrl}
<meta property="og:image" content={data.postImageUrl} />
{/if}
</svelte:head>
{#if data.isEvent && (data.eventDateLabel || data.eventLocationText)}
<div class="mb-6">
<EventBadges
eventDate={data.eventDateRaw}
locationText={data.eventLocationText}
locationHref={(data.mapEmbedUrl || data.mapLinkUrl) ? '#map-section' : null}
/>
</div>
{/if}
<div class="md:flex gap-2 items-end">
{#if data.postImageUrl}
<div class="overflow-hidden inline-block my-4 border border-white self-start ring-1 ring-black/50 shadow-lg">
<img
src={data.postImageUrl}
alt=""
class="block object-cover"
width="150"
height="200"
style="aspect-ratio: 1.3333"
loading="eager"
/>
</div>
{/if}
<div class="mt-2 mb-4! md:mb-1 min-w-0 gap-2">
{#if data.postDate}
<div class="flex flex-nowrap gap-2 items-center shrink-0 mb-2">
<Tag label={data.postDate} variant="date" />
</div>
{/if}
<div class="flex flex-nowrap gap-2 items-center shrink-0 p-1 pb-4 -ml-1 overflow-x-auto overflow-y-hidden">
<Tags tags={data.post.postTag ?? []} variant="green" tagBase="/posts/tag" />
</div>
</div>
</div>
<article class="mt-8">
{#if data.contentHtml}
<div class="content markdown max-w-none prose prose-zinc">
{@html data.contentHtml}
</div>
{/if}
{#if data.hasRowContent}
<ContentRows layout={data.post} translations={data.translations} />
{/if}
</article>
{#if data.isEvent && (data.mapEmbedUrl || data.mapLinkUrl)}
<EventMap
embedUrl={data.mapEmbedUrl}
linkUrl={data.mapLinkUrl}
locationText={data.eventLocationText}
label={t(data.translations, T.post_map)}
translations={data.translations}
/>
{/if}
{#if data.commentSlot}
<Accordion label={t(data.translations, T.post_comments)} class="mt-6">
<div class="p-8 border border-zinc-200 rounded-lg">
{@html data.commentSlot}
</div>
</Accordion>
{/if}
+19
View File
@@ -0,0 +1,19 @@
import type { PageServerLoad } from './$types';
import { loadPostsList } from '$lib/blog-utils';
import { t, T } from '$lib/translations';
export const load: PageServerLoad = async ({ locals }) => {
const translations = locals.translations ?? {};
const result = await loadPostsList({ page: 1 });
return {
...result,
translations,
seoTitle: `${t(translations, T.posts_title)} Aktuelles`,
seoDescription: 'Übersicht aller Beiträge und Meldungen.',
breadcrumbItems: [
{ href: '/', label: t(translations, T.nav_start) },
{ label: t(translations, T.nav_posts) },
],
};
};
+39
View File
@@ -0,0 +1,39 @@
<script lang="ts">
import BlogOverview from '$lib/components/BlogOverview.svelte';
import { t, T } from '$lib/translations';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
<svelte:head>
<title>{data.seoTitle}</title>
{#if data.seoDescription}
<meta name="description" content={data.seoDescription} />
{/if}
</svelte:head>
<div class="mb-6 pt-10 pageTitle">
<h1>{t(data.translations, T.posts_title)}</h1>
<h2>{t(data.translations, T.posts_subtitle)}</h2>
</div>
<div class="content mt-8">
{#if data.cmsError}
<div class="rounded-sm border border-amber-200 bg-amber-50 p-4 text-amber-800">
<strong>{t(data.translations, T.posts_cms_error)}</strong> {data.cmsError}
</div>
{:else}
<BlogOverview
posts={data.posts}
tags={data.tags}
activeTag={data.activeTag}
currentPage={data.currentPage}
totalPages={data.totalPages}
totalPosts={data.totalPosts}
basePath="/posts"
translations={data.translations}
upcomingEvents={data.upcomingEvents}
searchIndex={data.searchIndex}
/>
{/if}
</div>
@@ -0,0 +1,20 @@
import type { PageServerLoad } from './$types';
import { loadPostsList } from '$lib/blog-utils';
import { t, T } from '$lib/translations';
export const load: PageServerLoad = async ({ params, locals }) => {
const translations = locals.translations ?? {};
const page = Math.max(1, parseInt(params.page, 10) || 1);
const result = await loadPostsList({ page });
return {
...result,
translations,
seoTitle: `${t(translations, T.posts_title)} Seite ${result.currentPage}`,
seoDescription: 'Übersicht aller Beiträge und Meldungen.',
breadcrumbItems: [
{ href: '/', label: t(translations, T.nav_start) },
{ label: t(translations, T.nav_posts) },
],
};
};
+39
View File
@@ -0,0 +1,39 @@
<script lang="ts">
import BlogOverview from '$lib/components/BlogOverview.svelte';
import { t, T } from '$lib/translations';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
<svelte:head>
<title>{data.seoTitle}</title>
{#if data.seoDescription}
<meta name="description" content={data.seoDescription} />
{/if}
</svelte:head>
<div class="mb-6 pt-10 pageTitle">
<h1>{t(data.translations, T.posts_title)}</h1>
<h2>{t(data.translations, T.posts_subtitle)}</h2>
</div>
<div class="content mt-8">
{#if data.cmsError}
<div class="rounded-sm border border-amber-200 bg-amber-50 p-4 text-amber-800">
<strong>{t(data.translations, T.posts_cms_error)}</strong> {data.cmsError}
</div>
{:else}
<BlogOverview
posts={data.posts}
tags={data.tags}
activeTag={data.activeTag}
currentPage={data.currentPage}
totalPages={data.totalPages}
totalPosts={data.totalPosts}
basePath="/posts"
translations={data.translations}
upcomingEvents={data.upcomingEvents}
searchIndex={data.searchIndex}
/>
{/if}
</div>
+72
View File
@@ -0,0 +1,72 @@
import type { RequestHandler } from './$types';
import { env } from '$env/dynamic/public';
import { getPosts } from '$lib/cms';
import { filterHiddenPosts, sortPostsByDate, postHref } from '$lib/blog-utils';
import { SITE_NAME } from '$lib/constants';
function escapeXml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
export const GET: RequestHandler = async ({ url }) => {
const site =
(env.PUBLIC_SITE_URL || url.origin).replace(/\/$/, '') || url.origin;
const siteName = env.PUBLIC_SITE_NAME || SITE_NAME;
let posts: Awaited<ReturnType<typeof getPosts>> = [];
try {
posts = filterHiddenPosts(sortPostsByDate(await getPosts()));
} catch {
posts = [];
}
const items = posts
.map((p) => {
const created =
(p as { created?: string; _created?: string }).created ??
(p as { _created?: string })._created ??
'';
const pubDate = created ? new Date(created) : null;
const pubDateStr =
pubDate && !Number.isNaN(pubDate.getTime()) ? pubDate.toUTCString() : '';
const title = escapeXml(p.headline ?? p.slug ?? '');
const desc = escapeXml((p as { excerpt?: string }).excerpt ?? p.subheadline ?? '');
const link = `${site}${postHref(p)}`;
return [
'<item>',
`<title>${title}</title>`,
`<link>${link}</link>`,
`<guid isPermaLink="true">${link}</guid>`,
pubDateStr ? `<pubDate>${pubDateStr}</pubDate>` : '',
`<description>${desc}</description>`,
'</item>',
]
.filter(Boolean)
.join('');
})
.join('');
const body = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>${escapeXml(siteName)} Beiträge</title>
<description>Aktuelles zur Windkraft, Gesundheit und Region.</description>
<link>${site}</link>
<language>de-de</language>
<atom:link href="${site}/posts/rss.xml" rel="self" type="application/rss+xml" />
${items}
</channel>
</rss>`;
return new Response(body, {
headers: {
'Content-Type': 'application/rss+xml; charset=utf-8',
'Cache-Control': 'public, max-age=300',
},
});
};
@@ -0,0 +1,20 @@
import type { PageServerLoad } from './$types';
import { loadPostsList } from '$lib/blog-utils';
import { t, T } from '$lib/translations';
export const load: PageServerLoad = async ({ params, locals }) => {
const translations = locals.translations ?? {};
const result = await loadPostsList({ tagSlug: params.tag, page: 1 });
return {
...result,
translations,
seoTitle: `${t(translations, T.posts_title)} ${result.tagName}`,
seoDescription: `Beiträge zum Thema ${result.tagName}.`,
breadcrumbItems: [
{ href: '/', label: t(translations, T.nav_start) },
{ href: '/posts', label: t(translations, T.nav_posts) },
{ label: result.tagName ?? '' },
],
};
};
+39
View File
@@ -0,0 +1,39 @@
<script lang="ts">
import BlogOverview from '$lib/components/BlogOverview.svelte';
import { t, T } from '$lib/translations';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
<svelte:head>
<title>{data.seoTitle}</title>
{#if data.seoDescription}
<meta name="description" content={data.seoDescription} />
{/if}
</svelte:head>
<div class="mb-6 pt-10 pageTitle">
<h1>{t(data.translations, T.posts_title)}</h1>
<h2>{t(data.translations, T.posts_tag_subtitle, { tag: data.tagName ?? '' })}</h2>
</div>
<div class="content mt-8">
{#if data.cmsError}
<div class="rounded-sm border border-amber-200 bg-amber-50 p-4 text-amber-800">
<strong>{t(data.translations, T.posts_cms_error)}</strong> {data.cmsError}
</div>
{:else}
<BlogOverview
posts={data.posts}
tags={data.tags}
activeTag={data.activeTag}
currentPage={data.currentPage}
totalPages={data.totalPages}
totalPosts={data.totalPosts}
basePath="/posts"
translations={data.translations}
upcomingEvents={data.upcomingEvents}
searchIndex={data.searchIndex}
/>
{/if}
</div>
@@ -0,0 +1,21 @@
import type { PageServerLoad } from './$types';
import { loadPostsList } from '$lib/blog-utils';
import { t, T } from '$lib/translations';
export const load: PageServerLoad = async ({ params, locals }) => {
const translations = locals.translations ?? {};
const page = Math.max(1, parseInt(params.page, 10) || 1);
const result = await loadPostsList({ tagSlug: params.tag, page });
return {
...result,
translations,
seoTitle: `${t(translations, T.posts_title)} ${result.tagName} Seite ${result.currentPage}`,
seoDescription: `Beiträge zum Thema ${result.tagName}.`,
breadcrumbItems: [
{ href: '/', label: t(translations, T.nav_start) },
{ href: '/posts', label: t(translations, T.nav_posts) },
{ href: `/posts/tag/${encodeURIComponent(params.tag)}`, label: result.tagName ?? '' },
],
};
};
@@ -0,0 +1,39 @@
<script lang="ts">
import BlogOverview from '$lib/components/BlogOverview.svelte';
import { t, T } from '$lib/translations';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
<svelte:head>
<title>{data.seoTitle}</title>
{#if data.seoDescription}
<meta name="description" content={data.seoDescription} />
{/if}
</svelte:head>
<div class="mb-6 pt-10 pageTitle">
<h1>{t(data.translations, T.posts_title)}</h1>
<h2>{t(data.translations, T.posts_tag_subtitle, { tag: data.tagName ?? '' })}</h2>
</div>
<div class="content mt-8">
{#if data.cmsError}
<div class="rounded-sm border border-amber-200 bg-amber-50 p-4 text-amber-800">
<strong>{t(data.translations, T.posts_cms_error)}</strong> {data.cmsError}
</div>
{:else}
<BlogOverview
posts={data.posts}
tags={data.tags}
activeTag={data.activeTag}
currentPage={data.currentPage}
totalPages={data.totalPages}
totalPosts={data.totalPosts}
basePath="/posts"
translations={data.translations}
upcomingEvents={data.upcomingEvents}
searchIndex={data.searchIndex}
/>
{/if}
</div>
+20
View File
@@ -0,0 +1,20 @@
import type { RequestHandler } from './$types';
import { env } from '$env/dynamic/public';
export const GET: RequestHandler = ({ url }) => {
const base =
(env.PUBLIC_SITE_URL || url.origin).replace(/\/$/, '') || url.origin;
const body = [
'User-agent: *',
'Allow: /',
'',
`Sitemap: ${base}/sitemap.xml`,
'',
].join('\n');
return new Response(body, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'public, max-age=3600',
},
});
};
+111
View File
@@ -0,0 +1,111 @@
import type { RequestHandler } from './$types';
import { env } from '$env/dynamic/public';
import { getPages, getPosts } from '$lib/cms';
import { filterHiddenPosts, postHref } from '$lib/blog-utils';
function normalizeSlug(s: string | undefined): string {
return (s ?? '').replace(/^\//, '').trim();
}
function pageHref(canonical: string): string {
if (!canonical || canonical === 'home') return '/';
return (
'/' +
canonical
.split('/')
.filter(Boolean)
.map((seg) => encodeURIComponent(seg))
.join('/')
);
}
function escapeXml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
function toIsoDate(v: string | undefined): string | null {
if (!v) return null;
const d = new Date(v);
return Number.isNaN(d.getTime()) ? null : d.toISOString();
}
export const GET: RequestHandler = async ({ url }) => {
const site =
(env.PUBLIC_SITE_URL || url.origin).replace(/\/$/, '') || url.origin;
const urls: { loc: string; lastmod?: string; changefreq?: string; priority?: string }[] = [
{ loc: `${site}/`, changefreq: 'daily', priority: '1.0' },
{ loc: `${site}/posts`, changefreq: 'daily', priority: '0.8' },
{ loc: `${site}/blog`, changefreq: 'daily', priority: '0.8' },
];
try {
const pages = await getPages();
for (const p of pages) {
const slug = normalizeSlug(p.slug ?? p._slug);
if (!slug) continue;
const href = pageHref(slug);
if (href === '/') continue;
const lastmod = toIsoDate(
(p as { updated?: string; _updated?: string; created?: string }).updated ??
(p as { _updated?: string })._updated ??
(p as { created?: string }).created,
);
urls.push({
loc: `${site}${href}`,
...(lastmod ? { lastmod } : {}),
changefreq: 'weekly',
priority: '0.6',
});
}
} catch {
/* CMS down */
}
try {
const posts = filterHiddenPosts(await getPosts());
for (const p of posts) {
const href = postHref(p);
if (!href) continue;
const lastmod = toIsoDate(
(p as { updated?: string; _updated?: string; created?: string }).updated ??
(p as { _updated?: string })._updated ??
(p as { created?: string }).created,
);
urls.push({
loc: `${site}${href}`,
...(lastmod ? { lastmod } : {}),
changefreq: 'monthly',
priority: '0.5',
});
}
} catch {
/* CMS down */
}
const body = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls
.map(
(u) =>
`<url><loc>${escapeXml(u.loc)}</loc>` +
(u.lastmod ? `<lastmod>${u.lastmod}</lastmod>` : '') +
(u.changefreq ? `<changefreq>${u.changefreq}</changefreq>` : '') +
(u.priority ? `<priority>${u.priority}</priority>` : '') +
`</url>`,
)
.join('\n')}
</urlset>`;
return new Response(body, {
headers: {
'Content-Type': 'application/xml; charset=utf-8',
'Cache-Control': 'public, max-age=3600',
},
});
};