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
+2
View File
@@ -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;
+3
View File
@@ -3,6 +3,9 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="preconnect" href="https://cms.pm86.de">
<link rel="preconnect" href="https://api.iconify.design">
<link rel="preconnect" href="https://analytics.pm86.de">
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
+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).
+22 -8
View File
@@ -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<NavigationEntry>(batch.results.navHeader);
bootstrapNavSocial = batchData<NavigationEntry>(batch.results.navSocial);
bootstrapFooter = batchData<FooterEntry>(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('<svg')) logoSvgHtml = text;
}
logoSvgHtml = await cached(
'page_config',
`page_config:logo-svg:${u}`,
async () => {
const res = await fetch(u);
if (!res.ok) return null;
const text = (await res.text()).trim();
return text.includes('<svg') ? text : null;
},
);
} catch (err) { logWarn('layout.logoSvgFetch', err, { url: u }); }
} else {
logoUrl = ensureTransformedImage(u, {
+29 -12
View File
@@ -72,7 +72,7 @@
headline?: string;
subheadline?: string;
responsive?: {
sources: { type: string; srcset: string }[];
sources: { type: string; srcset: string; media?: string }[];
fallback: string;
width: number;
height: number;
@@ -118,13 +118,29 @@
});
const topBannerData = $derived(pageData?.topBanner ?? null);
const bannerPreload = $derived.by(() => {
// 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<string, Src>();
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}
<meta name="theme-color" content="#ffffff" />
<meta name="robots" content={robotsContent} />
{#if bannerPreload}
{#each bannerPreloads as p (p.media ?? p.type)}
<link
rel="preload"
as="image"
href={bannerPreload.href}
imagesrcset={bannerPreload.srcset}
href={p.href}
imagesrcset={p.srcset}
imagesizes="100vw"
type={bannerPreload.type}
type={p.type}
media={p.media}
fetchpriority="high"
/>
{/if}
{/each}
<link rel="icon" type={faviconType} href={faviconUrl} />
<link rel="apple-touch-icon" href={faviconUrl} />
<link rel="alternate" type="application/rss+xml" title="Beiträge" href="/posts/rss.xml" />
+2 -11
View File
@@ -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,
+2 -11
View File
@@ -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,
+3 -1
View File
@@ -84,7 +84,9 @@
? "https://schema.org/Event"
: "https://schema.org/BlogPosting"}
>
<a class="u-url" href="#" aria-hidden="true" hidden></a>
<!-- Microformat-Permalink: echte URL statt "#" — href="#" failt den
Lighthouse-SEO-Audit "Links are not crawlable" (prüft auch hidden). -->
<a class="u-url" href={data.canonicalUrl} aria-hidden="true" hidden></a>
{#if data.post.created}
<time class="dt-published" datetime={String(data.post.created)} hidden
>{data.post.created}</time