d9519a867e
- 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>
162 lines
5.9 KiB
TypeScript
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,
|
|
};
|
|
};
|