import type { PageServerLoad } from './$types'; import { getPageBySlug, getCommentCounts } from '$lib/cms'; import { extractCmsImageField, ensureTransformedImage, resolveContentImages, resolveImageUrls, } from '$lib/rusty-image'; import { buildBannerResponsive, warmResponsiveImage, type ResponsiveImage } from '$lib/rusty-image.server'; import { resolvePostOverviewBlocks, resolveSearchableTextBlocks, resolveCalendarBlocks, resolveDeadlineBannerBlocks, resolveStellingnahmeGeneratorBlocks, resolveAdressbuchBlocks, } from '$lib/blog-utils'; import { PAGE_RESOLVE, POST_SOCIAL_IMAGE_TRANSFORM } from '$lib/constants'; import { env } from '$env/dynamic/public'; import { error } from '@sveltejs/kit'; import { sanitizeBlocks } from '$lib/sanitize-blocks.server'; function focalToCss(focal: { x: number; y: number } | undefined): string | null { if (!focal) return null; return `${(focal.x * 100).toFixed(2)}% ${(focal.y * 100).toFixed(2)}%`; } export const load: PageServerLoad = async ({ params, locals, fetch, url, setHeaders }) => { const { slug } = params; const translations = locals.translations ?? {}; const preview = url.searchParams.get('preview') ?? undefined; const previewDraft = url.searchParams.get('preview_draft') ?? undefined; if (preview || previewDraft) { setHeaders({ 'cache-control': 'private, no-store, max-age=0', 'x-robots-tag': 'noindex, nofollow', }); } else { // Public Pages: Caddy/CDN + Browser cachen 60s, stale-while-revalidate // erlaubt 10min Sofort-Lieferung während im Hintergrund neu geholt wird. // CMS-Mutationen lösen Webhook → Frontend-Content-Cache-Purge aus, der // Edge-Cache läuft per TTL aus (kurze Inkonsistenz akzeptabel). setHeaders({ 'cache-control': 'public, s-maxage=60, stale-while-revalidate=600', }); } const page = await getPageBySlug(slug, { locale: 'de', resolve: PAGE_RESOLVE, // Hard-cap gegen Cycle-Surprises (page → image → img-Asset reicht in 3 // Levels). Kein Body-Stripping hier — Page-Detail braucht alle Felder. depth: 3, preview, previewDraft, }); if (!page) { error(404, 'Seite nicht gefunden'); } // resolveContentImages baut nur URLs zusammen (kein I/O) und mutiert den // Tree — vor den Block-Resolvern erledigen, die danach gegen mutierte // Blocks arbeiten. await resolveContentImages(page); // PostOverview→SearchableText hat Daten-Abhängigkeit (tagsMap). Calendar, // DeadlineBanner und StellingnahmeGenerator sind unabhängig — sofort // parallel starten, nicht erst nach PostOverview warten. const postOverviewPromise = resolvePostOverviewBlocks(page); const independentResolvers = Promise.all([ resolveCalendarBlocks(page), resolveDeadlineBannerBlocks(page), resolveStellingnahmeGeneratorBlocks(page), resolveAdressbuchBlocks(page), ]); const tagsMap = await postOverviewPromise; await Promise.all([ resolveSearchableTextBlocks(page, tagsMap), independentResolvers, ]); sanitizeBlocks(page); // Top-Bild: bevorzugt neues inline-Feld `topImage`, sonst Legacy via // topFullwidthBanner-Referenz. Banner-Bild ist auch Social-Image-Quelle. let topBannerResolvedImages: string[] = []; let topBannerResponsive: ResponsiveImage | null = null; let topBannerFocalCss: string | null = null; let pageSocialImageAbs: string | null = null; const siteBaseUrl = (env.PUBLIC_SITE_URL || '').replace(/\/$/, ''); const topImageRaw = (page as { topImage?: unknown }).topImage; const bannerRaw = (page as { topFullwidthBanner?: unknown }).topFullwidthBanner; const topImageSource: unknown = topImageRaw ?? (bannerRaw && typeof bannerRaw === 'object' && 'image' in bannerRaw ? (bannerRaw as { image?: unknown }).image : undefined); if (topImageSource) { const imageArr = Array.isArray(topImageSource) ? topImageSource : [topImageSource]; if (imageArr.length > 0) { const primary = extractCmsImageField(imageArr[0]); if (primary?.url) { topBannerFocalCss = focalToCss(primary.focal); topBannerResponsive = await buildBannerResponsive(primary.url, primary.focal); const socialRel = ensureTransformedImage(primary.url, { ...POST_SOCIAL_IMAGE_TRANSFORM, focalX: primary.focal?.x, focalY: primary.focal?.y, }); pageSocialImageAbs = socialRel.startsWith('/') ? `${siteBaseUrl}${socialRel}` : socialRel; } topBannerResolvedImages = await resolveImageUrls(imageArr as string[], { width: 2000, height: 750, fit: 'cover', format: 'webp', }); warmResponsiveImage(topBannerResponsive, fetch); } } const commentsEnabled = !!(page as { commentsEnabled?: boolean }).commentsEnabled; const commentPageId = commentsEnabled ? (page._slug ?? slug) : null; const commentCount = commentPageId ? (await getCommentCounts([commentPageId]))[commentPageId] ?? 0 : 0; return { page, translations, commentPageId, commentCount, preview: !!preview, previewDraft: !!previewDraft, seoTitle: page.seoTitle ?? page.headline ?? page.linkName ?? slug, seoDescription: page.seoDescription ?? page.subheadline ?? '', socialImage: pageSocialImageAbs ?? undefined, robots: (page as { seoMetaRobots?: string }).seoMetaRobots ?? undefined, keywords: (page as { seoKeywords?: string }).seoKeywords ?? undefined, breadcrumbItems: [ { href: '/', label: 'Start' }, { label: page.headline ?? page.linkName ?? page.slug ?? page._slug ?? slug }, ], topBanner: topImageSource ? { banner: (bannerRaw as import('$lib/cms').FullwidthBannerEntry | null) ?? null, resolvedImages: topBannerResolvedImages, responsive: topBannerResponsive, focalCss: topBannerFocalCss, headline: page.headline ?? undefined, subheadline: page.subheadline ?? undefined, } : null, }; };