feat(images): responsive <picture> srcset for posts/blocks, drop AVIF for build speed
Deploy to Firebase Hosting / deploy (push) Failing after 2m4s

## Responsive sources

Wire `ensureResponsiveImage` (multi-width × multi-format srcset) into the three
remaining single-`<img>` components:

- ImageBlock (body): widths 600 + 1200, fit follows transform params
- ImageGalleryBlock (single-image branch): widths 600 + 1280, fit=contain
- PostCard (post-list cards): widths 400 + 800, fit=cover, ar=3:2

`resolveContentImages` now sets `block.resolvedResponsive` alongside the legacy
`resolvedImageSrc`, so every Svelte block component renders `<picture>` if
the responsive shape is present and falls back to a plain `<img>` otherwise.

Posts list pages (`/posts`, `/posts/page/N`, `/posts/tag/.../page/N`) call a
new `resolvePostCardResponsive` helper instead of one-shot `ensureTransformedImage`.

## Drop AVIF from defaults

Server-side AVIF encoding on the CMS is 5-10× slower than WebP. The bandwidth
win is marginal for this site and the build time hit is severe (multi-minute
hangs on each new variant). Default formats are now `["webp", "jpeg"]` for
`ensureResponsiveImage`, in `Layout.astro`'s TopBanner hero, and in the
post-card helper. Callers can still opt back into AVIF per-call.

## Module split for client-safe types

`ResponsiveImage` is now defined in a tiny `rusty-image-types.ts` and
duplicated in `rusty-image.ts` so Svelte components can `import type` the
shape without dragging `rusty-image.ts` (and its `node:crypto`/`node:fs`
imports) into the browser bundle.

