Files
windwiderstand/src/routes/[...slug]/+page.server.ts
T
Peter Meier d9519a867e
Deploy / verify (push) Successful in 1m3s
Deploy / deploy (push) Successful in 1m47s
perf: LCP-Fix, Banner-Crops, SSR-Cache, Bulk-Windkarte
- TopBanner: JS-Fade-Gate entfernt (opacity-0 bis Hydration schob LCP
  von ~3s auf ~8.6s) — Bild malt jetzt progressiv über den LQIP
- buildBannerResponsive: Breakpoint-Varianten (1/1, 16/9, 24/9) mit
  media-Attributen, quality 60; CMS braucht explizites h neben ar,
  sonst kein Crop (Mobile bekam 1.92:1 unscharf hochskaliert)
- Layout-Preload: ein Link pro Breakpoint-Variante (WebP)
- Layout-Bootstrap-Batch + Logo-SVG-Fetch TTL-gecacht (liefen pro
  SSR-Request); invalidateCollection versteht CSV-Key-Heads
- WindkarteBlock: 40 Einzel-Fetches -> 1 List-Request mit Filter
- app.html: preconnect cms.pm86.de, api.iconify.design, analytics
- u-url Microformat: echte canonicalUrl statt href="#" (SEO-Audit)
- Icon-Subset: 'cloud' ergänzt (kein Runtime-Fetch an iconify mehr)
- StrommixBlock: nutzloses transition-colors entfernt (CLS-Audit)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 09:50:01 +02:00

162 lines
5.9 KiB
TypeScript

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,
};
};