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>
This commit is contained in:
@@ -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<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).
|
||||
|
||||
Reference in New Issue
Block a user