perf: LCP-Fix, Banner-Crops, SSR-Cache, Bulk-Windkarte
Deploy / verify (push) Successful in 1m3s
Deploy / deploy (push) Successful in 1m47s

- 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>
This commit is contained in:
Peter Meier
2026-06-12 09:50:01 +02:00
parent 41597f87b5
commit d9519a867e
13 changed files with 164 additions and 89 deletions
+7 -3
View File
@@ -90,9 +90,11 @@ function ttlFor(collection: string): number {
/**
* Cached fetch mit TTL + in-flight dedup. Key-Format: "<collection>:<op>:<params>".
* 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<T>(
export async function cached<T>(
collection: string,
key: string,
fn: () => Promise<T>,
@@ -122,7 +124,9 @@ async function cached<T>(
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++;
}
+8 -24
View File
@@ -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<HTMLImageElement | null>(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.
</script>
{#if showBanner}
@@ -103,23 +94,19 @@
{#if hasResponsive && responsive}
{#key responsive.fallback}
<picture class="block w-full max-w-[1920px] mx-auto">
{#each responsive.sources as s (s.type)}
<source type={s.type} srcset={s.srcset} sizes={sizes} />
{#each responsive.sources as s (s.media ? `${s.type}|${s.media}` : s.type)}
<source type={s.type} media={s.media} srcset={s.srcset} sizes={sizes} />
{/each}
<img
src={responsive.fallback}
alt={altText}
class="w-full object-cover max-h-[400px] lg:max-h-[600px] aspect-square sm:aspect-video lg:aspect-24/9 text-transparent transition-opacity duration-500"
class:opacity-0={!imgLoaded}
class:opacity-100={imgLoaded}
class="w-full object-cover max-h-[400px] lg:max-h-[600px] aspect-square sm:aspect-video lg:aspect-24/9 text-transparent"
width={responsive.width}
height={responsive.height}
style={imgStyle}
loading="eager"
fetchpriority="high"
decoding="async"
bind:this={imgEl}
onload={() => (imgLoaded = true)}
/>
</picture>
{/key}
@@ -128,15 +115,12 @@
<img
src={resolvedImages[0]}
alt={altText}
class="block w-full object-cover max-h-[400px] lg:max-h-[600px] max-w-[1920px] mx-auto aspect-square sm:aspect-video lg:aspect-24/9 text-transparent transition-opacity duration-500"
class:opacity-0={!imgLoaded}
class:opacity-100={imgLoaded}
class="block w-full object-cover max-h-[400px] lg:max-h-[600px] max-w-[1920px] mx-auto aspect-square sm:aspect-video lg:aspect-24/9 text-transparent"
width={1920}
height={600}
style={imgStyle}
loading="eager"
fetchpriority="high"
onload={() => (imgLoaded = true)}
/>
{/key}
{/if}
@@ -375,7 +375,7 @@
<svelte:element
this={explanationHref ? "a" : "div"}
href={explanationHref || undefined}
class="block rounded-xs border border-l-4 bg-white shadow-sm transition-colors {accentClasses.box} {explanationHref ? 'no-underline! hover:brightness-95' : ''}"
class="block rounded-xs border border-l-4 bg-white shadow-sm {accentClasses.box} {explanationHref ? 'no-underline! hover:brightness-95' : ''}"
>
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 px-4 py-3 text-sm">
{#if !live}
+11 -16
View File
@@ -69,22 +69,17 @@ let areas = $state<WindArea[]>([]);
.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 {
@@ -288,6 +288,9 @@
},
"tag-outline": {
"body": "<path fill=\"currentColor\" d=\"m21.41 11.58l-9-9A2 2 0 0 0 11 2H4a2 2 0 0 0-2 2v7a2 2 0 0 0 .59 1.42l9 9A2 2 0 0 0 13 22a2 2 0 0 0 1.41-.59l7-7A2 2 0 0 0 22 13a2 2 0 0 0-.59-1.42M13 20l-9-9V4h7l9 9M6.5 5A1.5 1.5 0 1 1 5 6.5A1.5 1.5 0 0 1 6.5 5\"/>"
},
"cloud": {
"body": "<path fill=\"currentColor\" d=\"M6.5 20q-2.28 0-3.89-1.57Q1 16.85 1 14.58q0-1.95 1.17-3.48q1.18-1.53 3.08-1.95q.63-2.3 2.5-3.72Q9.63 4 12 4q2.93 0 4.96 2.04Q19 8.07 19 11q1.73.2 2.86 1.5q1.14 1.28 1.14 3q0 1.88-1.31 3.19T18.5 20Z\"/>"
}
},
"aliases": {
+71 -2
View File
@@ -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, 6401023 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<ResponsiveImage | null> {
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).