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,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>
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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 ?? '') : '',
|
||||
};
|
||||
};
|
||||
@@ -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}
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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)}`);
|
||||
};
|
||||
@@ -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 },
|
||||
});
|
||||
};
|
||||
@@ -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)}`);
|
||||
};
|
||||
@@ -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 },
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -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}
|
||||
@@ -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) },
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -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) },
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -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,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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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 ?? '' },
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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',
|
||||
},
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user