From d9519a867ec81d28c4b23bf5c13722812f0a51b8 Mon Sep 17 00:00:00 2001 From: Peter Meier Date: Fri, 12 Jun 2026 09:50:01 +0200 Subject: [PATCH] perf: LCP-Fix, Banner-Crops, SSR-Cache, Bulk-Windkarte MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- scripts/generate-icons.mjs | 2 + src/app.html | 3 + src/lib/cms.ts | 10 ++- src/lib/components/TopBanner.svelte | 32 ++------ .../components/blocks/StrommixBlock.svelte | 2 +- .../components/blocks/WindkarteBlock.svelte | 27 +++---- src/lib/iconify-mdi-subset.generated.json | 3 + src/lib/rusty-image.server.ts | 73 ++++++++++++++++++- src/routes/+layout.server.ts | 30 ++++++-- src/routes/+layout.svelte | 41 ++++++++--- src/routes/+page.server.ts | 13 +--- src/routes/[...slug]/+page.server.ts | 13 +--- src/routes/post/[slug]/+page.svelte | 4 +- 13 files changed, 164 insertions(+), 89 deletions(-) diff --git a/scripts/generate-icons.mjs b/scripts/generate-icons.mjs index 0cb3d3b..31d2ef7 100644 --- a/scripts/generate-icons.mjs +++ b/scripts/generate-icons.mjs @@ -75,6 +75,8 @@ const EXTRA_ICONS = [ 'tune-variant', 'tune', 'check-decagram', + // CMS-Content (Social-Link o.ä.): tauchte als Runtime-Fetch in Lighthouse auf + 'cloud', ]; const STATIC_PATTERN = /icon="mdi:([a-z0-9-]+)"/g; diff --git a/src/app.html b/src/app.html index 425c4ac..ce39be1 100644 --- a/src/app.html +++ b/src/app.html @@ -3,6 +3,9 @@ + + + %sveltekit.head% diff --git a/src/lib/cms.ts b/src/lib/cms.ts index 9e30baa..3bbd962 100644 --- a/src/lib/cms.ts +++ b/src/lib/cms.ts @@ -90,9 +90,11 @@ function ttlFor(collection: string): number { /** * Cached fetch mit TTL + in-flight dedup. Key-Format: "::". - * Bei Fehler wird nichts gecacht. + * Multi-Collection-Einträge (z.B. Layout-Bootstrap-Batch) nutzen eine CSV-Liste + * als Key-Head: "navigation,footer:op:" — invalidateCollection purged dann bei + * Mutation jeder gelisteten Collection. Bei Fehler wird nichts gecacht. */ -async function cached( +export async function cached( collection: string, key: string, fn: () => Promise, @@ -122,7 +124,9 @@ async function cached( export function invalidateCollection(collection: string): number { let n = 0; for (const k of cache.keys()) { - if (k.startsWith(`${collection}:`)) { + const sep = k.indexOf(':'); + if (sep === -1) continue; + if (k.slice(0, sep).split(',').includes(collection)) { cache.delete(k); n++; } diff --git a/src/lib/components/TopBanner.svelte b/src/lib/components/TopBanner.svelte index d4bee27..c27b005 100644 --- a/src/lib/components/TopBanner.svelte +++ b/src/lib/components/TopBanner.svelte @@ -8,7 +8,7 @@ import type { FullwidthBannerEntry } from "$lib/cms"; interface ResponsiveBannerImage { - sources: { type: string; srcset: string }[]; + sources: { type: string; srcset: string; media?: string }[]; fallback: string; width: number; height: number; @@ -79,18 +79,9 @@ const showBanner = $derived(hasImage || hasText || hasPageTitle); const altText = $derived(banner?.headline ?? headline ?? ""); - let imgEl = $state(null); - let imgLoaded = $state(false); - - const currentSrc = $derived(responsive?.fallback ?? resolvedImages[0] ?? ""); - $effect(() => { - // reset on src change, then probe cache - void currentSrc; - imgLoaded = false; - queueMicrotask(() => { - if (imgEl?.complete && imgEl.naturalWidth > 0) imgLoaded = true; - }); - }); + // Kein JS-gesteuertes Fade-in: `opacity-0` im SSR-HTML hält das Banner bis + // nach der Hydration unsichtbar und schob den LCP von ~3s auf ~8s. Das Bild + // malt jetzt progressiv über den LQIP-Background — sichtbar ab erstem Paint. {#if showBanner} @@ -103,23 +94,19 @@ {#if hasResponsive && responsive} {#key responsive.fallback} - {#each responsive.sources as s (s.type)} - + {#each responsive.sources as s (s.media ? `${s.type}|${s.media}` : s.type)} + {/each} {altText} (imgLoaded = true)} /> {/key} @@ -128,15 +115,12 @@ {altText} (imgLoaded = true)} /> {/key} {/if} diff --git a/src/lib/components/blocks/StrommixBlock.svelte b/src/lib/components/blocks/StrommixBlock.svelte index 762ee23..11c8187 100644 --- a/src/lib/components/blocks/StrommixBlock.svelte +++ b/src/lib/components/blocks/StrommixBlock.svelte @@ -375,7 +375,7 @@
{#if !live} diff --git a/src/lib/components/blocks/WindkarteBlock.svelte b/src/lib/components/blocks/WindkarteBlock.svelte index 5b9b53a..3340afd 100644 --- a/src/lib/components/blocks/WindkarteBlock.svelte +++ b/src/lib/components/blocks/WindkarteBlock.svelte @@ -69,22 +69,17 @@ let areas = $state([]); .map((a) => (typeof a === "string" ? a : a._slug)) .filter(Boolean); - if (slugs.length > 0) { - const results = await Promise.all( - slugs.map((slug) => - fetch(`${CMS_BASE}/api/content/wind_area/${slug}`, { - signal: AbortSignal.timeout(6_000), - }).then((r) => (r.ok ? r.json() : null)) - ) - ); - areas = results.filter((r): r is WindArea => r !== null); - } else { - const res = await fetch(`${CMS_BASE}/api/content/wind_area?_limit=100`, { - signal: AbortSignal.timeout(6_000), - }); - if (res.ok) { - const data = await res.json(); - areas = data.items ?? []; + const res = await fetch(`${CMS_BASE}/api/content/wind_area?_limit=200`, { + signal: AbortSignal.timeout(6_000), + }); + if (res.ok) { + const data = await res.json(); + const allAreas: WindArea[] = data.items ?? []; + if (slugs.length > 0) { + const slugSet = new Set(slugs); + areas = allAreas.filter((a) => slugSet.has(a._slug)); + } else { + areas = allAreas; } } } catch { diff --git a/src/lib/iconify-mdi-subset.generated.json b/src/lib/iconify-mdi-subset.generated.json index c2a8fee..2e87570 100644 --- a/src/lib/iconify-mdi-subset.generated.json +++ b/src/lib/iconify-mdi-subset.generated.json @@ -288,6 +288,9 @@ }, "tag-outline": { "body": "" + }, + "cloud": { + "body": "" } }, "aliases": { diff --git a/src/lib/rusty-image.server.ts b/src/lib/rusty-image.server.ts index 73c0461..135e4f6 100644 --- a/src/lib/rusty-image.server.ts +++ b/src/lib/rusty-image.server.ts @@ -121,7 +121,7 @@ export async function blurhashToDataUrl( } export interface ResponsiveImage { - sources: { type: string; srcset: string }[]; + sources: { type: string; srcset: string; media?: string }[]; fallback: string; width: number; height: number; @@ -194,10 +194,23 @@ export async function buildResponsiveImage( : {}), }; + // CMS-Transform wendet `ar` nur an, wenn auch `h` mitkommt — `ar` allein + // wird ignoriert und das Bild behält sein intrinsisches Seitenverhältnis + // (gleiches Workaround wie in RustyImage.svelte). Ohne h liefert z.B. die + // 1/1-Mobile-Variante ein 1.92:1-Bild, das object-cover unscharf hochskaliert. + const heightFor = (w: number): number | undefined => + opts.ar && ar && fit !== 'contain' ? Math.round(w / ar) : undefined; + const sources = formats.map((fmt) => { const srcset = widths .map((w) => { - const u = getCmsImageUrl(url, { ...baseParams, width: w, format: fmt }); + const h = heightFor(w); + const u = getCmsImageUrl(url, { + ...baseParams, + width: w, + ...(h ? { height: h } : {}), + format: fmt, + }); return `${u} ${w}w`; }) .join(', '); @@ -205,10 +218,12 @@ export async function buildResponsiveImage( }); const fallbackWidth = widths[widths.length - 1] ?? maxW; + const fallbackHeight = heightFor(fallbackWidth); const fallbackFormat: 'jpeg' | 'webp' = formats.includes('jpeg') ? 'jpeg' : 'webp'; const fallback = getCmsImageUrl(url, { ...baseParams, width: fallbackWidth, + ...(fallbackHeight ? { height: fallbackHeight } : {}), format: fallbackFormat, }); @@ -230,6 +245,60 @@ export async function buildResponsiveImage( }; } +/** + * TopBanner-Varianten pro CSS-Breakpoint. TopBanner.svelte croppt per CSS: + * <640px auf 1/1, 640–1023 auf 16/9, ab 1024 auf 24/9. Ohne eigene Quelle pro + * Breakpoint lädt Mobile das breite Bild und skaliert es fürs Square-Crop + * unscharf hoch. Kein AVIF: der 5-10× langsamere Encoder staut bei kaltem + * Cache alle Varianten gleichzeitig — der erste Besucher hängt dann sichtbar + * lange auf dem LQIP. WebP q60 holt den Großteil der Ersparnis ohne das Risiko. + */ +export async function buildBannerResponsive( + url: string, + focal?: CmsImageField['focal'], +): Promise { + const formats: Array<'avif' | 'webp' | 'jpeg'> = ['webp', 'jpeg']; + const quality = 60; + const [mobile, tablet, desktop] = await Promise.all([ + buildResponsiveImage(url, { + widths: [480, 828, 1080], + ar: '1/1', + fit: 'cover', + quality, + focal, + formats, + lqip: false, + }), + buildResponsiveImage(url, { + widths: [768, 1024, 1536, 2048], + ar: '16/9', + fit: 'cover', + quality, + focal, + formats, + lqip: false, + }), + buildResponsiveImage(url, { + widths: [1024, 1440, 1920], + ar: '24/9', + fit: 'cover', + quality, + focal, + formats, + }), + ]); + if (!desktop) return null; + const sources = [ + ...(mobile?.sources.map((s) => ({ ...s, media: '(max-width: 639px)' })) ?? []), + ...(tablet?.sources.map((s) => ({ + ...s, + media: '(min-width: 640px) and (max-width: 1023px)', + })) ?? []), + ...desktop.sources.map((s) => ({ ...s, media: '(min-width: 1024px)' })), + ]; + return { ...desktop, sources }; +} + /** * Fire-and-forget Cache-Warmup: alle srcset-Varianten + Fallback parallel anrequesten. * Nutzt SvelteKit's `event.fetch` für Self-Calls (keine echten HTTP-Roundtrips in Prod-SSR). diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index 8048eee..2ec2f29 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -5,6 +5,7 @@ export const trailingSlash = 'always'; import { batchFetch, batchData, + cached, getNavigationByKey, NavigationKeys, getPageStubs, @@ -89,7 +90,13 @@ export const load: LayoutServerLoad = async ({ locals, route }) => { let bootstrapConfig: PageConfigEntry | null = null; let bootstrapTopBanner: TopBannerEntry | null = null; try { - const batch = await batchFetch([ + // TTL-Cache über den ganzen Batch — lief vorher ungecacht bei jedem + // SSR-Request. CSV-Key-Head: Webhook-Purge jeder beteiligten Collection + // invalidiert den Eintrag (siehe invalidateCollection in cms.ts). + const batch = await cached( + 'navigation', + 'navigation,footer,page_config,top_banner:layout-bootstrap:de', + () => batchFetch([ { id: 'navHeader', collection: 'navigation', @@ -125,7 +132,7 @@ export const load: LayoutServerLoad = async ({ locals, route }) => { collection: 'top_banner', slug: 'site-announcement', }, - ]); + ])); bootstrapNavHeader = batchData(batch.results.navHeader); bootstrapNavSocial = batchData(batch.results.navSocial); bootstrapFooter = batchData(batch.results.footer); @@ -336,14 +343,21 @@ export const load: LayoutServerLoad = async ({ locals, route }) => { : `${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 + // SVGs können nicht transformiert werden — direkt verwenden und inline + // einbetten. TTL-gecacht: lief vorher als unconditional fetch bei + // jedem SSR-Request gegen das CMS. logoUrl = u; try { - const res = await fetch(u); - if (res.ok) { - const text = (await res.text()).trim(); - if (text.includes(' { + const res = await fetch(u); + if (!res.ok) return null; + const text = (await res.text()).trim(); + return text.includes(' { + // Ein Preload pro Breakpoint-Variante (media-Attribut), Format: WebP. + // AVIF wäre kleiner, aber ein type-Mismatch (Browser ohne AVIF) macht den + // Preload wirkungslos — WebP greift überall. Browser laden nur den Preload, + // dessen media-Query matcht. + const bannerPreloads = $derived.by(() => { const r = topBannerData?.responsive; - if (!r) return null; - const webp = r.sources.find((s) => s.type === 'image/webp'); - const picked = webp ?? r.sources[0]; - if (!picked) return { href: r.fallback, srcset: undefined as string | undefined, type: undefined as string | undefined }; - return { href: r.fallback, srcset: picked.srcset, type: picked.type }; + if (!r) return []; + type Src = { type: string; srcset: string; media?: string }; + const sources = r.sources as Src[]; + const byMedia = new Map(); + for (const s of sources) { + const key = s.media ?? ''; + const existing = byMedia.get(key); + if (!existing || (existing.type !== 'image/webp' && s.type === 'image/webp')) { + if (s.type === 'image/webp' || !existing) byMedia.set(key, s); + } + } + return [...byMedia.values()].map((s) => ({ + href: r.fallback, + srcset: s.srcset, + type: s.type, + media: s.media, + })); }); function escapeLdJson(s: string): string { @@ -204,17 +220,18 @@ {:else} - {#if bannerPreload} + {#each bannerPreloads as p (p.media ?? p.type)} - {/if} + {/each} diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index d48e284..7fbbb49 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -5,7 +5,7 @@ import { resolveContentImages, resolveImageUrls, } from '$lib/rusty-image'; -import { buildResponsiveImage, warmResponsiveImage, type ResponsiveImage } from '$lib/rusty-image.server'; +import { buildBannerResponsive, warmResponsiveImage, type ResponsiveImage } from '$lib/rusty-image.server'; import { resolvePostOverviewBlocks, resolveSearchableTextBlocks, @@ -16,9 +16,6 @@ import { DEFAULT_HOME_PAGE_SLUG, PAGE_RESOLVE, SITE_NAME } from '$lib/constants' import { sanitizeBlocks } from '$lib/sanitize-blocks.server'; import { logWarn } from '$lib/log.server'; -const BANNER_WIDTHS = [640, 1024, 1536]; -const BANNER_AR = '16/9'; - 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)}%`; @@ -82,13 +79,7 @@ export const load: PageServerLoad = async ({ locals, fetch }) => { const primary = extractCmsImageField(bannerImages[0]); if (primary?.url) { focalCss = focalToCss(primary.focal); - responsive = await buildResponsiveImage(primary.url, { - widths: BANNER_WIDTHS, - fit: 'cover', - ar: BANNER_AR, - quality: 72, - focal: primary.focal, - }); + responsive = await buildBannerResponsive(primary.url, primary.focal); } resolvedImages = await resolveImageUrls(bannerImages as string[], { width: 2000, diff --git a/src/routes/[...slug]/+page.server.ts b/src/routes/[...slug]/+page.server.ts index d219af4..82ce34f 100644 --- a/src/routes/[...slug]/+page.server.ts +++ b/src/routes/[...slug]/+page.server.ts @@ -6,7 +6,7 @@ import { resolveContentImages, resolveImageUrls, } from '$lib/rusty-image'; -import { buildResponsiveImage, warmResponsiveImage, type ResponsiveImage } from '$lib/rusty-image.server'; +import { buildBannerResponsive, warmResponsiveImage, type ResponsiveImage } from '$lib/rusty-image.server'; import { resolvePostOverviewBlocks, resolveSearchableTextBlocks, @@ -20,9 +20,6 @@ import { env } from '$env/dynamic/public'; import { error } from '@sveltejs/kit'; import { sanitizeBlocks } from '$lib/sanitize-blocks.server'; -const BANNER_WIDTHS = [640, 960, 1280, 1600, 1920]; -const BANNER_AR = '16/9'; - 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)}%`; @@ -106,13 +103,7 @@ export const load: PageServerLoad = async ({ params, locals, fetch, url, setHead const primary = extractCmsImageField(imageArr[0]); if (primary?.url) { topBannerFocalCss = focalToCss(primary.focal); - topBannerResponsive = await buildResponsiveImage(primary.url, { - widths: BANNER_WIDTHS, - fit: 'cover', - ar: BANNER_AR, - quality: 72, - focal: primary.focal, - }); + topBannerResponsive = await buildBannerResponsive(primary.url, primary.focal); const socialRel = ensureTransformedImage(primary.url, { ...POST_SOCIAL_IMAGE_TRANSFORM, focalX: primary.focal?.x, diff --git a/src/routes/post/[slug]/+page.svelte b/src/routes/post/[slug]/+page.svelte index 59211a7..fc91175 100644 --- a/src/routes/post/[slug]/+page.svelte +++ b/src/routes/post/[slug]/+page.svelte @@ -84,7 +84,9 @@ ? "https://schema.org/Event" : "https://schema.org/BlogPosting"} > - + + {#if data.post.created}