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:
@@ -75,6 +75,8 @@ const EXTRA_ICONS = [
|
|||||||
'tune-variant',
|
'tune-variant',
|
||||||
'tune',
|
'tune',
|
||||||
'check-decagram',
|
'check-decagram',
|
||||||
|
// CMS-Content (Social-Link o.ä.): tauchte als Runtime-Fetch in Lighthouse auf
|
||||||
|
'cloud',
|
||||||
];
|
];
|
||||||
|
|
||||||
const STATIC_PATTERN = /icon="mdi:([a-z0-9-]+)"/g;
|
const STATIC_PATTERN = /icon="mdi:([a-z0-9-]+)"/g;
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<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%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
|||||||
+7
-3
@@ -90,9 +90,11 @@ function ttlFor(collection: string): number {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Cached fetch mit TTL + in-flight dedup. Key-Format: "<collection>:<op>:<params>".
|
* 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,
|
collection: string,
|
||||||
key: string,
|
key: string,
|
||||||
fn: () => Promise<T>,
|
fn: () => Promise<T>,
|
||||||
@@ -122,7 +124,9 @@ async function cached<T>(
|
|||||||
export function invalidateCollection(collection: string): number {
|
export function invalidateCollection(collection: string): number {
|
||||||
let n = 0;
|
let n = 0;
|
||||||
for (const k of cache.keys()) {
|
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);
|
cache.delete(k);
|
||||||
n++;
|
n++;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
import type { FullwidthBannerEntry } from "$lib/cms";
|
import type { FullwidthBannerEntry } from "$lib/cms";
|
||||||
|
|
||||||
interface ResponsiveBannerImage {
|
interface ResponsiveBannerImage {
|
||||||
sources: { type: string; srcset: string }[];
|
sources: { type: string; srcset: string; media?: string }[];
|
||||||
fallback: string;
|
fallback: string;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
@@ -79,18 +79,9 @@
|
|||||||
const showBanner = $derived(hasImage || hasText || hasPageTitle);
|
const showBanner = $derived(hasImage || hasText || hasPageTitle);
|
||||||
const altText = $derived(banner?.headline ?? headline ?? "");
|
const altText = $derived(banner?.headline ?? headline ?? "");
|
||||||
|
|
||||||
let imgEl = $state<HTMLImageElement | null>(null);
|
// Kein JS-gesteuertes Fade-in: `opacity-0` im SSR-HTML hält das Banner bis
|
||||||
let imgLoaded = $state(false);
|
// 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.
|
||||||
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;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if showBanner}
|
{#if showBanner}
|
||||||
@@ -103,23 +94,19 @@
|
|||||||
{#if hasResponsive && responsive}
|
{#if hasResponsive && responsive}
|
||||||
{#key responsive.fallback}
|
{#key responsive.fallback}
|
||||||
<picture class="block w-full max-w-[1920px] mx-auto">
|
<picture class="block w-full max-w-[1920px] mx-auto">
|
||||||
{#each responsive.sources as s (s.type)}
|
{#each responsive.sources as s (s.media ? `${s.type}|${s.media}` : s.type)}
|
||||||
<source type={s.type} srcset={s.srcset} sizes={sizes} />
|
<source type={s.type} media={s.media} srcset={s.srcset} sizes={sizes} />
|
||||||
{/each}
|
{/each}
|
||||||
<img
|
<img
|
||||||
src={responsive.fallback}
|
src={responsive.fallback}
|
||||||
alt={altText}
|
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="w-full object-cover max-h-[400px] lg:max-h-[600px] aspect-square sm:aspect-video lg:aspect-24/9 text-transparent"
|
||||||
class:opacity-0={!imgLoaded}
|
|
||||||
class:opacity-100={imgLoaded}
|
|
||||||
width={responsive.width}
|
width={responsive.width}
|
||||||
height={responsive.height}
|
height={responsive.height}
|
||||||
style={imgStyle}
|
style={imgStyle}
|
||||||
loading="eager"
|
loading="eager"
|
||||||
fetchpriority="high"
|
fetchpriority="high"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
bind:this={imgEl}
|
|
||||||
onload={() => (imgLoaded = true)}
|
|
||||||
/>
|
/>
|
||||||
</picture>
|
</picture>
|
||||||
{/key}
|
{/key}
|
||||||
@@ -128,15 +115,12 @@
|
|||||||
<img
|
<img
|
||||||
src={resolvedImages[0]}
|
src={resolvedImages[0]}
|
||||||
alt={altText}
|
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="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"
|
||||||
class:opacity-0={!imgLoaded}
|
|
||||||
class:opacity-100={imgLoaded}
|
|
||||||
width={1920}
|
width={1920}
|
||||||
height={600}
|
height={600}
|
||||||
style={imgStyle}
|
style={imgStyle}
|
||||||
loading="eager"
|
loading="eager"
|
||||||
fetchpriority="high"
|
fetchpriority="high"
|
||||||
onload={() => (imgLoaded = true)}
|
|
||||||
/>
|
/>
|
||||||
{/key}
|
{/key}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -375,7 +375,7 @@
|
|||||||
<svelte:element
|
<svelte:element
|
||||||
this={explanationHref ? "a" : "div"}
|
this={explanationHref ? "a" : "div"}
|
||||||
href={explanationHref || undefined}
|
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">
|
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 px-4 py-3 text-sm">
|
||||||
{#if !live}
|
{#if !live}
|
||||||
|
|||||||
@@ -69,22 +69,17 @@ let areas = $state<WindArea[]>([]);
|
|||||||
.map((a) => (typeof a === "string" ? a : a._slug))
|
.map((a) => (typeof a === "string" ? a : a._slug))
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
if (slugs.length > 0) {
|
const res = await fetch(`${CMS_BASE}/api/content/wind_area?_limit=200`, {
|
||||||
const results = await Promise.all(
|
signal: AbortSignal.timeout(6_000),
|
||||||
slugs.map((slug) =>
|
});
|
||||||
fetch(`${CMS_BASE}/api/content/wind_area/${slug}`, {
|
if (res.ok) {
|
||||||
signal: AbortSignal.timeout(6_000),
|
const data = await res.json();
|
||||||
}).then((r) => (r.ok ? r.json() : null))
|
const allAreas: WindArea[] = data.items ?? [];
|
||||||
)
|
if (slugs.length > 0) {
|
||||||
);
|
const slugSet = new Set(slugs);
|
||||||
areas = results.filter((r): r is WindArea => r !== null);
|
areas = allAreas.filter((a) => slugSet.has(a._slug));
|
||||||
} else {
|
} else {
|
||||||
const res = await fetch(`${CMS_BASE}/api/content/wind_area?_limit=100`, {
|
areas = allAreas;
|
||||||
signal: AbortSignal.timeout(6_000),
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
areas = data.items ?? [];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -288,6 +288,9 @@
|
|||||||
},
|
},
|
||||||
"tag-outline": {
|
"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\"/>"
|
"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": {
|
"aliases": {
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ export async function blurhashToDataUrl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ResponsiveImage {
|
export interface ResponsiveImage {
|
||||||
sources: { type: string; srcset: string }[];
|
sources: { type: string; srcset: string; media?: string }[];
|
||||||
fallback: string;
|
fallback: string;
|
||||||
width: number;
|
width: number;
|
||||||
height: 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 sources = formats.map((fmt) => {
|
||||||
const srcset = widths
|
const srcset = widths
|
||||||
.map((w) => {
|
.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`;
|
return `${u} ${w}w`;
|
||||||
})
|
})
|
||||||
.join(', ');
|
.join(', ');
|
||||||
@@ -205,10 +218,12 @@ export async function buildResponsiveImage(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const fallbackWidth = widths[widths.length - 1] ?? maxW;
|
const fallbackWidth = widths[widths.length - 1] ?? maxW;
|
||||||
|
const fallbackHeight = heightFor(fallbackWidth);
|
||||||
const fallbackFormat: 'jpeg' | 'webp' = formats.includes('jpeg') ? 'jpeg' : 'webp';
|
const fallbackFormat: 'jpeg' | 'webp' = formats.includes('jpeg') ? 'jpeg' : 'webp';
|
||||||
const fallback = getCmsImageUrl(url, {
|
const fallback = getCmsImageUrl(url, {
|
||||||
...baseParams,
|
...baseParams,
|
||||||
width: fallbackWidth,
|
width: fallbackWidth,
|
||||||
|
...(fallbackHeight ? { height: fallbackHeight } : {}),
|
||||||
format: fallbackFormat,
|
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.
|
* 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).
|
* Nutzt SvelteKit's `event.fetch` für Self-Calls (keine echten HTTP-Roundtrips in Prod-SSR).
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export const trailingSlash = 'always';
|
|||||||
import {
|
import {
|
||||||
batchFetch,
|
batchFetch,
|
||||||
batchData,
|
batchData,
|
||||||
|
cached,
|
||||||
getNavigationByKey,
|
getNavigationByKey,
|
||||||
NavigationKeys,
|
NavigationKeys,
|
||||||
getPageStubs,
|
getPageStubs,
|
||||||
@@ -89,7 +90,13 @@ export const load: LayoutServerLoad = async ({ locals, route }) => {
|
|||||||
let bootstrapConfig: PageConfigEntry | null = null;
|
let bootstrapConfig: PageConfigEntry | null = null;
|
||||||
let bootstrapTopBanner: TopBannerEntry | null = null;
|
let bootstrapTopBanner: TopBannerEntry | null = null;
|
||||||
try {
|
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',
|
id: 'navHeader',
|
||||||
collection: 'navigation',
|
collection: 'navigation',
|
||||||
@@ -125,7 +132,7 @@ export const load: LayoutServerLoad = async ({ locals, route }) => {
|
|||||||
collection: 'top_banner',
|
collection: 'top_banner',
|
||||||
slug: 'site-announcement',
|
slug: 'site-announcement',
|
||||||
},
|
},
|
||||||
]);
|
]));
|
||||||
bootstrapNavHeader = batchData<NavigationEntry>(batch.results.navHeader);
|
bootstrapNavHeader = batchData<NavigationEntry>(batch.results.navHeader);
|
||||||
bootstrapNavSocial = batchData<NavigationEntry>(batch.results.navSocial);
|
bootstrapNavSocial = batchData<NavigationEntry>(batch.results.navSocial);
|
||||||
bootstrapFooter = batchData<FooterEntry>(batch.results.footer);
|
bootstrapFooter = batchData<FooterEntry>(batch.results.footer);
|
||||||
@@ -336,14 +343,21 @@ export const load: LayoutServerLoad = async ({ locals, route }) => {
|
|||||||
: `${cmsBase}/api/assets/${rawSrc}`;
|
: `${cmsBase}/api/assets/${rawSrc}`;
|
||||||
const isSvg = u.split('?')[0].toLowerCase().endsWith('.svg');
|
const isSvg = u.split('?')[0].toLowerCase().endsWith('.svg');
|
||||||
if (isSvg) {
|
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;
|
logoUrl = u;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(u);
|
logoSvgHtml = await cached(
|
||||||
if (res.ok) {
|
'page_config',
|
||||||
const text = (await res.text()).trim();
|
`page_config:logo-svg:${u}`,
|
||||||
if (text.includes('<svg')) logoSvgHtml = text;
|
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 }); }
|
} catch (err) { logWarn('layout.logoSvgFetch', err, { url: u }); }
|
||||||
} else {
|
} else {
|
||||||
logoUrl = ensureTransformedImage(u, {
|
logoUrl = ensureTransformedImage(u, {
|
||||||
|
|||||||
+29
-12
@@ -72,7 +72,7 @@
|
|||||||
headline?: string;
|
headline?: string;
|
||||||
subheadline?: string;
|
subheadline?: string;
|
||||||
responsive?: {
|
responsive?: {
|
||||||
sources: { type: string; srcset: string }[];
|
sources: { type: string; srcset: string; media?: string }[];
|
||||||
fallback: string;
|
fallback: string;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
@@ -118,13 +118,29 @@
|
|||||||
});
|
});
|
||||||
const topBannerData = $derived(pageData?.topBanner ?? null);
|
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;
|
const r = topBannerData?.responsive;
|
||||||
if (!r) return null;
|
if (!r) return [];
|
||||||
const webp = r.sources.find((s) => s.type === 'image/webp');
|
type Src = { type: string; srcset: string; media?: string };
|
||||||
const picked = webp ?? r.sources[0];
|
const sources = r.sources as Src[];
|
||||||
if (!picked) return { href: r.fallback, srcset: undefined as string | undefined, type: undefined as string | undefined };
|
const byMedia = new Map<string, Src>();
|
||||||
return { href: r.fallback, srcset: picked.srcset, type: picked.type };
|
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 {
|
function escapeLdJson(s: string): string {
|
||||||
@@ -204,17 +220,18 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<meta name="theme-color" content="#ffffff" />
|
<meta name="theme-color" content="#ffffff" />
|
||||||
<meta name="robots" content={robotsContent} />
|
<meta name="robots" content={robotsContent} />
|
||||||
{#if bannerPreload}
|
{#each bannerPreloads as p (p.media ?? p.type)}
|
||||||
<link
|
<link
|
||||||
rel="preload"
|
rel="preload"
|
||||||
as="image"
|
as="image"
|
||||||
href={bannerPreload.href}
|
href={p.href}
|
||||||
imagesrcset={bannerPreload.srcset}
|
imagesrcset={p.srcset}
|
||||||
imagesizes="100vw"
|
imagesizes="100vw"
|
||||||
type={bannerPreload.type}
|
type={p.type}
|
||||||
|
media={p.media}
|
||||||
fetchpriority="high"
|
fetchpriority="high"
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/each}
|
||||||
<link rel="icon" type={faviconType} href={faviconUrl} />
|
<link rel="icon" type={faviconType} href={faviconUrl} />
|
||||||
<link rel="apple-touch-icon" href={faviconUrl} />
|
<link rel="apple-touch-icon" href={faviconUrl} />
|
||||||
<link rel="alternate" type="application/rss+xml" title="Beiträge" href="/posts/rss.xml" />
|
<link rel="alternate" type="application/rss+xml" title="Beiträge" href="/posts/rss.xml" />
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
resolveContentImages,
|
resolveContentImages,
|
||||||
resolveImageUrls,
|
resolveImageUrls,
|
||||||
} from '$lib/rusty-image';
|
} 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 {
|
import {
|
||||||
resolvePostOverviewBlocks,
|
resolvePostOverviewBlocks,
|
||||||
resolveSearchableTextBlocks,
|
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 { sanitizeBlocks } from '$lib/sanitize-blocks.server';
|
||||||
import { logWarn } from '$lib/log.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 {
|
function focalToCss(focal: { x: number; y: number } | undefined): string | null {
|
||||||
if (!focal) return null;
|
if (!focal) return null;
|
||||||
return `${(focal.x * 100).toFixed(2)}% ${(focal.y * 100).toFixed(2)}%`;
|
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]);
|
const primary = extractCmsImageField(bannerImages[0]);
|
||||||
if (primary?.url) {
|
if (primary?.url) {
|
||||||
focalCss = focalToCss(primary.focal);
|
focalCss = focalToCss(primary.focal);
|
||||||
responsive = await buildResponsiveImage(primary.url, {
|
responsive = await buildBannerResponsive(primary.url, primary.focal);
|
||||||
widths: BANNER_WIDTHS,
|
|
||||||
fit: 'cover',
|
|
||||||
ar: BANNER_AR,
|
|
||||||
quality: 72,
|
|
||||||
focal: primary.focal,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
resolvedImages = await resolveImageUrls(bannerImages as string[], {
|
resolvedImages = await resolveImageUrls(bannerImages as string[], {
|
||||||
width: 2000,
|
width: 2000,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
resolveContentImages,
|
resolveContentImages,
|
||||||
resolveImageUrls,
|
resolveImageUrls,
|
||||||
} from '$lib/rusty-image';
|
} 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 {
|
import {
|
||||||
resolvePostOverviewBlocks,
|
resolvePostOverviewBlocks,
|
||||||
resolveSearchableTextBlocks,
|
resolveSearchableTextBlocks,
|
||||||
@@ -20,9 +20,6 @@ import { env } from '$env/dynamic/public';
|
|||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
import { sanitizeBlocks } from '$lib/sanitize-blocks.server';
|
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 {
|
function focalToCss(focal: { x: number; y: number } | undefined): string | null {
|
||||||
if (!focal) return null;
|
if (!focal) return null;
|
||||||
return `${(focal.x * 100).toFixed(2)}% ${(focal.y * 100).toFixed(2)}%`;
|
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]);
|
const primary = extractCmsImageField(imageArr[0]);
|
||||||
if (primary?.url) {
|
if (primary?.url) {
|
||||||
topBannerFocalCss = focalToCss(primary.focal);
|
topBannerFocalCss = focalToCss(primary.focal);
|
||||||
topBannerResponsive = await buildResponsiveImage(primary.url, {
|
topBannerResponsive = await buildBannerResponsive(primary.url, primary.focal);
|
||||||
widths: BANNER_WIDTHS,
|
|
||||||
fit: 'cover',
|
|
||||||
ar: BANNER_AR,
|
|
||||||
quality: 72,
|
|
||||||
focal: primary.focal,
|
|
||||||
});
|
|
||||||
const socialRel = ensureTransformedImage(primary.url, {
|
const socialRel = ensureTransformedImage(primary.url, {
|
||||||
...POST_SOCIAL_IMAGE_TRANSFORM,
|
...POST_SOCIAL_IMAGE_TRANSFORM,
|
||||||
focalX: primary.focal?.x,
|
focalX: primary.focal?.x,
|
||||||
|
|||||||
@@ -84,7 +84,9 @@
|
|||||||
? "https://schema.org/Event"
|
? "https://schema.org/Event"
|
||||||
: "https://schema.org/BlogPosting"}
|
: "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}
|
{#if data.post.created}
|
||||||
<time class="dt-published" datetime={String(data.post.created)} hidden
|
<time class="dt-published" datetime={String(data.post.created)} hidden
|
||||||
>{data.post.created}</time
|
>{data.post.created}</time
|
||||||
|
|||||||
Reference in New Issue
Block a user