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',
|
||||
'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,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
@@ -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,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}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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" />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user