`resolvePostCardResponsive` lives in a new `server-image.ts` for the same
reason — `blog-utils.ts` is imported by Svelte islands, so its server-only
helpers move out.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Peter Meier
2026-04-27 18:41:22 +02:00
parent e8bdb9e987
commit e28230bcef
19 changed files with 1193 additions and 14745 deletions
+1
View File
@@ -26,6 +26,7 @@
"@iconify/svelte": "^5.2.1",
"@tailwindcss/vite": "^4.1.18",
"astro": "^6.1.1",
"blurhash": "^2.0.5",
"json5": "^2.2.3",
"marked": "^17.0.2",
"react": "^19.2.4",
+22 -2
View File
@@ -1,9 +1,13 @@
<script lang="ts">
import type { PostEntry } from "../lib/cms";
import type { ResponsiveImage } from "../lib/rusty-image-types";
import PostMeta from "./PostMeta.svelte";
import EventBadges from "./EventBadges.svelte";
type PostWithImage = PostEntry & { _resolvedImageUrl?: string };
type PostWithImage = PostEntry & {
_resolvedImageUrl?: string;
_resolvedResponsive?: ResponsiveImage;
};
let {
post,
@@ -16,6 +20,7 @@
} = $props();
const resolvedImg = $derived(post._resolvedImageUrl);
const responsive = $derived(post._resolvedResponsive);
type PostWithEvent = PostWithImage & {
isEvent?: boolean;
@@ -33,7 +38,22 @@
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 responsive}
<picture>
{#each responsive.sources as s (s.type)}
<source type={s.type} srcset={s.srcset} sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 400px" />
{/each}
<img
src={responsive.fallback}
width={responsive.width}
height={responsive.height}
alt={post.headline ?? ""}
class="w-full h-full object-cover"
loading="lazy"
decoding="async"
/>
</picture>
{:else if resolvedImg}
<img
src={resolvedImg}
alt={post.headline ?? ""}
+86 -13
View File
@@ -1,23 +1,67 @@
<script lang="ts">
/**
* TopBanner: Fullwidth-Banner mit Bild (über RustyCMS-Transform-API aufgelöst), optionalem Text
* und Titel/Subtitle der aktuellen Page (Markdown).
* und Titel/Subtitle der aktuellen Page (Markdown). Bevorzugt `<picture>` mit AVIF/WebP-Sources
* und srcset, wenn `responsive` geliefert wird; sonst Fallback auf einzelnes `<img>` (legacy).
*/
import { marked } from "marked";
import type { FullwidthBannerEntry } from "../lib/cms";
marked.setOptions({ gfm: true });
interface ResponsiveBannerImage {
sources: { type: string; srcset: string }[];
fallback: string;
width: number;
height: number;
/** Optionaler LQIP (data-URL) als Unschärfe-Placeholder beim Laden. */
lqip?: string;
}
interface Props {
banner: FullwidthBannerEntry | null;
/** Einfache (legacy) einzelne URL(s) — wenn `responsive` fehlt. */
resolvedImages?: string[];
/** Bevorzugter Pfad: fertig berechneter `<picture>`-Inhalt fürs erste Banner-Bild. */
responsive?: ResponsiveBannerImage | null;
/** `sizes`-Attribut fürs srcset (Default: volle Viewport-Breite). */
sizes?: string;
/** Focal-Point als CSS-`object-position` (z. B. "69.5% 40.38%"). */
focalCss?: string | null;
/** Headline der Page (Markdown). */
headline?: string;
/** Subheadline der Page (Markdown). */
subheadline?: string;
}
let { banner = null, resolvedImages = [], headline, subheadline }: Props = $props();
let {
banner = null,
resolvedImages = [],
responsive = null,
sizes = "100vw",
focalCss = null,
headline,
subheadline,
}: Props = $props();
const imgStyle = $derived(
focalCss ? `object-position: ${focalCss};` : undefined,
);
/**
* LQIP: Blurhash-Placeholder als `background-image` auf dem Wrapper.
* Kein Blur-Filter nötig — der aus 32×32 hochskalierte JPEG ist intrinsisch weich.
* `background-position` folgt dem Focal, damit der Placeholder identisch zum Image croppt.
*/
const wrapperStyle = $derived.by(() => {
const parts: string[] = [];
if (responsive?.lqip) {
parts.push(`background-image: url("${responsive.lqip}")`);
parts.push("background-size: cover");
parts.push(`background-position: ${focalCss ?? "center"}`);
}
return parts.length > 0 ? parts.join("; ") : undefined;
});
const headlineHtml = $derived(
headline?.trim() ? (marked.parseInline(headline) as string) : "",
@@ -26,25 +70,54 @@
subheadline?.trim() ? (marked.parseInline(subheadline) as string) : "",
);
const hasImage = $derived(resolvedImages.length > 0 && Boolean(resolvedImages[0]?.trim()));
const hasResponsive = $derived(
responsive != null && responsive.fallback.trim().length > 0,
);
const hasImage = $derived(
hasResponsive || (resolvedImages.length > 0 && Boolean(resolvedImages[0]?.trim())),
);
const hasText = $derived(banner?.text != null && banner.text !== "");
const hasPageTitle = $derived((headline != null && headline !== "") || (subheadline != null && subheadline !== ""));
const showBanner = $derived(hasImage || hasText || hasPageTitle);
const altText = $derived(banner?.headline ?? headline ?? "");
</script>
{#if showBanner}
<div class="w-full">
{#if hasImage}
<div class="w-full overflow-hidden relative flex justify-center items-center shadow-lg border-b border-white">
<img
src={resolvedImages[0]}
alt={banner?.headline ?? headline ?? ""}
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}
loading="eager"
fetchpriority="high"
/>
<div
class="w-full overflow-hidden relative flex justify-center items-center shadow-lg bg-zinc-200"
style={wrapperStyle}
>
{#if hasResponsive && responsive}
<picture>
{#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"
width={responsive.width}
height={responsive.height}
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"
/>
{/if}
{#if hasPageTitle}
<div class="absolute inset-0 flex items-center justify-start ">
<div class="container-custom mx-auto px-6 relative">
+23 -6
View File
@@ -45,12 +45,29 @@
<div class={layoutClasses} data-block-type="image" data-block-slug={block._slug}>
{#if imageUrl}
<figure class="max-w-[400px]">
<img
src={imageUrl}
alt={block.caption ?? title ?? ""}
class="w-full rounded-sm"
loading="lazy"
/>
{#if block.resolvedResponsive}
<picture>
{#each block.resolvedResponsive.sources as s (s.type)}
<source type={s.type} srcset={s.srcset} sizes="(max-width: 768px) 100vw, 400px" />
{/each}
<img
src={block.resolvedResponsive.fallback}
width={block.resolvedResponsive.width}
height={block.resolvedResponsive.height}
alt={block.caption ?? title ?? ""}
class="w-full rounded-sm"
loading="lazy"
decoding="async"
/>
</picture>
{:else}
<img
src={imageUrl}
alt={block.caption ?? title ?? ""}
class="w-full rounded-sm"
loading="lazy"
/>
{/if}
{#if block.caption}
<figcaption class="mt-1 text-sm text-zinc-600">{block.caption}</figcaption>
{/if}
+23 -6
View File
@@ -150,12 +150,29 @@
<p class="text-sm text-stein-500">{t(T.image_gallery_empty)}</p>
{:else if withUrl.length === 1}
<figure class="m-0">
<img
src={withUrl[0].url}
alt={withUrl[0].img.description ?? ""}
class="w-full rounded-sm object-cover aspect-video"
loading="eager"
/>
{#if withUrl[0].img.resolvedResponsive}
<picture>
{#each withUrl[0].img.resolvedResponsive.sources as s (s.type)}
<source type={s.type} srcset={s.srcset} sizes="(max-width: 768px) 100vw, 1024px" />
{/each}
<img
src={withUrl[0].img.resolvedResponsive.fallback}
width={withUrl[0].img.resolvedResponsive.width}
height={withUrl[0].img.resolvedResponsive.height}
alt={withUrl[0].img.description ?? ""}
class="w-full rounded-sm object-cover aspect-video"
loading="eager"
decoding="async"
/>
</picture>
{:else}
<img
src={withUrl[0].url}
alt={withUrl[0].img.description ?? ""}
class="w-full rounded-sm object-cover aspect-video"
loading="eager"
/>
{/if}
{#if withUrl[0].img.description}
<figcaption class="mt-1 text-sm text-stein-600">{withUrl[0].img.description}</figcaption>
{/if}
+51 -26
View File
@@ -16,7 +16,13 @@ import {
getPageConfigBySlug,
} from "../lib/cms";
import type { NavLinkEntry, FullwidthBannerEntry } from "../lib/cms";
import { resolveImageUrls, ensureTransformedImage } from "../lib/rusty-image";
import {
resolveImageUrls,
ensureTransformedImage,
ensureResponsiveImage,
type ResponsiveImage,
} from "../lib/rusty-image";
import { extractCmsImageField } from "../lib/cms-media-url";
import {
DEFAULT_SOCIAL_IMAGE_URL,
ROW_RESOLVE,
@@ -390,6 +396,10 @@ const siteName =
// Top-Banner nur laden, wenn die Seite einen Banner anzeigen soll (showBannerInLayout + topFullwidthBanner)
let topBanner: FullwidthBannerEntry | null = null;
let topBannerResolvedImages: string[] = [];
let topBannerResponsive: ResponsiveImage | null = null;
let topBannerFocalCss: string | null = null;
/** Responsive Banner-Breiten: mobile (640) bis Retina-Desktop (2400). */
const BANNER_WIDTHS = [640, 960, 1280, 1600, 1920, 2400];
if (
showBannerInLayout &&
topFullwidthBanner != null &&
@@ -398,19 +408,6 @@ if (
try {
if (isResolvedBanner(topFullwidthBanner)) {
topBanner = topFullwidthBanner;
const bannerImages = Array.isArray(topBanner.image)
? topBanner.image
: topBanner.image != null
? [topBanner.image]
: [];
if (bannerImages.length > 0) {
topBannerResolvedImages = await resolveImageUrls(bannerImages, {
width: 2000,
height: 750,
fit: "cover",
format: "webp",
});
}
} else if (
typeof topFullwidthBanner === "string" &&
topFullwidthBanner !== ""
@@ -418,18 +415,43 @@ if (
topBanner = await getFullwidthBannerBySlug(topFullwidthBanner, {
locale: "de",
});
const bannerImages = Array.isArray(topBanner?.image)
? topBanner.image
: topBanner?.image != null
? [topBanner.image]
: [];
if (bannerImages.length > 0) {
topBannerResolvedImages = await resolveImageUrls(bannerImages, {
width: 2000,
height: 750,
fit: "cover",
format: "webp",
});
}
const bannerImages = Array.isArray(topBanner?.image)
? topBanner.image
: topBanner?.image != null
? [topBanner.image]
: [];
if (bannerImages.length > 0) {
const primary = extractCmsImageField(bannerImages[0]);
if (primary) {
// Responsives `<picture>` fürs erste Banner-Bild: original Seitenverhältnis,
// Mehr-Breiten-srcset + WebP/JPEG-Fallback, Focal-Point durchgereicht.
// AVIF bewusst weggelassen — Encoder ist server-seitig 5-10× langsamer als
// WebP, der Bandbreitengewinn rechtfertigt die Build-Zeit hier nicht.
// CSS `object-cover` (siehe TopBanner) kümmert sich um den Viewport-Crop;
// Focal landet zusätzlich als `object-position`, damit mobile (square) das Motiv trifft.
try {
topBannerResponsive = await ensureResponsiveImage(primary.url, {
widths: BANNER_WIDTHS,
fit: "contain",
formats: ["webp", "jpeg"],
quality: 78,
focal: primary.focal,
});
topBannerResolvedImages = [topBannerResponsive.fallback];
} catch {
// Fallback: klassisches Einzelbild
topBannerResolvedImages = await resolveImageUrls(bannerImages, {
width: 2000,
height: 750,
fit: "cover",
format: "webp",
});
}
if (primary.focal) {
topBannerFocalCss = `${(primary.focal.x * 100).toFixed(2)}% ${(primary.focal.y * 100).toFixed(2)}%`;
}
}
}
} catch {
@@ -676,6 +698,9 @@ const cmsFromCache = getCmsFromCache();
<TopBanner
banner={topBanner}
resolvedImages={topBannerResolvedImages}
responsive={topBannerResponsive}
focalCss={topBannerFocalCss}
sizes="100vw"
headline={headline}
subheadline={subheadline}
/>
+5
View File
@@ -6,6 +6,7 @@
import type { BlockLayout } from "./block-layout";
import type { components } from "./cms-api.generated";
import type { ResponsiveImage } from "./rusty-image-types";
/** Aufgelöster Block vom CMS (mit _type, _slug + typ-spezifische Felder). */
export type ResolvedBlock = {
@@ -104,6 +105,8 @@ export interface ImageBlockData {
};
/** Nach resolveContentImages: lokaler Pfad (public) zum transformierten Bild. */
resolvedImageSrc?: string;
/** Nach resolveContentImages: responsive `<picture>`-Quelle (multi-width × multi-format). */
resolvedResponsive?: ResponsiveImage;
caption?: string;
aspectRatio?: number;
layout?: BlockLayout;
@@ -118,6 +121,8 @@ export interface ImageGalleryImage {
file?: { url?: string };
/** Nach resolveContentImages: lokaler Pfad zum transformierten Bild (max. Breite, Ratio erhalten). */
resolvedSrc?: string;
/** Nach resolveContentImages: responsive `<picture>`-Quelle. */
resolvedResponsive?: ResponsiveImage;
}
/** Bildergalerie (_type: "image_gallery"). images: Array von img-Objekten (src, description). */
+39 -12
View File
@@ -9,6 +9,7 @@ import {
} from "./cms";
import type { RowContentLayout } from "./block-types";
import type { CalendarItemData } from "./block-types";
import type { ResponsiveImage } from "./rusty-image-types";
/** rusty-image nur dynamisch importieren, damit client-seitige Importe (z. B. PostOverviewBlock) nicht node:crypto in den Browser ziehen. */
/** Aus der Tag-API: Anzeigename plus optionales Icon und Farbe (CMS-Felder icon / color). */
@@ -138,6 +139,33 @@ export function getPostImageUrl(
: null;
}
/**
* Wie `getPostImageUrl`, gibt zusätzlich focal + alt zurück (wenn im CMS gesetzt).
* Focal-Point steuert den Crop bei aspect-ratio-/cover-Transforms.
*/
export function getPostImageField(
postImage: PostEntry["postImage"],
): { url: string; focal?: { x: number; y: number }; alt?: string } | null {
const url = getPostImageUrl(postImage);
if (!url) return null;
if (!postImage || typeof postImage === "string") return { url };
const obj = postImage as {
focal?: { x?: unknown; y?: unknown };
alt?: unknown;
};
const fx =
typeof obj.focal?.x === "number" && Number.isFinite(obj.focal.x)
? Math.max(0, Math.min(1, obj.focal.x))
: undefined;
const fy =
typeof obj.focal?.y === "number" && Number.isFinite(obj.focal.y)
? Math.max(0, Math.min(1, obj.focal.y))
: undefined;
const focal = fx != null && fy != null ? { x: fx, y: fy } : undefined;
const alt = typeof obj.alt === "string" ? obj.alt : undefined;
return { url, ...(focal ? { focal } : {}), ...(alt ? { alt } : {}) };
}
/** Filtert Posts: behält nur solche, die mindestens einen der Tag-Slugs haben. */
export function filterPostsByTagSlugs(
posts: PostEntry[],
@@ -187,6 +215,7 @@ type PostWithEvent = PostEntry & {
isEvent?: boolean;
eventDate?: string;
_resolvedImageUrl?: string;
_resolvedResponsive?: ResponsiveImage;
};
export function buildPostSearchIndex(posts: PostEntry[]): PostSearchIndexEntry[] {
@@ -325,19 +354,17 @@ export async function resolvePostOverviewBlocks(
for (const post of posts) resolvePostTagsInPost(post, tagsMap);
}
for (const post of item.postsResolved as PostEntry[]) {
const raw = getPostImageUrl(post.postImage);
if (raw && import.meta.env.SSR) {
const field = getPostImageField(post.postImage);
if (field && import.meta.env.SSR) {
try {
const { ensureTransformedImage } = await import("./rusty-image");
const url = await ensureTransformedImage(raw, {
width: 400,
height: 267,
fit: "cover",
format: "webp",
});
(
post as PostEntry & { _resolvedImageUrl?: string }
)._resolvedImageUrl = url;
const { resolvePostCardResponsive } = await import("./server-image");
const responsive = await resolvePostCardResponsive(field);
const target = post as PostEntry & {
_resolvedImageUrl?: string;
_resolvedResponsive?: ResponsiveImage;
};
target._resolvedResponsive = responsive;
target._resolvedImageUrl = responsive.fallback;
} catch {
// Bild optional
}
+463 -14614
View File
File diff suppressed because it is too large Load Diff
+48 -6
View File
@@ -49,26 +49,68 @@ export function normalizeMediaUrl(url: string): string {
return u;
}
/** Focal-Point aus RustyCMS-`image`-Field ({x,y} in 0..1). */
export interface CmsFocal {
x: number;
y: number;
}
/** Extrahierter Bildfeld-Inhalt: absolute URL + optional focal + alt. */
export interface CmsImageField {
url: string;
focal?: CmsFocal;
alt?: string;
}
/**
* Wie Image-Block / Galerie / Organisation-Logo: `string` oder Objekt mit `src` / `file.url`.
* Liefert eine für `ensureTransformedImage` nutzbare URL (nach normalizeMediaUrl).
*/
export function absoluteUrlFromCmsImageField(field: unknown): string | null {
return extractCmsImageField(field)?.url ?? null;
}
/**
* Wie `absoluteUrlFromCmsImageField`, gibt zusätzlich focal + alt zurück.
* Für RustyCMS `type: "image"` Felder: `{ src, alt?, focal?: {x,y}, ... }`.
*/
export function extractCmsImageField(field: unknown): CmsImageField | null {
if (typeof field === "string") {
const s = field.trim();
if (!s) return null;
return normalizeMediaUrl(s.startsWith("//") ? `https:${s}` : s);
const url = normalizeMediaUrl(s.startsWith("//") ? `https:${s}` : s);
return { url };
}
if (field && typeof field === "object") {
const o = field as { src?: string; file?: { url?: string } };
const u =
const o = field as {
src?: string;
file?: { url?: string };
fields?: { file?: { url?: string } };
focal?: { x?: unknown; y?: unknown };
alt?: unknown;
};
const raw =
typeof o.src === "string" && o.src.trim()
? o.src
: typeof o.file?.url === "string" && o.file.url.trim()
? o.file.url
: undefined;
if (!u) return null;
return normalizeMediaUrl(u.startsWith("//") ? `https:${u}` : u);
: typeof o.fields?.file?.url === "string" && o.fields.file.url.trim()
? o.fields.file.url
: undefined;
if (!raw) return null;
const url = normalizeMediaUrl(raw.startsWith("//") ? `https:${raw}` : raw);
const fx =
typeof o.focal?.x === "number" && Number.isFinite(o.focal.x)
? Math.max(0, Math.min(1, o.focal.x))
: undefined;
const fy =
typeof o.focal?.y === "number" && Number.isFinite(o.focal.y)
? Math.max(0, Math.min(1, o.focal.y))
: undefined;
const focal =
fx !== undefined && fy !== undefined ? { x: fx, y: fy } : undefined;
const alt = typeof o.alt === "string" ? o.alt : undefined;
return focal ? { url, focal, alt } : alt !== undefined ? { url, alt } : { url };
}
return null;
}
+18
View File
@@ -0,0 +1,18 @@
/** Pure type definitions related to rusty-image. Lives in a separate file so
* client (Svelte) components can import the shape without dragging the
* implementation (which uses `node:crypto`/`node:fs`) into the browser bundle. */
/** Responsive `<picture>` source set: per-format srcset + fallback + dimensions. */
export interface ResponsiveImage {
/** One srcset string per output format. Order = `<source>` order. */
sources: { type: string; srcset: string }[];
/** Fallback `<img src>` (typically the largest jpeg variant). */
fallback: string;
/** Render dimensions for the `<img>` width/height attrs (prevents CLS). */
width: number;
height: number;
/** All width steps that were actually generated. */
widths: number[];
/** Optional LQIP (Low-Quality Image Placeholder) as a data-URL, decoded from blurhash. */
lqip?: string;
}
+340 -34
View File
@@ -9,8 +9,10 @@ import fs from "node:fs/promises";
import path from "node:path";
import {
absoluteUrlFromCmsImageField,
extractCmsImageField,
getCmsBaseUrl,
normalizeMediaUrl,
type CmsFocal,
} from "./cms-media-url";
/** Für ältere Importe von `rusty-image`; neue Nutzung: `./cms-media-url`. */
@@ -26,8 +28,12 @@ export interface RustyImageTransformParams {
fit?: "fill" | "contain" | "cover";
/** Ausgabeformat */
format?: "jpeg" | "png" | "webp" | "avif";
/** JPEG-Qualität 1100 */
/** JPEG/WebP-Qualität 1100 */
quality?: number;
/** Focal-Point X (0..1) für `fit: cover` + `ar` → Crop um Motiv zentrieren. */
focalX?: number;
/** Focal-Point Y (0..1). */
focalY?: number;
}
const DEFAULT_FORMAT = "jpeg";
@@ -65,11 +71,19 @@ export function hashForTransform(
fit: params.fit ?? "contain",
format: params.format ?? DEFAULT_FORMAT,
quality: params.quality ?? 85,
fx: normalizeFocalForHash(params.focalX),
fy: normalizeFocalForHash(params.focalY),
};
const payload = JSON.stringify(normalized);
return createHash("sha256").update(payload).digest("hex").slice(0, 20);
}
/** Focal auf 4 Nachkommastellen runden, damit minimale Rundungs-Wackler nicht den Hash ändern. */
function normalizeFocalForHash(v: number | undefined): number | undefined {
if (v == null || !Number.isFinite(v)) return undefined;
return Math.round(Math.max(0, Math.min(1, v)) * 10000) / 10000;
}
/**
* Stellt sicher, dass das transformierte Bild in public existiert.
* Wenn nicht: GET an RustyCMS /api/transform, Ergebnis in public/images/transformed/<hash>.<ext> schreiben.
@@ -139,6 +153,10 @@ export async function ensureTransformedImage(
if (params.format != null) searchParams.set("format", params.format);
if (params.quality != null)
searchParams.set("quality", String(params.quality));
if (params.focalX != null && Number.isFinite(params.focalX))
searchParams.set("fx", String(normalizeFocalForHash(params.focalX)));
if (params.focalY != null && Number.isFinite(params.focalY))
searchParams.set("fy", String(normalizeFocalForHash(params.focalY)));
const transformUrl = `${baseUrl}/api/transform?${searchParams.toString()}`;
console.log("[rusty-image] GET", transformUrl);
@@ -166,7 +184,7 @@ type ImageArrayItem = string | { src?: string };
/**
* Löst ein Array von Bild-URLs/Referenzen über die Transform-API auf.
* Akzeptiert string[] oder Objekte mit src (z. B. { _type: "img", src, description }).
* Gibt ein Array von öffentlichen Pfaden zurück (z. B. für Fullwidth-Banner).
* Focal-Point wird automatisch pro Eintrag übernommen, wenn gesetzt.
*/
export async function resolveImageUrls(
items: ImageArrayItem[],
@@ -174,30 +192,285 @@ export async function resolveImageUrls(
): Promise<string[]> {
const result: string[] = [];
for (const item of items) {
const u = absoluteUrlFromCmsImageField(item);
if (!u) continue;
const extracted = extractCmsImageField(item);
if (!extracted) continue;
const perItem: RustyImageTransformParams = {
...params,
focalX: params.focalX ?? extracted.focal?.x,
focalY: params.focalY ?? extracted.focal?.y,
};
try {
result.push(await ensureTransformedImage(u, params));
result.push(await ensureTransformedImage(extracted.url, perItem));
} catch (e) {
console.warn("[rusty-image] resolve failed for", u, e);
console.warn("[rusty-image] resolve failed for", extracted.url, e);
}
}
return result;
}
/** Holt die Bild-URL aus einem Image-Block (image oder img: string, src oder file.url). */
function getImageBlockUrl(block: {
image?: string | { src?: string; file?: { url?: string }; title?: string };
img?: string | { src?: string; file?: { url?: string }; title?: string };
}): string | null {
return absoluteUrlFromCmsImageField(block.image ?? block.img);
/** AssetInfo vom RustyCMS ?meta=true Endpoint (nur benötigte Felder). */
interface AssetMeta {
width?: number;
height?: number;
hasAlpha?: boolean;
mimeType?: string;
blurhash?: string;
}
/** URL aus einem Galerie-Bildeintrag (src oder file.url). */
function getGalleryImageUrl(
img: { src?: string; file?: { url?: string } },
): string | null {
return absoluteUrlFromCmsImageField(img);
/** Prozess-Cache: gleiche URL nicht doppelt abfragen pro Build/SSR. */
const metaCache = new Map<string, Promise<AssetMeta | null>>();
/**
* Holt Asset-Metadaten (width/height/...) über `GET /api/assets/<path>?meta=true`.
* Nur für RustyCMS-Asset-URLs; andere Hosts null. Fehler null (nicht werfen).
*/
export async function fetchAssetMeta(url: string): Promise<AssetMeta | null> {
const absoluteUrl = normalizeMediaUrl(url);
if (!absoluteUrl.startsWith("http")) return null;
// Nur CMS-Assets unterstützen das meta-Flag.
if (!absoluteUrl.includes("/api/assets/")) return null;
const cached = metaCache.get(absoluteUrl);
if (cached) return cached;
const job = (async (): Promise<AssetMeta | null> => {
try {
const u = new URL(absoluteUrl);
u.searchParams.set("meta", "true");
const res = await fetch(u.toString());
if (!res.ok) return null;
const info = (await res.json()) as {
width?: number;
height?: number;
has_alpha?: boolean;
hasAlpha?: boolean;
mime_type?: string;
mimeType?: string;
blurhash?: string;
};
return {
width: typeof info.width === "number" ? info.width : undefined,
height: typeof info.height === "number" ? info.height : undefined,
hasAlpha:
typeof info.hasAlpha === "boolean"
? info.hasAlpha
: typeof info.has_alpha === "boolean"
? info.has_alpha
: undefined,
mimeType: info.mimeType ?? info.mime_type,
blurhash:
typeof info.blurhash === "string" && info.blurhash.length > 0
? info.blurhash
: undefined,
};
} catch (e) {
console.warn("[rusty-image] meta fetch failed for", absoluteUrl, e);
return null;
}
})();
metaCache.set(absoluteUrl, job);
return job;
}
/** Ergebnis eines `<picture>`-Aufbaus: mehrere Formate + Fallback + Dimensionen.
* Identisch zu `ResponsiveImage` in `./rusty-image-types`; doppelt definiert,
* damit client-seitige Importe (Svelte) den Typ ohne diese server-only Datei
* (node:crypto, node:fs) bekommen können. */
export interface ResponsiveImage {
sources: { type: string; srcset: string }[];
fallback: string;
width: number;
height: number;
widths: number[];
lqip?: string;
}
/** Optionen für `ensureResponsiveImage`. */
export interface ResponsiveImageOptions {
/** Kandidaten-Breiten, werden gegen Original-Breite geklampt (keine Upscales). */
widths?: number[];
/** Seitenverhältnis (wie bei Transform: "16:9"). Wenn gesetzt → Höhe = width / ar. */
ar?: string;
/** Fit-Modus (Default: `cover` bei ar gesetzt, sonst `contain`). */
fit?: "fill" | "contain" | "cover";
/** Formate in Reihenfolge. Erstes = bevorzugt (AVIF). Fallback immer als `<img src>`. */
formats?: ("avif" | "webp" | "jpeg" | "png")[];
/** Qualität (1100). */
quality?: number;
/** Focal-Point (0..1). */
focal?: CmsFocal;
/** LQIP (Blurhash → data-URL) erzeugen, wenn Meta einen Blurhash liefert. Default: true. */
lqip?: boolean;
}
const DEFAULT_WIDTHS = [640, 960, 1280, 1600, 1920, 2400];
// AVIF intentionally omitted from the default — server-side AVIF encoding is
// 5-10× slower than WebP and the bandwidth win is marginal for this site.
// Callers can still opt in via `formats: ["avif", ...]` per call.
const DEFAULT_FORMATS: ("avif" | "webp" | "jpeg")[] = ["webp", "jpeg"];
function parseAr(ar: string | undefined): number | null {
if (!ar) return null;
const m = ar.split(":");
if (m.length !== 2) return null;
const a = Number(m[0]);
const b = Number(m[1]);
if (!Number.isFinite(a) || !Number.isFinite(b) || a <= 0 || b <= 0) return null;
return a / b;
}
const MIME_FOR_FORMAT: Record<string, string> = {
avif: "image/avif",
webp: "image/webp",
jpeg: "image/jpeg",
png: "image/png",
};
/** In-memory Cache: gleicher Blurhash → selbe data-URL (einmal dekodieren pro Build). */
const blurhashCache = new Map<string, Promise<string | null>>();
/**
* Dekodiert einen Blurhash zu einer winzigen JPEG-data-URL (typ. < 1 KB).
* 32×32 RGBA-Pixel sharp raw JPEG (q=40) base64. Rendering-seitig als
* `background-image` mit CSS-`filter: blur()` + `scale()`-Bleed genutzt.
* Fehler null (LQIP ist optional).
*/
export async function blurhashToDataUrl(
hash: string,
targetWidth = 32,
targetHeight = 32,
): Promise<string | null> {
if (!hash || typeof hash !== "string") return null;
const key = `${hash}|${targetWidth}x${targetHeight}`;
const cached = blurhashCache.get(key);
if (cached) return cached;
const job = (async (): Promise<string | null> => {
try {
const { decode } = await import("blurhash");
const sharpMod = await import("sharp");
const sharp = sharpMod.default ?? sharpMod;
const pixels = decode(hash, targetWidth, targetHeight);
const buffer = await sharp(Buffer.from(pixels), {
raw: {
width: targetWidth,
height: targetHeight,
channels: 4,
},
})
.jpeg({ quality: 40, progressive: false })
.toBuffer();
return `data:image/jpeg;base64,${buffer.toString("base64")}`;
} catch (e) {
console.warn("[rusty-image] blurhash decode failed:", e);
return null;
}
})();
blurhashCache.set(key, job);
return job;
}
/**
* Baut eine responsive Bildquelle: pro Format ein `srcset` über mehrere Breiten,
* plus einen Fallback-`src`. Original-Dimensionen werden vorher via `?meta=true`
* abgefragt, damit keine Upscales erzeugt werden und das `<img>` exakte
* width/height-Attribute bekommt (kein CLS).
*/
export async function ensureResponsiveImage(
url: string,
opts: ResponsiveImageOptions = {},
): Promise<ResponsiveImage> {
const absoluteUrl = normalizeMediaUrl(url);
const requestedWidths = opts.widths && opts.widths.length > 0 ? opts.widths : DEFAULT_WIDTHS;
const formats = opts.formats && opts.formats.length > 0 ? opts.formats : DEFAULT_FORMATS;
const quality = opts.quality ?? 80;
const arValue = parseAr(opts.ar);
const effectiveFit: "fill" | "contain" | "cover" =
opts.fit ?? (opts.ar ? "cover" : "contain");
const meta = await fetchAssetMeta(absoluteUrl);
const originalWidth = meta?.width;
const originalHeight = meta?.height;
// Keine Upscales: alles > originalWidth weglassen; wenn nichts übrig, originalWidth nehmen.
let widths = requestedWidths
.map((w) => Math.round(w))
.filter((w) => w > 0)
.filter((w) => (originalWidth ? w <= originalWidth : true));
if (widths.length === 0) {
widths = originalWidth
? [originalWidth]
: [requestedWidths[requestedWidths.length - 1] ?? 1280];
}
widths = Array.from(new Set(widths)).sort((a, b) => a - b);
const largestWidth = widths[widths.length - 1];
const renderHeight = arValue
? Math.round(largestWidth / arValue)
: originalHeight && originalWidth
? Math.round((largestWidth * originalHeight) / originalWidth)
: Math.round(largestWidth * 0.5625); // 16:9 als letzter Fallback
const sources: { type: string; srcset: string }[] = [];
let fallback = "";
for (const format of formats) {
const parts: string[] = [];
for (const w of widths) {
const params: RustyImageTransformParams = {
width: w,
fit: effectiveFit,
format,
quality,
focalX: opts.focal?.x,
focalY: opts.focal?.y,
};
if (opts.ar) params.ar = opts.ar;
// Bei fit=cover + ar: Höhe explizit setzen, sonst würde nur „ar-Crop" greifen ohne Skalierung.
if (arValue && (effectiveFit === "cover" || effectiveFit === "fill")) {
params.height = Math.round(w / arValue);
}
try {
const path = await ensureTransformedImage(absoluteUrl, params);
parts.push(`${path} ${w}w`);
fallback = path;
} catch (e) {
console.warn("[rusty-image] responsive variant failed", format, w, e);
}
}
if (parts.length > 0) {
sources.push({ type: MIME_FOR_FORMAT[format] ?? "image/jpeg", srcset: parts.join(", ") });
}
}
let lqip: string | undefined;
if ((opts.lqip ?? true) && meta?.blurhash) {
const dataUrl = await blurhashToDataUrl(meta.blurhash);
if (dataUrl) lqip = dataUrl;
}
return {
sources,
fallback,
width: largestWidth,
height: renderHeight,
widths,
...(lqip ? { lqip } : {}),
};
}
/** Holt URL + focal aus einem Image-Block (image oder img). */
function getImageBlockField(block: {
image?: unknown;
img?: unknown;
}) {
return extractCmsImageField(block.image ?? block.img);
}
/** URL + focal aus einem Galerie-Bildeintrag (src oder file.url). */
function getGalleryImageField(
img: unknown,
) {
return extractCmsImageField(img);
}
/** RowContentLayout-typ: Zeilen mit content-Arrays. */
@@ -427,33 +700,66 @@ export async function resolveContentImages(
continue;
}
if (block._type === "image") {
const url = getImageBlockUrl(
block as {
image?: string | { file?: { url?: string }; title?: string };
img?: string | { file?: { url?: string }; title?: string };
},
);
if (!url) continue;
const field = getImageBlockField(block);
if (!field) continue;
try {
block.resolvedImageSrc = await ensureTransformedImage(
url,
transformParams,
);
// Build responsive variants. Body-image widths target the typical
// article column on mobile/tablet/desktop; AVIF/WebP/JPEG fallback.
// Conservative variant matrix to keep build time bounded — body images
// get 2 widths × WebP/JPEG (skip AVIF here since AVIF encoding on the
// CMS is the slowest step). Hero/banner uses a richer set in Layout.astro.
const responsive = await ensureResponsiveImage(field.url, {
widths: [600, 1200],
formats: ["webp", "jpeg"],
quality: 80,
focal: field.focal,
...(transformParams.ar ? { ar: transformParams.ar } : {}),
...(transformParams.fit ? { fit: transformParams.fit } : {}),
});
(block as unknown as { resolvedResponsive?: ResponsiveImage }).resolvedResponsive = responsive;
block.resolvedImageSrc = responsive.fallback;
} catch (e) {
console.warn("[rusty-image] resolve failed for", url, e);
console.warn("[rusty-image] resolve failed for", field.url, e);
// Fallback: legacy single transform so the block still renders.
try {
block.resolvedImageSrc = await ensureTransformedImage(field.url, {
...transformParams,
focalX: transformParams.focalX ?? field.focal?.x,
focalY: transformParams.focalY ?? field.focal?.y,
});
} catch {
// give up — ImageBlock shows "Bild nicht verfügbar"
}
}
continue;
}
if (block._type === "image_gallery" && Array.isArray(block.images)) {
for (const img of block.images) {
if (!img || typeof img !== "object") continue;
const url = getGalleryImageUrl(img);
if (!url) continue;
const field = getGalleryImageField(img);
if (!field) continue;
try {
(img as { resolvedSrc?: string }).resolvedSrc =
await ensureTransformedImage(url, galleryParams);
const responsive = await ensureResponsiveImage(field.url, {
widths: [600, 1280],
formats: ["webp", "jpeg"],
quality: 80,
focal: field.focal,
fit: "contain",
});
(img as { resolvedResponsive?: ResponsiveImage }).resolvedResponsive = responsive;
(img as { resolvedSrc?: string }).resolvedSrc = responsive.fallback;
} catch (e) {
console.warn("[rusty-image] gallery resolve failed for", url, e);
console.warn("[rusty-image] gallery responsive failed for", field.url, e);
try {
(img as { resolvedSrc?: string }).resolvedSrc =
await ensureTransformedImage(field.url, {
...galleryParams,
focalX: galleryParams.focalX ?? field.focal?.x,
focalY: galleryParams.focalY ?? field.focal?.y,
});
} catch {
// give up
}
}
}
continue;
+19
View File
@@ -0,0 +1,19 @@
/** Server-only helpers around `rusty-image`. Lives in its own file so client
* (Svelte) components that import other utilities from `blog-utils` don't
* accidentally pull `node:crypto`/`node:fs` into the browser bundle. */
import { ensureResponsiveImage, type ResponsiveImage } from "./rusty-image";
/** Build the standard post-card responsive image (3:2 cover, WebP/JPEG, mobile→desktop widths). */
export async function resolvePostCardResponsive(
field: { url: string; focal?: { x: number; y: number } },
): Promise<ResponsiveImage> {
return ensureResponsiveImage(field.url, {
widths: [400, 800],
formats: ["webp", "jpeg"],
ar: "3:2",
fit: "cover",
quality: 78,
focal: field.focal,
});
}
+6 -4
View File
@@ -22,7 +22,7 @@ import {
resolvePostTagsInPost,
resolvePostOverviewBlocks,
resolveSearchableTextBlocks,
getPostImageUrl,
getPostImageField,
formatPostDate,
} from "../../lib/blog-utils";
import { POST_RESOLVE } from "../../lib/constants";
@@ -60,12 +60,14 @@ const tagsMap = await resolvePostOverviewBlocks(post);
await resolveSearchableTextBlocks(post, tagsMap);
resolvePostTagsInPost(post, tagsMap);
const rawPostImageUrl = getPostImageUrl(post.postImage);
const postImageUrl = rawPostImageUrl
? await ensureTransformedImage(rawPostImageUrl, {
const postImageField = getPostImageField(post.postImage);
const postImageUrl = postImageField
? await ensureTransformedImage(postImageField.url, {
width: 150,
height: 200,
fit: "cover",
focalX: postImageField.focal?.x,
focalY: postImageField.focal?.y,
})
: null;
+11 -6
View File
@@ -14,7 +14,7 @@ import {
getPostsPerPage,
getTagsMap,
resolvePostTagsInPost,
getPostImageUrl,
getPostImageField,
buildPostSearchIndex,
selectUpcomingEvents,
} from '../../lib/blog-utils';
@@ -32,13 +32,18 @@ try {
const tagsMap = await getTagsMap();
for (const p of posts) resolvePostTagsInPost(p, tagsMap);
// Resolve post card images
const { ensureTransformedImage } = await import('../../lib/rusty-image');
const { resolvePostCardResponsive } = await import('../../lib/server-image');
for (const post of posts) {
const raw = getPostImageUrl(post.postImage);
if (raw) {
const field = getPostImageField(post.postImage);
if (field) {
try {
const url = await ensureTransformedImage(raw, { width: 400, height: 267, fit: 'cover', format: 'webp' });
(post as typeof post & { _resolvedImageUrl?: string })._resolvedImageUrl = url;
const responsive = await resolvePostCardResponsive(field);
const target = post as typeof post & {
_resolvedImageUrl?: string;
_resolvedResponsive?: Awaited<ReturnType<typeof resolvePostCardResponsive>>;
};
target._resolvedResponsive = responsive;
target._resolvedImageUrl = responsive.fallback;
} catch { /* image optional */ }
}
}
+11 -6
View File
@@ -9,7 +9,7 @@ import {
getTotalPages,
paginate,
getPostsPerPage,
getPostImageUrl,
getPostImageField,
buildPostSearchIndex,
selectUpcomingEvents,
} from '../../../lib/blog-utils';
@@ -43,13 +43,18 @@ let cmsError: string | null = null;
try {
[posts, tags] = await Promise.all([getPosts({ resolve: ['postImage'] }), getTags()]);
posts = filterHiddenPosts(sortPostsByDate(posts));
const { ensureTransformedImage } = await import('../../../lib/rusty-image');
const { resolvePostCardResponsive } = await import('../../../lib/server-image');
for (const post of posts) {
const raw = getPostImageUrl(post.postImage);
if (raw) {
const field = getPostImageField(post.postImage);
if (field) {
try {
const url = await ensureTransformedImage(raw, { width: 400, height: 267, fit: 'cover', format: 'webp' });
(post as typeof post & { _resolvedImageUrl?: string })._resolvedImageUrl = url;
const responsive = await resolvePostCardResponsive(field);
const target = post as typeof post & {
_resolvedImageUrl?: string;
_resolvedResponsive?: Awaited<ReturnType<typeof resolvePostCardResponsive>>;
};
target._resolvedResponsive = responsive;
target._resolvedImageUrl = responsive.fallback;
} catch { /* image optional */ }
}
}
+11 -4
View File
@@ -11,7 +11,7 @@ import {
getPostsPerPage,
getTagsMap,
resolvePostTagsInPost,
getPostImageUrl,
getPostImageField,
buildPostSearchIndex,
selectUpcomingEvents,
} from '../../../lib/blog-utils';
@@ -56,10 +56,17 @@ try {
for (const p of posts) resolvePostTagsInPost(p, tagsMap);
const { ensureTransformedImage } = await import('../../../lib/rusty-image');
for (const post of posts) {
const raw = getPostImageUrl(post.postImage);
if (raw) {
const field = getPostImageField(post.postImage);
if (field) {
try {
const url = await ensureTransformedImage(raw, { width: 400, height: 267, fit: 'cover', format: 'webp' });
const url = await ensureTransformedImage(field.url, {
width: 400,
height: 267,
fit: 'cover',
format: 'webp',
focalX: field.focal?.x,
focalY: field.focal?.y,
});
(post as typeof post & { _resolvedImageUrl?: string })._resolvedImageUrl = url;
} catch { /* image optional */ }
}
+11 -6
View File
@@ -11,7 +11,7 @@ import {
getPostsPerPage,
getTagsMap,
resolvePostTagsInPost,
getPostImageUrl,
getPostImageField,
buildPostSearchIndex,
selectUpcomingEvents,
} from '../../../../../lib/blog-utils';
@@ -66,13 +66,18 @@ try {
posts = filterHiddenPosts(sortPostsByDate(posts));
const tagsMap = await getTagsMap();
for (const p of posts) resolvePostTagsInPost(p, tagsMap);
const { ensureTransformedImage } = await import('../../../../../lib/rusty-image');
const { resolvePostCardResponsive } = await import('../../../../../lib/server-image');
for (const post of posts) {
const raw = getPostImageUrl(post.postImage);
if (raw) {
const field = getPostImageField(post.postImage);
if (field) {
try {
const url = await ensureTransformedImage(raw, { width: 400, height: 267, fit: 'cover', format: 'webp' });
(post as typeof post & { _resolvedImageUrl?: string })._resolvedImageUrl = url;
const responsive = await resolvePostCardResponsive(field);
const target = post as typeof post & {
_resolvedImageUrl?: string;
_resolvedResponsive?: Awaited<ReturnType<typeof resolvePostCardResponsive>>;
};
target._resolvedResponsive = responsive;
target._resolvedImageUrl = responsive.fallback;
} catch { /* image optional */ }
}
}
+5
View File
@@ -1933,6 +1933,11 @@ balanced-match@^1.0.0:
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
blurhash@^2.0.5:
version "2.0.5"
resolved "https://registry.yarnpkg.com/blurhash/-/blurhash-2.0.5.tgz#efde729fc14a2f03571a6aa91b49cba80d1abe4b"
integrity sha512-cRygWd7kGBQO3VEhPiTgq4Wc43ctsM+o46urrmPOiuAe+07fzlSB9OJVdpgDL0jPqXUVQ9ht7aq7kxOeJHRK+w==
boolbase@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e"