feat(image): enhance image handling with focal points and responsive support
Deploy / verify (push) Failing after 14s
Deploy / deploy (push) Has been skipped

- Added support for extracting focal points and alt text from CMS image fields.
- Introduced responsive image handling in TopBanner component, allowing for AVIF/WebP sources and LQIP placeholders.
- Updated image transformation functions to accommodate focal point parameters for better cropping.
- Refactored image URL resolution logic across various components and routes to utilize new image field structure.
This commit is contained in:
Peter Meier
2026-04-18 00:42:33 +02:00
parent 4d79bf08da
commit a139372ce2
12 changed files with 734 additions and 61 deletions
+16 -1
View File
@@ -20,7 +20,20 @@
seoDescription?: string;
socialImage?: string;
breadcrumbItems?: { href?: string; label: string }[];
topBanner?: { banner: unknown; resolvedImages: string[]; headline?: string; subheadline?: string } | null;
topBanner?: {
banner: unknown;
resolvedImages: string[];
headline?: string;
subheadline?: string;
responsive?: {
sources: { type: string; srcset: string }[];
fallback: string;
width: number;
height: number;
lqip?: string;
} | null;
focalCss?: string | null;
} | null;
});
const baseUrl = $derived(data.siteBaseUrl ?? '');
@@ -153,6 +166,8 @@
<TopBanner
banner={topBannerData.banner as import('$lib/cms').FullwidthBannerEntry | null}
resolvedImages={topBannerData.resolvedImages}
responsive={topBannerData.responsive ?? null}
focalCss={topBannerData.focalCss ?? null}
headline={topBannerData.headline}
subheadline={topBannerData.subheadline}
/>
+30 -1
View File
@@ -1,6 +1,11 @@
import type { PageServerLoad } from './$types';
import { getPageBySlug, getPages, getHomePageSlugFromConfig } from '$lib/cms';
import { resolveContentImages, resolveImageUrls } from '$lib/rusty-image';
import {
extractCmsImageField,
resolveContentImages,
resolveImageUrls,
} from '$lib/rusty-image';
import { buildResponsiveImage, type ResponsiveImage } from '$lib/rusty-image.server';
import {
resolvePostOverviewBlocks,
resolveSearchableTextBlocks,
@@ -8,6 +13,13 @@ import {
} from '$lib/blog-utils';
import { DEFAULT_HOME_PAGE_SLUG, PAGE_RESOLVE, SITE_NAME } from '$lib/constants';
const BANNER_WIDTHS = [640, 960, 1280, 1600, 1920, 2400];
function focalToCss(focal: { x: number; y: number } | undefined): string | null {
if (!focal) return null;
return `${(focal.x * 100).toFixed(2)}% ${(focal.y * 100).toFixed(2)}%`;
}
export const load: PageServerLoad = async ({ locals }) => {
const translations = locals.translations ?? {};
let homeSlug = DEFAULT_HOME_PAGE_SLUG;
@@ -42,6 +54,8 @@ export const load: PageServerLoad = async ({ locals }) => {
let topBanner: {
banner: import('$lib/cms').FullwidthBannerEntry | null;
resolvedImages: string[];
responsive: ResponsiveImage | null;
focalCss: string | null;
headline?: string;
subheadline?: string;
} | null = null;
@@ -54,7 +68,20 @@ export const load: PageServerLoad = async ({ locals }) => {
? [(bannerRaw as { image?: unknown }).image]
: [];
let resolvedImages: string[] = [];
let responsive: ResponsiveImage | null = null;
let focalCss: string | null = null;
if (bannerImages.length > 0) {
const primary = extractCmsImageField(bannerImages[0]);
if (primary?.url) {
focalCss = focalToCss(primary.focal);
responsive = await buildResponsiveImage(primary.url, {
widths: BANNER_WIDTHS,
fit: 'contain',
formats: ['avif', 'webp', 'jpeg'],
quality: 78,
focal: primary.focal,
});
}
resolvedImages = await resolveImageUrls(bannerImages as string[], {
width: 2000,
height: 750,
@@ -65,6 +92,8 @@ export const load: PageServerLoad = async ({ locals }) => {
topBanner = {
banner: bannerRaw as import('$lib/cms').FullwidthBannerEntry | null,
resolvedImages,
responsive,
focalCss,
headline: page.headline ?? undefined,
subheadline: page.subheadline ?? undefined,
};
+28 -1
View File
@@ -1,6 +1,11 @@
import type { PageServerLoad } from './$types';
import { getPageBySlug } from '$lib/cms';
import { resolveContentImages, resolveImageUrls } from '$lib/rusty-image';
import {
extractCmsImageField,
resolveContentImages,
resolveImageUrls,
} from '$lib/rusty-image';
import { buildResponsiveImage, type ResponsiveImage } from '$lib/rusty-image.server';
import {
resolvePostOverviewBlocks,
resolveSearchableTextBlocks,
@@ -9,6 +14,13 @@ import {
import { PAGE_RESOLVE } from '$lib/constants';
import { error } from '@sveltejs/kit';
const BANNER_WIDTHS = [640, 960, 1280, 1600, 1920, 2400];
function focalToCss(focal: { x: number; y: number } | undefined): string | null {
if (!focal) return null;
return `${(focal.x * 100).toFixed(2)}% ${(focal.y * 100).toFixed(2)}%`;
}
export const load: PageServerLoad = async ({ params, locals }) => {
const { slug } = params;
const translations = locals.translations ?? {};
@@ -29,6 +41,8 @@ export const load: PageServerLoad = async ({ params, locals }) => {
// Resolve top banner if present
let topBannerResolvedImages: string[] = [];
let topBannerResponsive: ResponsiveImage | null = null;
let topBannerFocalCss: string | null = null;
const bannerRaw = (page as { topFullwidthBanner?: unknown }).topFullwidthBanner;
if (bannerRaw && typeof bannerRaw === 'object' && 'image' in bannerRaw) {
const bannerImages = Array.isArray((bannerRaw as { image?: unknown }).image)
@@ -37,6 +51,17 @@ export const load: PageServerLoad = async ({ params, locals }) => {
? [(bannerRaw as { image?: unknown }).image]
: [];
if (bannerImages.length > 0) {
const primary = extractCmsImageField(bannerImages[0]);
if (primary?.url) {
topBannerFocalCss = focalToCss(primary.focal);
topBannerResponsive = await buildResponsiveImage(primary.url, {
widths: BANNER_WIDTHS,
fit: 'contain',
formats: ['avif', 'webp', 'jpeg'],
quality: 78,
focal: primary.focal,
});
}
topBannerResolvedImages = await resolveImageUrls(bannerImages as string[], {
width: 2000,
height: 750,
@@ -59,6 +84,8 @@ export const load: PageServerLoad = async ({ params, locals }) => {
? {
banner: bannerRaw as import('$lib/cms').FullwidthBannerEntry | null,
resolvedImages: topBannerResolvedImages,
responsive: topBannerResponsive,
focalCss: topBannerFocalCss,
headline: page.headline ?? undefined,
subheadline: page.subheadline ?? undefined,
}
+12
View File
@@ -44,6 +44,8 @@ export const GET: RequestHandler = async ({ url }) => {
const format = url.searchParams.get('format');
const fit = url.searchParams.get('fit');
const ar = url.searchParams.get('ar');
const fx = url.searchParams.get('fx');
const fy = url.searchParams.get('fy');
if (w) {
const n = Number(w);
@@ -60,6 +62,14 @@ export const GET: RequestHandler = async ({ url }) => {
if (format) params.format = format;
if (fit) params.fit = fit;
if (ar) params.ar = ar;
if (fx) {
const n = Number(fx);
if (Number.isFinite(n)) params.fx = n;
}
if (fy) {
const n = Number(fy);
if (Number.isFinite(n)) params.fy = n;
}
const key = buildCacheKey(src, params);
@@ -80,6 +90,8 @@ export const GET: RequestHandler = async ({ url }) => {
if (params.format) transformParams.set('format', params.format);
if (params.fit) transformParams.set('fit', params.fit);
if (params.ar) transformParams.set('ar', params.ar);
if (params.fx != null) transformParams.set('fx', String(params.fx));
if (params.fy != null) transformParams.set('fy', String(params.fy));
const transformUrl = `${cmsBase}/api/transform?${transformParams.toString()}`;
+6 -4
View File
@@ -9,7 +9,7 @@ import {
resolvePostTagsInPost,
resolvePostOverviewBlocks,
resolveSearchableTextBlocks,
getPostImageUrl,
getPostImageField,
formatPostDate,
} from '$lib/blog-utils';
import { POST_RESOLVE } from '$lib/constants';
@@ -36,12 +36,14 @@ export const load: PageServerLoad = async ({ params, locals, url }) => {
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;