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",
|
"@iconify/svelte": "^5.2.1",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"astro": "^6.1.1",
|
"astro": "^6.1.1",
|
||||||
|
"blurhash": "^2.0.5",
|
||||||
"json5": "^2.2.3",
|
"json5": "^2.2.3",
|
||||||
"marked": "^17.0.2",
|
"marked": "^17.0.2",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PostEntry } from "../lib/cms";
|
import type { PostEntry } from "../lib/cms";
|
||||||
|
import type { ResponsiveImage } from "../lib/rusty-image-types";
|
||||||
import PostMeta from "./PostMeta.svelte";
|
import PostMeta from "./PostMeta.svelte";
|
||||||
import EventBadges from "./EventBadges.svelte";
|
import EventBadges from "./EventBadges.svelte";
|
||||||
|
|
||||||
type PostWithImage = PostEntry & { _resolvedImageUrl?: string };
|
type PostWithImage = PostEntry & {
|
||||||
|
_resolvedImageUrl?: string;
|
||||||
|
_resolvedResponsive?: ResponsiveImage;
|
||||||
|
};
|
||||||
|
|
||||||
let {
|
let {
|
||||||
post,
|
post,
|
||||||
@@ -16,6 +20,7 @@
|
|||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const resolvedImg = $derived(post._resolvedImageUrl);
|
const resolvedImg = $derived(post._resolvedImageUrl);
|
||||||
|
const responsive = $derived(post._resolvedResponsive);
|
||||||
|
|
||||||
type PostWithEvent = PostWithImage & {
|
type PostWithEvent = PostWithImage & {
|
||||||
isEvent?: boolean;
|
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"
|
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">
|
<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
|
<img
|
||||||
src={resolvedImg}
|
src={resolvedImg}
|
||||||
alt={post.headline ?? ""}
|
alt={post.headline ?? ""}
|
||||||
|
|||||||
@@ -1,23 +1,67 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
/**
|
/**
|
||||||
* TopBanner: Fullwidth-Banner mit Bild (über RustyCMS-Transform-API aufgelöst), optionalem Text
|
* 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 { marked } from "marked";
|
||||||
import type { FullwidthBannerEntry } from "../lib/cms";
|
import type { FullwidthBannerEntry } from "../lib/cms";
|
||||||
|
|
||||||
marked.setOptions({ gfm: true });
|
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 {
|
interface Props {
|
||||||
banner: FullwidthBannerEntry | null;
|
banner: FullwidthBannerEntry | null;
|
||||||
|
/** Einfache (legacy) einzelne URL(s) — wenn `responsive` fehlt. */
|
||||||
resolvedImages?: string[];
|
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 der Page (Markdown). */
|
||||||
headline?: string;
|
headline?: string;
|
||||||
/** Subheadline der Page (Markdown). */
|
/** Subheadline der Page (Markdown). */
|
||||||
subheadline?: string;
|
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(
|
const headlineHtml = $derived(
|
||||||
headline?.trim() ? (marked.parseInline(headline) as string) : "",
|
headline?.trim() ? (marked.parseInline(headline) as string) : "",
|
||||||
@@ -26,25 +70,54 @@
|
|||||||
subheadline?.trim() ? (marked.parseInline(subheadline) as string) : "",
|
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 hasText = $derived(banner?.text != null && banner.text !== "");
|
||||||
const hasPageTitle = $derived((headline != null && headline !== "") || (subheadline != null && subheadline !== ""));
|
const hasPageTitle = $derived((headline != null && headline !== "") || (subheadline != null && subheadline !== ""));
|
||||||
const showBanner = $derived(hasImage || hasText || hasPageTitle);
|
const showBanner = $derived(hasImage || hasText || hasPageTitle);
|
||||||
|
const altText = $derived(banner?.headline ?? headline ?? "");
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if showBanner}
|
{#if showBanner}
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
{#if hasImage}
|
{#if hasImage}
|
||||||
<div class="w-full overflow-hidden relative flex justify-center items-center shadow-lg border-b border-white">
|
<div
|
||||||
<img
|
class="w-full overflow-hidden relative flex justify-center items-center shadow-lg bg-zinc-200"
|
||||||
src={resolvedImages[0]}
|
style={wrapperStyle}
|
||||||
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"
|
{#if hasResponsive && responsive}
|
||||||
width={1920}
|
<picture>
|
||||||
height={600}
|
{#each responsive.sources as s (s.type)}
|
||||||
loading="eager"
|
<source type={s.type} srcset={s.srcset} sizes={sizes} />
|
||||||
fetchpriority="high"
|
{/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}
|
{#if hasPageTitle}
|
||||||
<div class="absolute inset-0 flex items-center justify-start ">
|
<div class="absolute inset-0 flex items-center justify-start ">
|
||||||
<div class="container-custom mx-auto px-6 relative">
|
<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}>
|
<div class={layoutClasses} data-block-type="image" data-block-slug={block._slug}>
|
||||||
{#if imageUrl}
|
{#if imageUrl}
|
||||||
<figure class="max-w-[400px]">
|
<figure class="max-w-[400px]">
|
||||||
<img
|
{#if block.resolvedResponsive}
|
||||||
src={imageUrl}
|
<picture>
|
||||||
alt={block.caption ?? title ?? ""}
|
{#each block.resolvedResponsive.sources as s (s.type)}
|
||||||
class="w-full rounded-sm"
|
<source type={s.type} srcset={s.srcset} sizes="(max-width: 768px) 100vw, 400px" />
|
||||||
loading="lazy"
|
{/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}
|
{#if block.caption}
|
||||||
<figcaption class="mt-1 text-sm text-zinc-600">{block.caption}</figcaption>
|
<figcaption class="mt-1 text-sm text-zinc-600">{block.caption}</figcaption>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -150,12 +150,29 @@
|
|||||||
<p class="text-sm text-stein-500">{t(T.image_gallery_empty)}</p>
|
<p class="text-sm text-stein-500">{t(T.image_gallery_empty)}</p>
|
||||||
{:else if withUrl.length === 1}
|
{:else if withUrl.length === 1}
|
||||||
<figure class="m-0">
|
<figure class="m-0">
|
||||||
<img
|
{#if withUrl[0].img.resolvedResponsive}
|
||||||
src={withUrl[0].url}
|
<picture>
|
||||||
alt={withUrl[0].img.description ?? ""}
|
{#each withUrl[0].img.resolvedResponsive.sources as s (s.type)}
|
||||||
class="w-full rounded-sm object-cover aspect-video"
|
<source type={s.type} srcset={s.srcset} sizes="(max-width: 768px) 100vw, 1024px" />
|
||||||
loading="eager"
|
{/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}
|
{#if withUrl[0].img.description}
|
||||||
<figcaption class="mt-1 text-sm text-stein-600">{withUrl[0].img.description}</figcaption>
|
<figcaption class="mt-1 text-sm text-stein-600">{withUrl[0].img.description}</figcaption>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
+51
-26
@@ -16,7 +16,13 @@ import {
|
|||||||
getPageConfigBySlug,
|
getPageConfigBySlug,
|
||||||
} from "../lib/cms";
|
} from "../lib/cms";
|
||||||
import type { NavLinkEntry, FullwidthBannerEntry } 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 {
|
import {
|
||||||
DEFAULT_SOCIAL_IMAGE_URL,
|
DEFAULT_SOCIAL_IMAGE_URL,
|
||||||
ROW_RESOLVE,
|
ROW_RESOLVE,
|
||||||
@@ -390,6 +396,10 @@ const siteName =
|
|||||||
// Top-Banner nur laden, wenn die Seite einen Banner anzeigen soll (showBannerInLayout + topFullwidthBanner)
|
// Top-Banner nur laden, wenn die Seite einen Banner anzeigen soll (showBannerInLayout + topFullwidthBanner)
|
||||||
let topBanner: FullwidthBannerEntry | null = null;
|
let topBanner: FullwidthBannerEntry | null = null;
|
||||||
let topBannerResolvedImages: string[] = [];
|
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 (
|
if (
|
||||||
showBannerInLayout &&
|
showBannerInLayout &&
|
||||||
topFullwidthBanner != null &&
|
topFullwidthBanner != null &&
|
||||||
@@ -398,19 +408,6 @@ if (
|
|||||||
try {
|
try {
|
||||||
if (isResolvedBanner(topFullwidthBanner)) {
|
if (isResolvedBanner(topFullwidthBanner)) {
|
||||||
topBanner = 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 (
|
} else if (
|
||||||
typeof topFullwidthBanner === "string" &&
|
typeof topFullwidthBanner === "string" &&
|
||||||
topFullwidthBanner !== ""
|
topFullwidthBanner !== ""
|
||||||
@@ -418,18 +415,43 @@ if (
|
|||||||
topBanner = await getFullwidthBannerBySlug(topFullwidthBanner, {
|
topBanner = await getFullwidthBannerBySlug(topFullwidthBanner, {
|
||||||
locale: "de",
|
locale: "de",
|
||||||
});
|
});
|
||||||
const bannerImages = Array.isArray(topBanner?.image)
|
}
|
||||||
? topBanner.image
|
|
||||||
: topBanner?.image != null
|
const bannerImages = Array.isArray(topBanner?.image)
|
||||||
? [topBanner.image]
|
? topBanner.image
|
||||||
: [];
|
: topBanner?.image != null
|
||||||
if (bannerImages.length > 0) {
|
? [topBanner.image]
|
||||||
topBannerResolvedImages = await resolveImageUrls(bannerImages, {
|
: [];
|
||||||
width: 2000,
|
if (bannerImages.length > 0) {
|
||||||
height: 750,
|
const primary = extractCmsImageField(bannerImages[0]);
|
||||||
fit: "cover",
|
if (primary) {
|
||||||
format: "webp",
|
// 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 {
|
} catch {
|
||||||
@@ -676,6 +698,9 @@ const cmsFromCache = getCmsFromCache();
|
|||||||
<TopBanner
|
<TopBanner
|
||||||
banner={topBanner}
|
banner={topBanner}
|
||||||
resolvedImages={topBannerResolvedImages}
|
resolvedImages={topBannerResolvedImages}
|
||||||
|
responsive={topBannerResponsive}
|
||||||
|
focalCss={topBannerFocalCss}
|
||||||
|
sizes="100vw"
|
||||||
headline={headline}
|
headline={headline}
|
||||||
subheadline={subheadline}
|
subheadline={subheadline}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
import type { BlockLayout } from "./block-layout";
|
import type { BlockLayout } from "./block-layout";
|
||||||
import type { components } from "./cms-api.generated";
|
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). */
|
/** Aufgelöster Block vom CMS (mit _type, _slug + typ-spezifische Felder). */
|
||||||
export type ResolvedBlock = {
|
export type ResolvedBlock = {
|
||||||
@@ -104,6 +105,8 @@ export interface ImageBlockData {
|
|||||||
};
|
};
|
||||||
/** Nach resolveContentImages: lokaler Pfad (public) zum transformierten Bild. */
|
/** Nach resolveContentImages: lokaler Pfad (public) zum transformierten Bild. */
|
||||||
resolvedImageSrc?: string;
|
resolvedImageSrc?: string;
|
||||||
|
/** Nach resolveContentImages: responsive `<picture>`-Quelle (multi-width × multi-format). */
|
||||||
|
resolvedResponsive?: ResponsiveImage;
|
||||||
caption?: string;
|
caption?: string;
|
||||||
aspectRatio?: number;
|
aspectRatio?: number;
|
||||||
layout?: BlockLayout;
|
layout?: BlockLayout;
|
||||||
@@ -118,6 +121,8 @@ export interface ImageGalleryImage {
|
|||||||
file?: { url?: string };
|
file?: { url?: string };
|
||||||
/** Nach resolveContentImages: lokaler Pfad zum transformierten Bild (max. Breite, Ratio erhalten). */
|
/** Nach resolveContentImages: lokaler Pfad zum transformierten Bild (max. Breite, Ratio erhalten). */
|
||||||
resolvedSrc?: string;
|
resolvedSrc?: string;
|
||||||
|
/** Nach resolveContentImages: responsive `<picture>`-Quelle. */
|
||||||
|
resolvedResponsive?: ResponsiveImage;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Bildergalerie (_type: "image_gallery"). images: Array von img-Objekten (src, description). */
|
/** Bildergalerie (_type: "image_gallery"). images: Array von img-Objekten (src, description). */
|
||||||
|
|||||||
+39
-12
@@ -9,6 +9,7 @@ import {
|
|||||||
} from "./cms";
|
} from "./cms";
|
||||||
import type { RowContentLayout } from "./block-types";
|
import type { RowContentLayout } from "./block-types";
|
||||||
import type { CalendarItemData } 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. */
|
/** 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). */
|
/** Aus der Tag-API: Anzeigename plus optionales Icon und Farbe (CMS-Felder icon / color). */
|
||||||
@@ -138,6 +139,33 @@ export function getPostImageUrl(
|
|||||||
: null;
|
: 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. */
|
/** Filtert Posts: behält nur solche, die mindestens einen der Tag-Slugs haben. */
|
||||||
export function filterPostsByTagSlugs(
|
export function filterPostsByTagSlugs(
|
||||||
posts: PostEntry[],
|
posts: PostEntry[],
|
||||||
@@ -187,6 +215,7 @@ type PostWithEvent = PostEntry & {
|
|||||||
isEvent?: boolean;
|
isEvent?: boolean;
|
||||||
eventDate?: string;
|
eventDate?: string;
|
||||||
_resolvedImageUrl?: string;
|
_resolvedImageUrl?: string;
|
||||||
|
_resolvedResponsive?: ResponsiveImage;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function buildPostSearchIndex(posts: PostEntry[]): PostSearchIndexEntry[] {
|
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 posts) resolvePostTagsInPost(post, tagsMap);
|
||||||
}
|
}
|
||||||
for (const post of item.postsResolved as PostEntry[]) {
|
for (const post of item.postsResolved as PostEntry[]) {
|
||||||
const raw = getPostImageUrl(post.postImage);
|
const field = getPostImageField(post.postImage);
|
||||||
if (raw && import.meta.env.SSR) {
|
if (field && import.meta.env.SSR) {
|
||||||
try {
|
try {
|
||||||
const { ensureTransformedImage } = await import("./rusty-image");
|
const { resolvePostCardResponsive } = await import("./server-image");
|
||||||
const url = await ensureTransformedImage(raw, {
|
const responsive = await resolvePostCardResponsive(field);
|
||||||
width: 400,
|
const target = post as PostEntry & {
|
||||||
height: 267,
|
_resolvedImageUrl?: string;
|
||||||
fit: "cover",
|
_resolvedResponsive?: ResponsiveImage;
|
||||||
format: "webp",
|
};
|
||||||
});
|
target._resolvedResponsive = responsive;
|
||||||
(
|
target._resolvedImageUrl = responsive.fallback;
|
||||||
post as PostEntry & { _resolvedImageUrl?: string }
|
|
||||||
)._resolvedImageUrl = url;
|
|
||||||
} catch {
|
} catch {
|
||||||
// Bild optional
|
// 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;
|
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`.
|
* Wie Image-Block / Galerie / Organisation-Logo: `string` oder Objekt mit `src` / `file.url`.
|
||||||
* Liefert eine für `ensureTransformedImage` nutzbare URL (nach normalizeMediaUrl).
|
* Liefert eine für `ensureTransformedImage` nutzbare URL (nach normalizeMediaUrl).
|
||||||
*/
|
*/
|
||||||
export function absoluteUrlFromCmsImageField(field: unknown): string | null {
|
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") {
|
if (typeof field === "string") {
|
||||||
const s = field.trim();
|
const s = field.trim();
|
||||||
if (!s) return null;
|
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") {
|
if (field && typeof field === "object") {
|
||||||
const o = field as { src?: string; file?: { url?: string } };
|
const o = field as {
|
||||||
const u =
|
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()
|
typeof o.src === "string" && o.src.trim()
|
||||||
? o.src
|
? o.src
|
||||||
: typeof o.file?.url === "string" && o.file.url.trim()
|
: typeof o.file?.url === "string" && o.file.url.trim()
|
||||||
? o.file.url
|
? o.file.url
|
||||||
: undefined;
|
: typeof o.fields?.file?.url === "string" && o.fields.file.url.trim()
|
||||||
if (!u) return null;
|
? o.fields.file.url
|
||||||
return normalizeMediaUrl(u.startsWith("//") ? `https:${u}` : u);
|
: 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;
|
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 path from "node:path";
|
||||||
import {
|
import {
|
||||||
absoluteUrlFromCmsImageField,
|
absoluteUrlFromCmsImageField,
|
||||||
|
extractCmsImageField,
|
||||||
getCmsBaseUrl,
|
getCmsBaseUrl,
|
||||||
normalizeMediaUrl,
|
normalizeMediaUrl,
|
||||||
|
type CmsFocal,
|
||||||
} from "./cms-media-url";
|
} from "./cms-media-url";
|
||||||
|
|
||||||
/** Für ältere Importe von `rusty-image`; neue Nutzung: `./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";
|
fit?: "fill" | "contain" | "cover";
|
||||||
/** Ausgabeformat */
|
/** Ausgabeformat */
|
||||||
format?: "jpeg" | "png" | "webp" | "avif";
|
format?: "jpeg" | "png" | "webp" | "avif";
|
||||||
/** JPEG-Qualität 1–100 */
|
/** JPEG/WebP-Qualität 1–100 */
|
||||||
quality?: number;
|
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";
|
const DEFAULT_FORMAT = "jpeg";
|
||||||
@@ -65,11 +71,19 @@ export function hashForTransform(
|
|||||||
fit: params.fit ?? "contain",
|
fit: params.fit ?? "contain",
|
||||||
format: params.format ?? DEFAULT_FORMAT,
|
format: params.format ?? DEFAULT_FORMAT,
|
||||||
quality: params.quality ?? 85,
|
quality: params.quality ?? 85,
|
||||||
|
fx: normalizeFocalForHash(params.focalX),
|
||||||
|
fy: normalizeFocalForHash(params.focalY),
|
||||||
};
|
};
|
||||||
const payload = JSON.stringify(normalized);
|
const payload = JSON.stringify(normalized);
|
||||||
return createHash("sha256").update(payload).digest("hex").slice(0, 20);
|
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.
|
* Stellt sicher, dass das transformierte Bild in public existiert.
|
||||||
* Wenn nicht: GET an RustyCMS /api/transform, Ergebnis in public/images/transformed/<hash>.<ext> schreiben.
|
* 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.format != null) searchParams.set("format", params.format);
|
||||||
if (params.quality != null)
|
if (params.quality != null)
|
||||||
searchParams.set("quality", String(params.quality));
|
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()}`;
|
const transformUrl = `${baseUrl}/api/transform?${searchParams.toString()}`;
|
||||||
console.log("[rusty-image] GET", transformUrl);
|
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.
|
* 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 }).
|
* 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(
|
export async function resolveImageUrls(
|
||||||
items: ImageArrayItem[],
|
items: ImageArrayItem[],
|
||||||
@@ -174,30 +192,285 @@ export async function resolveImageUrls(
|
|||||||
): Promise<string[]> {
|
): Promise<string[]> {
|
||||||
const result: string[] = [];
|
const result: string[] = [];
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const u = absoluteUrlFromCmsImageField(item);
|
const extracted = extractCmsImageField(item);
|
||||||
if (!u) continue;
|
if (!extracted) continue;
|
||||||
|
const perItem: RustyImageTransformParams = {
|
||||||
|
...params,
|
||||||
|
focalX: params.focalX ?? extracted.focal?.x,
|
||||||
|
focalY: params.focalY ?? extracted.focal?.y,
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
result.push(await ensureTransformedImage(u, params));
|
result.push(await ensureTransformedImage(extracted.url, perItem));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("[rusty-image] resolve failed for", u, e);
|
console.warn("[rusty-image] resolve failed for", extracted.url, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Holt die Bild-URL aus einem Image-Block (image oder img: string, src oder file.url). */
|
/** AssetInfo vom RustyCMS ?meta=true Endpoint (nur benötigte Felder). */
|
||||||
function getImageBlockUrl(block: {
|
interface AssetMeta {
|
||||||
image?: string | { src?: string; file?: { url?: string }; title?: string };
|
width?: number;
|
||||||
img?: string | { src?: string; file?: { url?: string }; title?: string };
|
height?: number;
|
||||||
}): string | null {
|
hasAlpha?: boolean;
|
||||||
return absoluteUrlFromCmsImageField(block.image ?? block.img);
|
mimeType?: string;
|
||||||
|
blurhash?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** URL aus einem Galerie-Bildeintrag (src oder file.url). */
|
/** Prozess-Cache: gleiche URL nicht doppelt abfragen pro Build/SSR. */
|
||||||
function getGalleryImageUrl(
|
const metaCache = new Map<string, Promise<AssetMeta | null>>();
|
||||||
img: { src?: string; file?: { url?: string } },
|
|
||||||
): string | null {
|
/**
|
||||||
return absoluteUrlFromCmsImageField(img);
|
* 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. */
|
/** RowContentLayout-typ: Zeilen mit content-Arrays. */
|
||||||
@@ -427,33 +700,66 @@ export async function resolveContentImages(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (block._type === "image") {
|
if (block._type === "image") {
|
||||||
const url = getImageBlockUrl(
|
const field = getImageBlockField(block);
|
||||||
block as {
|
if (!field) continue;
|
||||||
image?: string | { file?: { url?: string }; title?: string };
|
|
||||||
img?: string | { file?: { url?: string }; title?: string };
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (!url) continue;
|
|
||||||
try {
|
try {
|
||||||
block.resolvedImageSrc = await ensureTransformedImage(
|
// Build responsive variants. Body-image widths target the typical
|
||||||
url,
|
// article column on mobile/tablet/desktop; AVIF/WebP/JPEG fallback.
|
||||||
transformParams,
|
// 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) {
|
} 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;
|
continue;
|
||||||
}
|
}
|
||||||
if (block._type === "image_gallery" && Array.isArray(block.images)) {
|
if (block._type === "image_gallery" && Array.isArray(block.images)) {
|
||||||
for (const img of block.images) {
|
for (const img of block.images) {
|
||||||
if (!img || typeof img !== "object") continue;
|
if (!img || typeof img !== "object") continue;
|
||||||
const url = getGalleryImageUrl(img);
|
const field = getGalleryImageField(img);
|
||||||
if (!url) continue;
|
if (!field) continue;
|
||||||
try {
|
try {
|
||||||
(img as { resolvedSrc?: string }).resolvedSrc =
|
const responsive = await ensureResponsiveImage(field.url, {
|
||||||
await ensureTransformedImage(url, galleryParams);
|
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) {
|
} 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;
|
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,
|
resolvePostTagsInPost,
|
||||||
resolvePostOverviewBlocks,
|
resolvePostOverviewBlocks,
|
||||||
resolveSearchableTextBlocks,
|
resolveSearchableTextBlocks,
|
||||||
getPostImageUrl,
|
getPostImageField,
|
||||||
formatPostDate,
|
formatPostDate,
|
||||||
} from "../../lib/blog-utils";
|
} from "../../lib/blog-utils";
|
||||||
import { POST_RESOLVE } from "../../lib/constants";
|
import { POST_RESOLVE } from "../../lib/constants";
|
||||||
@@ -60,12 +60,14 @@ const tagsMap = await resolvePostOverviewBlocks(post);
|
|||||||
await resolveSearchableTextBlocks(post, tagsMap);
|
await resolveSearchableTextBlocks(post, tagsMap);
|
||||||
resolvePostTagsInPost(post, tagsMap);
|
resolvePostTagsInPost(post, tagsMap);
|
||||||
|
|
||||||
const rawPostImageUrl = getPostImageUrl(post.postImage);
|
const postImageField = getPostImageField(post.postImage);
|
||||||
const postImageUrl = rawPostImageUrl
|
const postImageUrl = postImageField
|
||||||
? await ensureTransformedImage(rawPostImageUrl, {
|
? await ensureTransformedImage(postImageField.url, {
|
||||||
width: 150,
|
width: 150,
|
||||||
height: 200,
|
height: 200,
|
||||||
fit: "cover",
|
fit: "cover",
|
||||||
|
focalX: postImageField.focal?.x,
|
||||||
|
focalY: postImageField.focal?.y,
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
getPostsPerPage,
|
getPostsPerPage,
|
||||||
getTagsMap,
|
getTagsMap,
|
||||||
resolvePostTagsInPost,
|
resolvePostTagsInPost,
|
||||||
getPostImageUrl,
|
getPostImageField,
|
||||||
buildPostSearchIndex,
|
buildPostSearchIndex,
|
||||||
selectUpcomingEvents,
|
selectUpcomingEvents,
|
||||||
} from '../../lib/blog-utils';
|
} from '../../lib/blog-utils';
|
||||||
@@ -32,13 +32,18 @@ try {
|
|||||||
const tagsMap = await getTagsMap();
|
const tagsMap = await getTagsMap();
|
||||||
for (const p of posts) resolvePostTagsInPost(p, tagsMap);
|
for (const p of posts) resolvePostTagsInPost(p, tagsMap);
|
||||||
// Resolve post card images
|
// Resolve post card images
|
||||||
const { ensureTransformedImage } = await import('../../lib/rusty-image');
|
const { resolvePostCardResponsive } = await import('../../lib/server-image');
|
||||||
for (const post of posts) {
|
for (const post of posts) {
|
||||||
const raw = getPostImageUrl(post.postImage);
|
const field = getPostImageField(post.postImage);
|
||||||
if (raw) {
|
if (field) {
|
||||||
try {
|
try {
|
||||||
const url = await ensureTransformedImage(raw, { width: 400, height: 267, fit: 'cover', format: 'webp' });
|
const responsive = await resolvePostCardResponsive(field);
|
||||||
(post as typeof post & { _resolvedImageUrl?: string })._resolvedImageUrl = url;
|
const target = post as typeof post & {
|
||||||
|
_resolvedImageUrl?: string;
|
||||||
|
_resolvedResponsive?: Awaited<ReturnType<typeof resolvePostCardResponsive>>;
|
||||||
|
};
|
||||||
|
target._resolvedResponsive = responsive;
|
||||||
|
target._resolvedImageUrl = responsive.fallback;
|
||||||
} catch { /* image optional */ }
|
} catch { /* image optional */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
getTotalPages,
|
getTotalPages,
|
||||||
paginate,
|
paginate,
|
||||||
getPostsPerPage,
|
getPostsPerPage,
|
||||||
getPostImageUrl,
|
getPostImageField,
|
||||||
buildPostSearchIndex,
|
buildPostSearchIndex,
|
||||||
selectUpcomingEvents,
|
selectUpcomingEvents,
|
||||||
} from '../../../lib/blog-utils';
|
} from '../../../lib/blog-utils';
|
||||||
@@ -43,13 +43,18 @@ let cmsError: string | null = null;
|
|||||||
try {
|
try {
|
||||||
[posts, tags] = await Promise.all([getPosts({ resolve: ['postImage'] }), getTags()]);
|
[posts, tags] = await Promise.all([getPosts({ resolve: ['postImage'] }), getTags()]);
|
||||||
posts = filterHiddenPosts(sortPostsByDate(posts));
|
posts = filterHiddenPosts(sortPostsByDate(posts));
|
||||||
const { ensureTransformedImage } = await import('../../../lib/rusty-image');
|
const { resolvePostCardResponsive } = await import('../../../lib/server-image');
|
||||||
for (const post of posts) {
|
for (const post of posts) {
|
||||||
const raw = getPostImageUrl(post.postImage);
|
const field = getPostImageField(post.postImage);
|
||||||
if (raw) {
|
if (field) {
|
||||||
try {
|
try {
|
||||||
const url = await ensureTransformedImage(raw, { width: 400, height: 267, fit: 'cover', format: 'webp' });
|
const responsive = await resolvePostCardResponsive(field);
|
||||||
(post as typeof post & { _resolvedImageUrl?: string })._resolvedImageUrl = url;
|
const target = post as typeof post & {
|
||||||
|
_resolvedImageUrl?: string;
|
||||||
|
_resolvedResponsive?: Awaited<ReturnType<typeof resolvePostCardResponsive>>;
|
||||||
|
};
|
||||||
|
target._resolvedResponsive = responsive;
|
||||||
|
target._resolvedImageUrl = responsive.fallback;
|
||||||
} catch { /* image optional */ }
|
} catch { /* image optional */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
getPostsPerPage,
|
getPostsPerPage,
|
||||||
getTagsMap,
|
getTagsMap,
|
||||||
resolvePostTagsInPost,
|
resolvePostTagsInPost,
|
||||||
getPostImageUrl,
|
getPostImageField,
|
||||||
buildPostSearchIndex,
|
buildPostSearchIndex,
|
||||||
selectUpcomingEvents,
|
selectUpcomingEvents,
|
||||||
} from '../../../lib/blog-utils';
|
} from '../../../lib/blog-utils';
|
||||||
@@ -56,10 +56,17 @@ try {
|
|||||||
for (const p of posts) resolvePostTagsInPost(p, tagsMap);
|
for (const p of posts) resolvePostTagsInPost(p, tagsMap);
|
||||||
const { ensureTransformedImage } = await import('../../../lib/rusty-image');
|
const { ensureTransformedImage } = await import('../../../lib/rusty-image');
|
||||||
for (const post of posts) {
|
for (const post of posts) {
|
||||||
const raw = getPostImageUrl(post.postImage);
|
const field = getPostImageField(post.postImage);
|
||||||
if (raw) {
|
if (field) {
|
||||||
try {
|
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;
|
(post as typeof post & { _resolvedImageUrl?: string })._resolvedImageUrl = url;
|
||||||
} catch { /* image optional */ }
|
} catch { /* image optional */ }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
getPostsPerPage,
|
getPostsPerPage,
|
||||||
getTagsMap,
|
getTagsMap,
|
||||||
resolvePostTagsInPost,
|
resolvePostTagsInPost,
|
||||||
getPostImageUrl,
|
getPostImageField,
|
||||||
buildPostSearchIndex,
|
buildPostSearchIndex,
|
||||||
selectUpcomingEvents,
|
selectUpcomingEvents,
|
||||||
} from '../../../../../lib/blog-utils';
|
} from '../../../../../lib/blog-utils';
|
||||||
@@ -66,13 +66,18 @@ try {
|
|||||||
posts = filterHiddenPosts(sortPostsByDate(posts));
|
posts = filterHiddenPosts(sortPostsByDate(posts));
|
||||||
const tagsMap = await getTagsMap();
|
const tagsMap = await getTagsMap();
|
||||||
for (const p of posts) resolvePostTagsInPost(p, tagsMap);
|
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) {
|
for (const post of posts) {
|
||||||
const raw = getPostImageUrl(post.postImage);
|
const field = getPostImageField(post.postImage);
|
||||||
if (raw) {
|
if (field) {
|
||||||
try {
|
try {
|
||||||
const url = await ensureTransformedImage(raw, { width: 400, height: 267, fit: 'cover', format: 'webp' });
|
const responsive = await resolvePostCardResponsive(field);
|
||||||
(post as typeof post & { _resolvedImageUrl?: string })._resolvedImageUrl = url;
|
const target = post as typeof post & {
|
||||||
|
_resolvedImageUrl?: string;
|
||||||
|
_resolvedResponsive?: Awaited<ReturnType<typeof resolvePostCardResponsive>>;
|
||||||
|
};
|
||||||
|
target._resolvedResponsive = responsive;
|
||||||
|
target._resolvedImageUrl = responsive.fallback;
|
||||||
} catch { /* image optional */ }
|
} 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"
|
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
|
||||||
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
|
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:
|
boolbase@^1.0.0:
|
||||||
version "1.0.0"
|
version "1.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e"
|
resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e"
|
||||||
|
|||||||
Reference in New Issue
Block a user