feat(image): enhance image handling with new properties and responsive support
Deploy / verify (push) Successful in 41s
Deploy / deploy (push) Successful in 1m2s

- Updated PostEntry type to include _rawImageUrl and _imageFocal for improved image management.
- Refactored image handling in various components to utilize the new properties, ensuring better focal point support and responsive image rendering.
- Introduced warmResponsiveImage function for pre-fetching image variants to optimize loading performance.
- Replaced CmsImage component with RustyImage in multiple blocks for consistent image processing.
This commit is contained in:
Peter Meier
2026-04-18 08:43:17 +02:00
parent 3c6ff773ae
commit a2f2d5bfb0
14 changed files with 297 additions and 162 deletions
+16 -4
View File
@@ -258,7 +258,14 @@ export async function loadPostsList(opts: {
focalX: field.focal?.x,
focalY: field.focal?.y,
});
(post as PostEntry & { _resolvedImageUrl?: string })._resolvedImageUrl = url;
const p = post as PostEntry & {
_resolvedImageUrl?: string;
_rawImageUrl?: string;
_imageFocal?: { x: number; y: number } | null;
};
p._resolvedImageUrl = url;
p._rawImageUrl = field.url;
p._imageFocal = field.focal ?? null;
} catch {
/* image optional */
}
@@ -440,9 +447,14 @@ export async function resolvePostOverviewBlocks(
focalX: field.focal?.x,
focalY: field.focal?.y,
});
(
post as PostEntry & { _resolvedImageUrl?: string }
)._resolvedImageUrl = url;
const p = post as PostEntry & {
_resolvedImageUrl?: string;
_rawImageUrl?: string;
_imageFocal?: { x: number; y: number } | null;
};
p._resolvedImageUrl = url;
p._rawImageUrl = field.url;
p._imageFocal = field.focal ?? null;
} catch {
// Bild optional
}
-45
View File
@@ -1,45 +0,0 @@
<script lang="ts">
import { getCmsImageUrl } from '$lib/rusty-image';
interface Props {
/** Asset-Dateiname (z.B. "hero.jpg") oder volle URL. */
src: string;
width?: number;
height?: number;
/** Qualität 1100 (JPEG/WebP). */
quality?: number;
format?: 'jpeg' | 'png' | 'webp' | 'avif';
fit?: 'fill' | 'contain' | 'cover';
/** Seitenverhältnis, z.B. "16:9" oder "1:1". */
ar?: string;
alt?: string;
class?: string;
loading?: 'lazy' | 'eager';
}
let {
src,
width,
height,
quality,
format,
fit,
ar,
alt = '',
class: className = '',
loading = 'lazy',
}: Props = $props();
const resolvedSrc = $derived(
getCmsImageUrl(src, { width, height, quality, format, fit, ar }),
);
</script>
<img
src={resolvedSrc}
{alt}
width={width}
height={height}
class={className}
{loading}
/>
+21 -4
View File
@@ -2,8 +2,13 @@
import type { PostEntry } from "$lib/cms";
import PostMeta from "./PostMeta.svelte";
import EventBadges from "./EventBadges.svelte";
import RustyImage from "./RustyImage.svelte";
type PostWithImage = PostEntry & { _resolvedImageUrl?: string };
type PostWithImage = PostEntry & {
_resolvedImageUrl?: string;
_rawImageUrl?: string;
_imageFocal?: { x: number; y: number } | null;
};
let {
post,
@@ -15,7 +20,9 @@
tagBase?: string;
} = $props();
const resolvedImg = $derived(post._resolvedImageUrl);
const rawImg = $derived(post._rawImageUrl);
const focal = $derived(post._imageFocal ?? null);
const fallbackImg = $derived(post._resolvedImageUrl);
type PostWithEvent = PostWithImage & {
isEvent?: boolean;
@@ -33,9 +40,19 @@
class="transition-all flex flex-col text-slate-950 no-underline overflow-hidden border border-gray-200 bg-white"
>
<div class="relative aspect-3/2 w-full overflow-hidden bg-gray-100">
{#if resolvedImg}
{#if rawImg}
<RustyImage
src={rawImg}
focal={focal}
width={400}
aspect="3/2"
alt={post.headline ?? ""}
class="w-full h-full object-cover"
loading="lazy"
/>
{:else if fallbackImg}
<img
src={resolvedImg}
src={fallbackImg}
alt={post.headline ?? ""}
class="w-full h-full object-cover"
loading="lazy"
+125 -13
View File
@@ -1,25 +1,137 @@
<script lang="ts">
/**
* Dünner img-Wrapper: `src` ist typischerweise von getCmsImageUrl / ensureTransformedImage (/cms-images?…).
* Smart CMS-Image-Component.
* - Automatisches AVIF→WebP→JPEG `<picture>`.
* - Retina-srcset per `densities` (Default [1, 2]).
* - CLS=0 via `width`+`height` (oder aus `aspect` abgeleitet).
* - Focal durchgereicht (cover-Crop am Motiv).
*
* `src` = rohe CMS-URL (ohne /cms-images-Wrapper). Die Component baut
* die transformierten /cms-images?…-URLs selbst.
*/
import { getCmsImageUrl, type RustyImageTransformParams } from "$lib/rusty-image";
interface Props {
/** Aufgelöster Bildpfad (z. B. ensureTransformedImage). */
/** Rohe CMS-URL (z.B. https://cms.pm86.de/api/assets/...). */
src: string;
width?: number;
/** Ziel-Renderbreite in CSS-px (für Attribut + srcset-Basis). */
width: number;
/** Ziel-Renderhöhe; alternativ `aspect`. Mindestens eins muss gegeben sein. */
height?: number;
/** Seitenverhältnis, z.B. "16/9", "3/2". Alternativ zu `height`. */
aspect?: string;
/** Focal-Point aus CMS-Image-Field. */
focal?: { x: number; y: number } | null;
alt?: string;
class?: string;
/** `sizes`-Attribut für Media-Queries; default `${width}px`. */
sizes?: string;
loading?: "lazy" | "eager";
fetchpriority?: "high" | "auto" | "low";
decoding?: "async" | "sync" | "auto";
class?: string;
style?: string;
/** Pixel-Dichten fürs srcset. Default [1, 2] (Retina). */
densities?: number[];
/** Format-Fallback-Kette. Default ['avif','webp','jpeg']. */
formats?: ("avif" | "webp" | "jpeg")[];
/** JPEG/AVIF/WebP-Qualität. Default 75. */
quality?: number;
fit?: "cover" | "contain" | "fill";
}
let { src, width, height, alt = "", class: className = "", loading = "lazy" }: Props = $props();
let {
src,
width,
height,
aspect,
focal = null,
alt = "",
sizes,
loading = "lazy",
fetchpriority,
decoding = "async",
class: className = "",
style,
densities = [1, 2],
formats = ["webp", "jpeg"],
quality = 75,
fit = "cover",
}: Props = $props();
function parseAr(s: string | undefined): number | null {
if (!s) return null;
const m = s.match(/^(\d+(?:\.\d+)?)\s*[/:x]\s*(\d+(?:\.\d+)?)$/);
if (!m) return null;
const w = Number(m[1]);
const h = Number(m[2]);
if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) return null;
return w / h;
}
const arValue = $derived(parseAr(aspect));
const computedHeight = $derived(
height ?? (arValue ? Math.round(width / arValue) : undefined),
);
const arString = $derived(
aspect ??
(height && width ? `${width}/${height}` : undefined),
);
const sizesAttr = $derived(sizes ?? `${width}px`);
function baseParams(format: "avif" | "webp" | "jpeg", w: number): RustyImageTransformParams {
return {
width: w,
format,
fit,
quality,
...(arString ? { ar: arString } : {}),
...(focal ? { focalX: focal.x, focalY: focal.y } : {}),
...(arString && fit !== "contain"
? { height: Math.round(w / (arValue ?? 1)) }
: {}),
};
}
function buildSrcset(format: "avif" | "webp" | "jpeg"): string {
return densities
.map((d) => {
const w = Math.round(width * d);
return `${getCmsImageUrl(src, baseParams(format, w))} ${d}x`;
})
.join(", ");
}
const fallbackFormat = $derived<"avif" | "webp" | "jpeg">(
formats.includes("jpeg") ? "jpeg" : formats[formats.length - 1] ?? "jpeg",
);
const fallbackUrl = $derived(
getCmsImageUrl(src, baseParams(fallbackFormat, width)),
);
const fallbackSrcset = $derived(buildSrcset(fallbackFormat));
const pictureSources = $derived(
formats.filter((f) => f !== fallbackFormat).map((f) => ({
type: `image/${f}`,
srcset: buildSrcset(f),
})),
);
</script>
<img
{src}
{alt}
{width}
{height}
class={className}
{loading}
/>
<picture>
{#each pictureSources as s (s.type)}
<source type={s.type} srcset={s.srcset} sizes={sizesAttr} />
{/each}
<img
src={fallbackUrl}
srcset={fallbackSrcset}
sizes={sizesAttr}
{alt}
width={width}
height={computedHeight}
class={className}
{style}
{loading}
{decoding}
{fetchpriority}
/>
</picture>
+25 -21
View File
@@ -90,33 +90,37 @@
style={wrapperStyle}
>
{#if hasResponsive && responsive}
<picture>
{#each responsive.sources as s (s.type)}
<source type={s.type} srcset={s.srcset} sizes={sizes} />
{/each}
{#key responsive.fallback}
<picture class="block w-full">
{#each responsive.sources as s (s.type)}
<source type={s.type} srcset={s.srcset} sizes={sizes} />
{/each}
<img
src={responsive.fallback}
alt={altText}
class="w-full object-cover max-h-[400px] lg:max-h-[600px] max-w-[1920px] 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"
/>
</picture>
{/key}
{:else}
{#key resolvedImages[0]}
<img
src={responsive.fallback}
src={resolvedImages[0]}
alt={altText}
class="w-full object-cover max-h-[400px] lg:max-h-[600px] max-w-[1920px] aspect-square sm:aspect-video lg:aspect-24/9"
width={responsive.width}
height={responsive.height}
class="w-full object-cover max-h-[400px] lg:max-h-[600px] max-w-[1920px] aspect-square sm:aspect-video lg:aspect-24/9 text-transparent"
width={1920}
height={600}
style={imgStyle}
loading="eager"
fetchpriority="high"
decoding="async"
/>
</picture>
{:else}
<img
src={resolvedImages[0]}
alt={altText}
class="w-full object-cover max-h-[400px] lg:max-h-[600px] max-w-[1920px] aspect-square sm:aspect-video lg:aspect-24/9"
width={1920}
height={600}
style={imgStyle}
loading="eager"
fetchpriority="high"
/>
{/key}
{/if}
{#if hasPageTitle}
<div class="absolute inset-0 flex items-center justify-start ">
+7 -21
View File
@@ -1,30 +1,16 @@
<script lang="ts">
import { getBlockLayoutClasses } from "$lib/block-layout";
import type { ImageBlockData } from "$lib/block-types";
import CmsImage from "$lib/components/CmsImage.svelte";
import RustyImage from "$lib/components/RustyImage.svelte";
import { extractCmsImageField } from "$lib/rusty-image";
let { block }: { block: ImageBlockData } = $props();
/** Rohe Bild-Quelle für /cms-images (URL, //-URL oder Asset-Dateiname/Slug). */
function getRawSrc(b: ImageBlockData): string | null {
const img = b.img ?? b.image;
if (typeof img === "string") {
const s = img.trim();
if (!s) return null;
return s.startsWith("//") ? `https:${s}` : s;
}
if (img && typeof img === "object") {
const src =
(img as { src?: string }).src ?? (img as { file?: { url?: string } }).file?.url;
if (typeof src === "string" && src)
return src.startsWith("//") ? `https:${src}` : src;
}
return null;
}
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
const imageSource = $derived(block.image ?? block.img);
const rawSrc = $derived(getRawSrc(block));
const imageField = $derived(extractCmsImageField(imageSource));
const rawSrc = $derived(imageField?.url ?? null);
const focal = $derived(imageField?.focal ?? null);
$effect(() => {
if (!rawSrc && block._slug) {
console.warn("[ImageBlock] Bild nicht verfügbar:", block._slug, {
@@ -57,10 +43,10 @@
<div class={layoutClasses} data-block-type="image" data-block-slug={block._slug}>
{#if rawSrc}
<figure class="max-w-[400px]">
<CmsImage
<RustyImage
src={rawSrc}
focal={focal}
width={800}
format="webp"
quality={85}
fit="contain"
alt={block.caption ?? title ?? ""}
@@ -7,7 +7,8 @@
import Icon from "@iconify/svelte";
import { useTranslate } from "$lib/translations";
import { T } from "$lib/translations";
import CmsImage from "$lib/components/CmsImage.svelte";
import RustyImage from "$lib/components/RustyImage.svelte";
import { extractCmsImageField } from "$lib/rusty-image";
const t = useTranslate();
let { block }: { block: ImageGalleryBlockData } = $props();
@@ -26,27 +27,24 @@
const withUrl = $derived(
images
.map((img) => {
const url = imageUrl(img);
return url != null ? { img, url } : null;
const field = extractCmsImageField(img);
return field?.url
? { img, url: field.url, focal: field.focal ?? null }
: null;
})
.filter((x): x is { img: ImageGalleryImage; url: string } => x != null),
.filter(
(
x,
): x is {
img: ImageGalleryImage;
url: string;
focal: { x: number; y: number } | null;
} => x != null,
),
);
let currentIndex = $state(0);
function imageUrl(img: ImageGalleryImage): string | null {
if (typeof img.src === "string") {
const s = img.src.trim();
if (!s) return null;
return s.startsWith("//") ? `https:${s}` : s;
}
if (img.file?.url) {
const u = img.file.url;
return u.startsWith("//") ? `https:${u}` : u;
}
return null;
}
function prev() {
currentIndex = (currentIndex - 1 + withUrl.length) % withUrl.length;
}
@@ -122,12 +120,12 @@
<p class="text-sm text-stein-500">{t(T.image_gallery_empty)}</p>
{:else if withUrl.length === 1}
<figure class="m-0">
<CmsImage
<RustyImage
src={withUrl[0].url}
focal={withUrl[0].focal}
width={1280}
height={720}
aspect="16/9"
fit="cover"
format="webp"
quality={80}
alt={withUrl[0].img.description ?? ""}
class="w-full rounded-sm object-cover aspect-video"
@@ -158,12 +156,12 @@
class="m-0 absolute inset-0"
transition:fade={{ duration: 200 }}
>
<CmsImage
<RustyImage
src={current.url}
focal={current.focal}
width={1280}
height={720}
aspect="16/9"
fit="cover"
format="webp"
quality={80}
alt={current.img.description ?? ""}
class="w-full h-full object-cover"
@@ -2,19 +2,15 @@
import { marked } from "marked";
import { getBlockLayoutClasses } from "$lib/block-layout";
import type { OrganisationsBlockData, OrganisationEntry } from "$lib/block-types";
import { absoluteUrlFromCmsImageField, getCmsImageUrl } from "$lib/rusty-image";
import { extractCmsImageField } from "$lib/rusty-image";
import Card from "$lib/components/Card.svelte";
import Badge from "$lib/components/Badge.svelte";
import RustyImage from "$lib/components/RustyImage.svelte";
marked.setOptions({ gfm: true });
/** Nach resolveContentImages: resolvedLogoSrc; sonst /cms-images Proxy. */
function organisationLogoSrc(o: OrganisationEntry): string | undefined {
const resolved = o.resolvedLogoSrc?.trim();
if (resolved) return resolved;
const url = absoluteUrlFromCmsImageField(o.logo);
if (!url) return undefined;
return getCmsImageUrl(url, { width: 256, fit: 'contain', format: 'webp', quality: 85 });
function organisationLogoField(o: OrganisationEntry) {
return extractCmsImageField(o.logo);
}
function organisationLogoAlt(o: OrganisationEntry): string {
@@ -81,17 +77,23 @@
o.link.url.trim() !== ""
? o.link.url.trim()
: undefined}
{@const logoSrc = organisationLogoSrc(o)}
{@const logoField = organisationLogoField(o)}
{#if compact}
<Card href={linkUrl} class="flex! flex-row! items-start! gap-3! p-3! gap-y-0!">
<div
class="h-10 w-10 rounded-md border border-stein-100 bg-stein-50 flex items-center justify-center overflow-hidden shrink-0"
>
{#if logoSrc}
<img
src={logoSrc}
{#if logoField}
<RustyImage
src={logoField.url}
focal={logoField.focal ?? null}
width={80}
aspect="1/1"
fit="contain"
quality={85}
alt={organisationLogoAlt(o)}
class="h-full w-full object-contain p-0.5"
loading="lazy"
/>
{:else}
{@const fishMaskId = `org-logo-fallback-mask-c-${orgIndex}`}
@@ -140,11 +142,17 @@
<div
class="h-14 w-14 rounded-md border border-stein-100 bg-stein-50 flex items-center justify-center overflow-hidden shrink-0"
>
{#if logoSrc}
<img
src={logoSrc}
{#if logoField}
<RustyImage
src={logoField.url}
focal={logoField.focal ?? null}
width={112}
aspect="1/1"
fit="contain"
quality={85}
alt={organisationLogoAlt(o)}
class="h-full w-full object-contain p-1"
loading="lazy"
/>
{:else}
{@const fishMaskId = `org-logo-fallback-mask-${orgIndex}`}
+1 -2
View File
@@ -36,8 +36,7 @@ const memoryCache = new Map<string, CachedImage>();
let resolvedDir: string | null = null;
function ensureCacheDir(): string {
if (resolvedDir) return resolvedDir;
const dir = join(process.cwd(), CACHE_DIR);
const dir = resolvedDir ?? join(process.cwd(), CACHE_DIR);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
resolvedDir = dir;
return dir;
+24 -1
View File
@@ -143,7 +143,7 @@ export interface ResponsiveImageOptions {
}
const DEFAULT_WIDTHS = [640, 960, 1280, 1600, 1920, 2400];
const DEFAULT_FORMATS: Array<'avif' | 'webp' | 'jpeg'> = ['avif', 'webp', 'jpeg'];
const DEFAULT_FORMATS: Array<'avif' | 'webp' | 'jpeg'> = ['webp', 'jpeg'];
const MIME_FOR_FORMAT: Record<'avif' | 'webp' | 'jpeg', string> = {
avif: 'image/avif',
webp: 'image/webp',
@@ -232,3 +232,26 @@ export async function buildResponsiveImage(
...(lqip ? { lqip } : {}),
};
}
/**
* 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).
* Blockiert den Response NICHT läuft im Hintergrund weiter.
*/
export function warmResponsiveImage(
responsive: ResponsiveImage | null,
fetchFn: typeof fetch,
): void {
if (!responsive) return;
const urls = new Set<string>();
urls.add(responsive.fallback);
for (const s of responsive.sources) {
for (const entry of s.srcset.split(',')) {
const url = entry.trim().split(/\s+/)[0];
if (url) urls.add(url);
}
}
for (const u of urls) {
void fetchFn(u).catch(() => {});
}
}
+8 -6
View File
@@ -5,7 +5,7 @@ import {
resolveContentImages,
resolveImageUrls,
} from '$lib/rusty-image';
import { buildResponsiveImage, type ResponsiveImage } from '$lib/rusty-image.server';
import { buildResponsiveImage, warmResponsiveImage, type ResponsiveImage } from '$lib/rusty-image.server';
import {
resolvePostOverviewBlocks,
resolveSearchableTextBlocks,
@@ -13,14 +13,15 @@ import {
} from '$lib/blog-utils';
import { DEFAULT_HOME_PAGE_SLUG, PAGE_RESOLVE, SITE_NAME } from '$lib/constants';
const BANNER_WIDTHS = [640, 960, 1280, 1600, 1920, 2400];
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)}%`;
}
export const load: PageServerLoad = async ({ locals }) => {
export const load: PageServerLoad = async ({ locals, fetch }) => {
const translations = locals.translations ?? {};
let homeSlug = DEFAULT_HOME_PAGE_SLUG;
try {
@@ -76,9 +77,9 @@ export const load: PageServerLoad = async ({ locals }) => {
focalCss = focalToCss(primary.focal);
responsive = await buildResponsiveImage(primary.url, {
widths: BANNER_WIDTHS,
fit: 'contain',
formats: ['avif', 'webp', 'jpeg'],
quality: 78,
fit: 'cover',
ar: BANNER_AR,
quality: 72,
focal: primary.focal,
});
}
@@ -89,6 +90,7 @@ export const load: PageServerLoad = async ({ locals }) => {
format: 'webp',
});
}
warmResponsiveImage(responsive, fetch);
topBanner = {
banner: bannerRaw as import('$lib/cms').FullwidthBannerEntry | null,
resolvedImages,
+8 -6
View File
@@ -5,7 +5,7 @@ import {
resolveContentImages,
resolveImageUrls,
} from '$lib/rusty-image';
import { buildResponsiveImage, type ResponsiveImage } from '$lib/rusty-image.server';
import { buildResponsiveImage, warmResponsiveImage, type ResponsiveImage } from '$lib/rusty-image.server';
import {
resolvePostOverviewBlocks,
resolveSearchableTextBlocks,
@@ -14,14 +14,15 @@ import {
import { PAGE_RESOLVE } from '$lib/constants';
import { error } from '@sveltejs/kit';
const BANNER_WIDTHS = [640, 960, 1280, 1600, 1920, 2400];
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)}%`;
}
export const load: PageServerLoad = async ({ params, locals }) => {
export const load: PageServerLoad = async ({ params, locals, fetch }) => {
const { slug } = params;
const translations = locals.translations ?? {};
@@ -56,9 +57,9 @@ export const load: PageServerLoad = async ({ params, locals }) => {
topBannerFocalCss = focalToCss(primary.focal);
topBannerResponsive = await buildResponsiveImage(primary.url, {
widths: BANNER_WIDTHS,
fit: 'contain',
formats: ['avif', 'webp', 'jpeg'],
quality: 78,
fit: 'cover',
ar: BANNER_AR,
quality: 72,
focal: primary.focal,
});
}
@@ -68,6 +69,7 @@ export const load: PageServerLoad = async ({ params, locals }) => {
fit: 'cover',
format: 'webp',
});
warmResponsiveImage(topBannerResponsive, fetch);
}
}
+2
View File
@@ -107,6 +107,8 @@ export const load: PageServerLoad = async ({ params, locals, url }) => {
return {
post,
postImageUrl,
rawPostImageUrl: postImageField?.url ?? null,
postImageFocal: postImageField?.focal ?? null,
postDate,
contentHtml,
hasRowContent,
+16 -1
View File
@@ -5,6 +5,7 @@
import EventBadges from '$lib/components/EventBadges.svelte';
import EventMap from '$lib/components/EventMap.svelte';
import Accordion from '$lib/components/Accordion.svelte';
import RustyImage from '$lib/components/RustyImage.svelte';
import { t, T } from '$lib/translations';
import type { PageData } from './$types';
@@ -32,7 +33,21 @@
{/if}
<div class="md:flex gap-2 items-end">
{#if data.postImageUrl}
{#if data.rawPostImageUrl}
<div class="overflow-hidden inline-block my-4 border border-white self-start ring-1 ring-black/50 shadow-lg">
<RustyImage
src={data.rawPostImageUrl}
focal={data.postImageFocal}
width={150}
height={200}
fit="cover"
alt=""
class="block object-cover"
loading="eager"
fetchpriority="high"
/>
</div>
{:else if data.postImageUrl}
<div class="overflow-hidden inline-block my-4 border border-white self-start ring-1 ring-black/50 shadow-lg">
<img
src={data.postImageUrl}