feat(images): responsive <picture> srcset for posts/blocks, drop AVIF for build speed
Deploy to Firebase Hosting / deploy (push) Failing after 2m4s
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:
@@ -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",
|
||||
|
||||
@@ -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 ?? ""}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 1–100 */
|
||||
/** JPEG/WebP-Qualität 1–100 */
|
||||
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 (1–100). */
|
||||
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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 */ }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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,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 */ }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user