diff --git a/package.json b/package.json index 1d56537..27174dc 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,9 @@ "@fontsource-variable/rubik": "^5.2.8", "@fontsource/inter": "^5.2.8", "@iconify/svelte": "^5.2.1", - "marked": "^17.0.2" + "blurhash": "^2.0.5", + "marked": "^17.0.2", + "sharp": "^0.34.5" }, "type": "module" } diff --git a/src/lib/blog-utils.ts b/src/lib/blog-utils.ts index 7a105f2..d9567a6 100644 --- a/src/lib/blog-utils.ts +++ b/src/lib/blog-utils.ts @@ -9,7 +9,7 @@ import { } from "./cms"; import type { RowContentLayout } from "./block-types"; import type { CalendarItemData } from "./block-types"; -import { ensureTransformedImage } from "./rusty-image"; +import { ensureTransformedImage, extractCmsImageField, type CmsImageField } from "./rusty-image"; /** Aus der Tag-API: Anzeigename plus optionales Icon und Farbe (CMS-Felder icon / color). */ export type TagMeta = { @@ -124,18 +124,28 @@ export function filterPostsByTag( export function getPostImageUrl( postImage: PostEntry["postImage"], ): string | null { - if (!postImage || typeof postImage === "string") return null; + return getPostImageField(postImage)?.url ?? null; +} + +/** + * URL + Focal + Alt aus post.postImage. Unterstützt RustyCMS-image-Typ + * und legacy file.url / fields.file.url. + */ +export function getPostImageField( + postImage: PostEntry["postImage"], +): CmsImageField | null { + if (!postImage) return null; + if (typeof postImage === "string") return extractCmsImageField(postImage); + const field = extractCmsImageField(postImage); + if (field) return field; const obj = postImage as { - src?: string; - file?: { url?: string }; fields?: { file?: { url?: string } }; }; - const url = obj?.src ?? obj?.file?.url ?? obj?.fields?.file?.url; - return typeof url === "string" - ? url.startsWith("//") - ? `https:${url}` - : url - : null; + const legacy = obj?.fields?.file?.url; + if (typeof legacy === "string" && legacy.trim()) { + return { url: legacy.startsWith("//") ? `https:${legacy}` : legacy }; + } + return null; } /** Filtert Posts: behält nur solche, die mindestens einen der Tag-Slugs haben. */ @@ -237,14 +247,16 @@ export async function loadPostsList(opts: { const tagsMap = await getTagsMap(); for (const p of posts) resolvePostTagsInPost(p, tagsMap); 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, { + const url = await ensureTransformedImage(field.url, { width: 400, height: 267, fit: "cover", format: "webp", + focalX: field.focal?.x, + focalY: field.focal?.y, }); (post as PostEntry & { _resolvedImageUrl?: string })._resolvedImageUrl = url; } catch { @@ -417,14 +429,16 @@ 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) { + const field = getPostImageField(post.postImage); + if (field) { try { - const url = await ensureTransformedImage(raw, { + const url = await ensureTransformedImage(field.url, { width: 400, height: 267, fit: "cover", format: "webp", + focalX: field.focal?.x, + focalY: field.focal?.y, }); ( post as PostEntry & { _resolvedImageUrl?: string } diff --git a/src/lib/components/TopBanner.svelte b/src/lib/components/TopBanner.svelte index b2c4e8b..8f06e8b 100644 --- a/src/lib/components/TopBanner.svelte +++ b/src/lib/components/TopBanner.svelte @@ -1,23 +1,67 @@ {#if showBanner}
{#if hasImage} -
- {banner?.headline +
+ {#if hasResponsive && responsive} + + {#each responsive.sources as s (s.type)} + + {/each} + {altText} + + {:else} + {altText} + {/if} {#if hasPageTitle}
diff --git a/src/lib/image-cache.server.ts b/src/lib/image-cache.server.ts index ab60f0f..907966a 100644 --- a/src/lib/image-cache.server.ts +++ b/src/lib/image-cache.server.ts @@ -27,6 +27,8 @@ export interface ImageTransformParams { format?: string; fit?: string; ar?: string; + fx?: number; + fy?: number; } const memoryCache = new Map(); @@ -45,6 +47,10 @@ function ensureCacheDir(): string { * Deterministischer Cache-Key aus src + allen Transform-Parametern. * Gleiche Eingabe = gleicher Key, unabhängig von Reihenfolge. */ +function roundFocal(v: number): number { + return Math.round(v * 10000) / 10000; +} + export function buildCacheKey(src: string, params: ImageTransformParams): string { const parts = [src]; if (params.w != null) parts.push(`w=${params.w}`); @@ -53,6 +59,8 @@ export function buildCacheKey(src: string, params: ImageTransformParams): string if (params.format) parts.push(`f=${params.format}`); if (params.fit) parts.push(`fit=${params.fit}`); if (params.ar) parts.push(`ar=${params.ar}`); + if (params.fx != null) parts.push(`fx=${roundFocal(params.fx)}`); + if (params.fy != null) parts.push(`fy=${roundFocal(params.fy)}`); return createHash('sha256').update(parts.join('|')).digest('hex').slice(0, 16); } diff --git a/src/lib/rusty-image.server.ts b/src/lib/rusty-image.server.ts new file mode 100644 index 0000000..f748117 --- /dev/null +++ b/src/lib/rusty-image.server.ts @@ -0,0 +1,234 @@ +/** + * Server-only Bild-Helfer: Asset-Meta vom CMS, Blurhash-LQIP, Responsive-Srcset. + * Nur in `+page.server.ts` / `+layout.server.ts` importieren — nutzt sharp + blurhash + process.env. + */ + +import { env } from '$env/dynamic/public'; +import { + getCmsImageUrl, + type RustyImageTransformParams, + type CmsImageField, +} from './rusty-image'; + +function getCmsBaseUrl(): string { + return (env.PUBLIC_CMS_URL || process.env.PUBLIC_CMS_URL || 'http://localhost:3000').replace( + /\/$/, + '', + ); +} + +export interface AssetMeta { + width: number; + height: number; + hasAlpha: boolean; + mimeType: string; + blurhash?: string; +} + +const metaCache = new Map>(); + +function toMetaEndpoint(url: string): string | null { + try { + const u = new URL(url); + const base = getCmsBaseUrl(); + const baseHost = new URL(base).host; + if (u.host !== baseHost && !/^127\.0\.0\.1|localhost/.test(u.host)) { + return null; + } + u.searchParams.set('meta', 'true'); + return `${base}${u.pathname}${u.search}`; + } catch { + return null; + } +} + +/** + * Holt AssetInfo vom CMS: `/api/assets/?meta=true`. + * Cached per URL für die Prozess-Lebensdauer. + */ +export async function fetchAssetMeta(url: string): Promise { + const endpoint = toMetaEndpoint(url); + if (!endpoint) return null; + const cached = metaCache.get(endpoint); + if (cached) return cached; + const p = (async () => { + try { + const res = await fetch(endpoint); + if (!res.ok) return null; + const json = (await res.json()) as { + width?: number; + height?: number; + has_alpha?: boolean; + mime_type?: string; + blurhash?: string; + }; + if ( + typeof json?.width !== 'number' || + typeof json?.height !== 'number' || + json.width <= 0 || + json.height <= 0 + ) { + return null; + } + return { + width: json.width, + height: json.height, + hasAlpha: Boolean(json.has_alpha), + mimeType: json.mime_type ?? 'image/jpeg', + ...(typeof json.blurhash === 'string' && json.blurhash.trim() + ? { blurhash: json.blurhash } + : {}), + } satisfies AssetMeta; + } catch { + return null; + } + })(); + metaCache.set(endpoint, p); + return p; +} + +const blurhashCache = new Map>(); + +/** + * Dekodiert Blurhash → 32×32 RGBA → JPEG Q40 → base64 data-URL. + * Liefert kompakten LQIP (~300–500 B) für CSS-`background-image`. + */ +export async function blurhashToDataUrl( + hash: string, + width = 32, + height = 32, +): Promise { + const key = `${hash}|${width}x${height}`; + const cached = blurhashCache.get(key); + if (cached) return cached; + const p = (async () => { + try { + const [{ decode }, sharpMod] = await Promise.all([ + import('blurhash'), + import('sharp'), + ]); + const pixels = decode(hash, width, height); + const sharp = sharpMod.default; + const jpeg = await sharp(Buffer.from(pixels), { + raw: { width, height, channels: 4 }, + }) + .jpeg({ quality: 40 }) + .toBuffer(); + return `data:image/jpeg;base64,${jpeg.toString('base64')}`; + } catch { + return null; + } + })(); + blurhashCache.set(key, p); + return p; +} + +export interface ResponsiveImage { + sources: { type: string; srcset: string }[]; + fallback: string; + width: number; + height: number; + widths: number[]; + lqip?: string; +} + +export interface ResponsiveImageOptions { + widths?: number[]; + ar?: string; + fit?: RustyImageTransformParams['fit']; + formats?: Array<'avif' | 'webp' | 'jpeg'>; + quality?: number; + focal?: CmsImageField['focal']; + lqip?: boolean; +} + +const DEFAULT_WIDTHS = [640, 960, 1280, 1600, 1920, 2400]; +const DEFAULT_FORMATS: Array<'avif' | 'webp' | 'jpeg'> = ['avif', 'webp', 'jpeg']; +const MIME_FOR_FORMAT: Record<'avif' | 'webp' | 'jpeg', string> = { + avif: 'image/avif', + webp: 'image/webp', + jpeg: 'image/jpeg', +}; + +function parseAr(ar: string | undefined): number | null { + if (!ar) return null; + const m = ar.match(/^(\d+(?:\.\d+)?)\s*[/:x]\s*(\d+(?:\.\d+)?)$/); + if (!m) return null; + const w = Number(m[1]); + const h = Number(m[2]); + if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) return null; + return w / h; +} + +/** + * Baut ``-Daten (AVIF/WebP/JPEG srcset + Fallback + LQIP). + * Breiten werden gegen die echte Bildbreite aus `fetchAssetMeta` geclampt (kein Upscaling). + * Alle URLs zeigen auf `/cms-images?…` (erst bei Client-Request wird gerendert & gecached). + */ +export async function buildResponsiveImage( + url: string, + opts: ResponsiveImageOptions = {}, +): Promise { + const meta = await fetchAssetMeta(url); + + const widthsRaw = (opts.widths ?? DEFAULT_WIDTHS).filter((w) => Number.isFinite(w) && w > 0); + if (widthsRaw.length === 0) return null; + + const maxW = meta?.width ?? Math.max(...widthsRaw); + const widths = [...new Set(widthsRaw.map((w) => Math.min(w, maxW)).filter((w) => w > 0))].sort( + (a, b) => a - b, + ); + if (widths.length === 0) return null; + + const ar = + parseAr(opts.ar) ?? + (meta?.width && meta?.height ? meta.width / meta.height : null); + + const formats = opts.formats ?? DEFAULT_FORMATS; + const fit = opts.fit ?? 'cover'; + const quality = opts.quality ?? 78; + + const baseParams: RustyImageTransformParams = { + fit, + quality, + ...(opts.ar ? { ar: opts.ar } : {}), + ...(opts.focal + ? { focalX: opts.focal.x, focalY: opts.focal.y } + : {}), + }; + + const sources = formats.map((fmt) => { + const srcset = widths + .map((w) => { + const u = getCmsImageUrl(url, { ...baseParams, width: w, format: fmt }); + return `${u} ${w}w`; + }) + .join(', '); + return { type: MIME_FOR_FORMAT[fmt], srcset }; + }); + + const fallbackWidth = widths[widths.length - 1] ?? maxW; + const fallbackFormat: 'jpeg' | 'webp' = formats.includes('jpeg') ? 'jpeg' : 'webp'; + const fallback = getCmsImageUrl(url, { + ...baseParams, + width: fallbackWidth, + format: fallbackFormat, + }); + + const width = fallbackWidth; + const height = ar ? Math.round(width / ar) : meta?.height ?? Math.round(width * 0.5625); + + const lqip = + opts.lqip !== false && meta?.blurhash + ? ((await blurhashToDataUrl(meta.blurhash)) ?? undefined) + : undefined; + + return { + sources, + fallback, + width, + height, + widths, + ...(lqip ? { lqip } : {}), + }; +} diff --git a/src/lib/rusty-image.ts b/src/lib/rusty-image.ts index f05e59c..f374b8a 100644 --- a/src/lib/rusty-image.ts +++ b/src/lib/rusty-image.ts @@ -3,30 +3,60 @@ * Der Endpoint holt bei Bedarf vom CMS /api/transform und legt Bytes unter IMAGE_CACHE_DIR ab. */ +export interface CmsFocal { + x: number; + y: number; +} + +export interface CmsImageField { + url: string; + focal?: CmsFocal; + alt?: string; +} + /** - * Normalisiert CMS-Loopback-URLs (127.0.0.1) zur konfigurierten PUBLIC_CMS_URL. - * Für Browser-seitigen Code (kein Node.js/fs nötig). + * Extrahiert URL + Focal + Alt aus einem CMS-Image-Field (string oder {src, alt?, focal?}). + * Normalisiert Protokoll-lose URLs zu https:. */ -export function absoluteUrlFromCmsImageField(field: unknown): string | null { +export function extractCmsImageField(field: unknown): CmsImageField | null { if (typeof field === 'string') { const s = field.trim(); if (!s) return null; - return s.startsWith('//') ? `https:${s}` : s; + return { url: s.startsWith('//') ? `https:${s}` : s }; } 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 }; + focal?: { x?: number; y?: number }; + alt?: string; + }; + const rawUrl = 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 u.startsWith('//') ? `https:${u}` : u; + if (!rawUrl) return null; + const url = rawUrl.startsWith('//') ? `https:${rawUrl}` : rawUrl; + const fx = typeof o.focal?.x === 'number' ? o.focal.x : undefined; + const fy = typeof o.focal?.y === 'number' ? o.focal.y : undefined; + return { + url, + ...(fx != null && fy != null ? { focal: { x: fx, y: fy } } : {}), + ...(typeof o.alt === 'string' ? { alt: o.alt } : {}), + }; } return null; } +/** + * Legacy: nur URL. Bevorzuge extractCmsImageField für Focal/Alt. + */ +export function absoluteUrlFromCmsImageField(field: unknown): string | null { + return extractCmsImageField(field)?.url ?? null; +} + export interface RustyImageTransformParams { width?: number; height?: number; @@ -34,6 +64,14 @@ export interface RustyImageTransformParams { fit?: 'fill' | 'contain' | 'cover'; format?: 'jpeg' | 'png' | 'webp' | 'avif'; quality?: number; + /** Focal-X in [0,1] (Default 0.5) — für fit:cover-Crops. */ + focalX?: number; + /** Focal-Y in [0,1] (Default 0.5). */ + focalY?: number; +} + +function roundFocal(v: number): number { + return Math.round(v * 10000) / 10000; } /** @@ -49,6 +87,8 @@ export function getCmsImageUrl(src: string, params: RustyImageTransformParams = if (params.fit != null) searchParams.set('fit', params.fit); if (params.format != null) searchParams.set('format', params.format); if (params.quality != null) searchParams.set('q', String(params.quality)); + if (params.focalX != null) searchParams.set('fx', String(roundFocal(params.focalX))); + if (params.focalY != null) searchParams.set('fy', String(roundFocal(params.focalY))); return `/cms-images?${searchParams.toString()}`; } @@ -64,7 +104,7 @@ export async function ensureTransformedImage(url: string, params: RustyImageTran return getCmsImageUrl(url, params); } -type ImageArrayItem = string | { src?: string }; +type ImageArrayItem = string | { src?: string; focal?: { x?: number; y?: number } }; function toImageUrl(item: ImageArrayItem): string | null { if (typeof item === 'string') return item.startsWith('//') ? `https:${item}` : item; @@ -74,10 +114,20 @@ function toImageUrl(item: ImageArrayItem): string | null { } export async function resolveImageUrls(items: ImageArrayItem[], params: RustyImageTransformParams = {}): Promise { - return items - .map(toImageUrl) - .filter((u): u is string => u !== null && u.startsWith('http')) - .map((u) => getCmsImageUrl(u, params)); + const out: string[] = []; + for (const item of items) { + const field = extractCmsImageField(item); + if (!field || !field.url.startsWith('http')) continue; + out.push( + getCmsImageUrl(field.url, { + ...params, + ...(field.focal + ? { focalX: params.focalX ?? field.focal.x, focalY: params.focalY ?? field.focal.y } + : {}), + }), + ); + } + return out; } export interface RowContentLayoutLike { @@ -169,9 +219,12 @@ export async function resolveContentImages(layout: RowContentLayoutLike, transfo if (block._type === 'image') { const raw = block.image ?? block.img; - const url = absoluteUrlFromCmsImageField(raw); - if (url) { - block.resolvedImageSrc = getCmsImageUrl(url, transformParams); + const field = extractCmsImageField(raw); + if (field) { + block.resolvedImageSrc = getCmsImageUrl(field.url, { + ...transformParams, + ...(field.focal ? { focalX: field.focal.x, focalY: field.focal.y } : {}), + }); } continue; } @@ -179,9 +232,12 @@ export async function resolveContentImages(layout: RowContentLayoutLike, transfo if (block._type === 'image_gallery' && Array.isArray(block.images)) { for (const img of block.images) { if (!img || typeof img !== 'object') continue; - const url = absoluteUrlFromCmsImageField(img); - if (url) { - (img as { resolvedSrc?: string }).resolvedSrc = getCmsImageUrl(url, galleryParams); + const field = extractCmsImageField(img); + if (field) { + (img as { resolvedSrc?: string }).resolvedSrc = getCmsImageUrl(field.url, { + ...galleryParams, + ...(field.focal ? { focalX: field.focal.x, focalY: field.focal.y } : {}), + }); } } continue; @@ -191,9 +247,12 @@ export async function resolveContentImages(layout: RowContentLayoutLike, transfo for (const org of block.organisations) { if (!org || typeof org !== 'object') continue; const o = org as { logo?: unknown; resolvedLogoSrc?: string }; - const url = absoluteUrlFromCmsImageField(o.logo); - if (url) { - o.resolvedLogoSrc = getCmsImageUrl(url, orgLogoParams); + const field = extractCmsImageField(o.logo); + if (field) { + o.resolvedLogoSrc = getCmsImageUrl(field.url, { + ...orgLogoParams, + ...(field.focal ? { focalX: field.focal.x, focalY: field.focal.y } : {}), + }); } } continue; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 3722521..7b6ab56 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -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 @@ diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index 84f41ee..c7f117d 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -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, }; diff --git a/src/routes/[...slug]/+page.server.ts b/src/routes/[...slug]/+page.server.ts index 0033ac3..b7bae89 100644 --- a/src/routes/[...slug]/+page.server.ts +++ b/src/routes/[...slug]/+page.server.ts @@ -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, } diff --git a/src/routes/cms-images/+server.ts b/src/routes/cms-images/+server.ts index b7deb3e..2a2f60f 100644 --- a/src/routes/cms-images/+server.ts +++ b/src/routes/cms-images/+server.ts @@ -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()}`; diff --git a/src/routes/post/[slug]/+page.server.ts b/src/routes/post/[slug]/+page.server.ts index c7d0573..bd38589 100644 --- a/src/routes/post/[slug]/+page.server.ts +++ b/src/routes/post/[slug]/+page.server.ts @@ -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; diff --git a/yarn.lock b/yarn.lock index 76f3164..8c7781e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -24,6 +24,13 @@ "@emnapi/wasi-threads" "1.2.0" tslib "^2.4.0" +"@emnapi/runtime@^1.7.0": + version "1.10.0" + resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.10.0.tgz#4b260c0d3534204e98c6110b8db1a987d26ec87c" + integrity sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA== + dependencies: + tslib "^2.4.0" + "@emnapi/runtime@^1.7.1", "@emnapi/runtime@^1.8.1": version "1.9.0" resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.9.0.tgz#91c54a6e77c36154c125e873409472e2b70efd5b" @@ -202,6 +209,153 @@ resolved "https://registry.yarnpkg.com/@iconify/types/-/types-2.0.0.tgz#ab0e9ea681d6c8a1214f30cd741fe3a20cc57f57" integrity sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg== +"@img/colour@^1.0.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@img/colour/-/colour-1.1.0.tgz#b0c2c2fa661adf75effd6b4964497cd80010bb9d" + integrity sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ== + +"@img/sharp-darwin-arm64@0.34.5": + version "0.34.5" + resolved "https://registry.yarnpkg.com/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz#6e0732dcade126b6670af7aa17060b926835ea86" + integrity sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w== + optionalDependencies: + "@img/sharp-libvips-darwin-arm64" "1.2.4" + +"@img/sharp-darwin-x64@0.34.5": + version "0.34.5" + resolved "https://registry.yarnpkg.com/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz#19bc1dd6eba6d5a96283498b9c9f401180ee9c7b" + integrity sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw== + optionalDependencies: + "@img/sharp-libvips-darwin-x64" "1.2.4" + +"@img/sharp-libvips-darwin-arm64@1.2.4": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz#2894c0cb87d42276c3889942e8e2db517a492c43" + integrity sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g== + +"@img/sharp-libvips-darwin-x64@1.2.4": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz#e63681f4539a94af9cd17246ed8881734386f8cc" + integrity sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg== + +"@img/sharp-libvips-linux-arm64@1.2.4": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz#b1b288b36864b3bce545ad91fa6dadcf1a4ad318" + integrity sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw== + +"@img/sharp-libvips-linux-arm@1.2.4": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz#b9260dd1ebe6f9e3bdbcbdcac9d2ac125f35852d" + integrity sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A== + +"@img/sharp-libvips-linux-ppc64@1.2.4": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz#4b83ecf2a829057222b38848c7b022e7b4d07aa7" + integrity sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA== + +"@img/sharp-libvips-linux-riscv64@1.2.4": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz#880b4678009e5a2080af192332b00b0aaf8a48de" + integrity sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA== + +"@img/sharp-libvips-linux-s390x@1.2.4": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz#74f343c8e10fad821b38f75ced30488939dc59ec" + integrity sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ== + +"@img/sharp-libvips-linux-x64@1.2.4": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz#df4183e8bd8410f7d61b66859a35edeab0a531ce" + integrity sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw== + +"@img/sharp-libvips-linuxmusl-arm64@1.2.4": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz#c8d6b48211df67137541007ee8d1b7b1f8ca8e06" + integrity sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw== + +"@img/sharp-libvips-linuxmusl-x64@1.2.4": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz#be11c75bee5b080cbee31a153a8779448f919f75" + integrity sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg== + +"@img/sharp-linux-arm64@0.34.5": + version "0.34.5" + resolved "https://registry.yarnpkg.com/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz#7aa7764ef9c001f15e610546d42fce56911790cc" + integrity sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg== + optionalDependencies: + "@img/sharp-libvips-linux-arm64" "1.2.4" + +"@img/sharp-linux-arm@0.34.5": + version "0.34.5" + resolved "https://registry.yarnpkg.com/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz#5fb0c3695dd12522d39c3ff7a6bc816461780a0d" + integrity sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw== + optionalDependencies: + "@img/sharp-libvips-linux-arm" "1.2.4" + +"@img/sharp-linux-ppc64@0.34.5": + version "0.34.5" + resolved "https://registry.yarnpkg.com/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz#9c213a81520a20caf66978f3d4c07456ff2e0813" + integrity sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA== + optionalDependencies: + "@img/sharp-libvips-linux-ppc64" "1.2.4" + +"@img/sharp-linux-riscv64@0.34.5": + version "0.34.5" + resolved "https://registry.yarnpkg.com/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz#cdd28182774eadbe04f62675a16aabbccb833f60" + integrity sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw== + optionalDependencies: + "@img/sharp-libvips-linux-riscv64" "1.2.4" + +"@img/sharp-linux-s390x@0.34.5": + version "0.34.5" + resolved "https://registry.yarnpkg.com/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz#93eac601b9f329bb27917e0e19098c722d630df7" + integrity sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg== + optionalDependencies: + "@img/sharp-libvips-linux-s390x" "1.2.4" + +"@img/sharp-linux-x64@0.34.5": + version "0.34.5" + resolved "https://registry.yarnpkg.com/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz#55abc7cd754ffca5002b6c2b719abdfc846819a8" + integrity sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ== + optionalDependencies: + "@img/sharp-libvips-linux-x64" "1.2.4" + +"@img/sharp-linuxmusl-arm64@0.34.5": + version "0.34.5" + resolved "https://registry.yarnpkg.com/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz#d6515ee971bb62f73001a4829b9d865a11b77086" + integrity sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg== + optionalDependencies: + "@img/sharp-libvips-linuxmusl-arm64" "1.2.4" + +"@img/sharp-linuxmusl-x64@0.34.5": + version "0.34.5" + resolved "https://registry.yarnpkg.com/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz#d97978aec7c5212f999714f2f5b736457e12ee9f" + integrity sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q== + optionalDependencies: + "@img/sharp-libvips-linuxmusl-x64" "1.2.4" + +"@img/sharp-wasm32@0.34.5": + version "0.34.5" + resolved "https://registry.yarnpkg.com/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz#2f15803aa626f8c59dd7c9d0bbc766f1ab52cfa0" + integrity sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw== + dependencies: + "@emnapi/runtime" "^1.7.0" + +"@img/sharp-win32-arm64@0.34.5": + version "0.34.5" + resolved "https://registry.yarnpkg.com/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz#3706e9e3ac35fddfc1c87f94e849f1b75307ce0a" + integrity sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g== + +"@img/sharp-win32-ia32@0.34.5": + version "0.34.5" + resolved "https://registry.yarnpkg.com/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz#0b71166599b049e032f085fb9263e02f4e4788de" + integrity sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg== + +"@img/sharp-win32-x64@0.34.5": + version "0.34.5" + resolved "https://registry.yarnpkg.com/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz#a81ffb00e69267cd0a1d626eaedb8a8430b2b2f8" + integrity sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw== + "@jridgewell/gen-mapping@^0.3.5": version "0.3.13" resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz#6342a19f44347518c93e43b1ac69deb3c4656a1f" @@ -683,6 +837,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== + brace-expansion@^2.0.1: version "2.0.2" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.2.tgz#54fc53237a613d854c7bd37463aad17df87214e7" @@ -727,7 +886,7 @@ deepmerge@^4.2.2, deepmerge@^4.3.1: resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a" integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A== -detect-libc@^2.0.3: +detect-libc@^2.0.3, detect-libc@^2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.1.2.tgz#689c5dcdc1900ef5583a4cb9f6d7b473742074ad" integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ== @@ -1109,11 +1268,50 @@ rollup@^4.34.9, rollup@^4.59.0: "@rollup/rollup-win32-x64-msvc" "4.59.0" fsevents "~2.3.2" +semver@^7.7.3: + version "7.7.4" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a" + integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA== + set-cookie-parser@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/set-cookie-parser/-/set-cookie-parser-3.0.1.tgz#a902ea39d692837bcfd7160891f06b2427321a18" integrity sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q== +sharp@^0.34.5: + version "0.34.5" + resolved "https://registry.yarnpkg.com/sharp/-/sharp-0.34.5.tgz#b6f148e4b8c61f1797bde11a9d1cfebbae2c57b0" + integrity sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg== + dependencies: + "@img/colour" "^1.0.0" + detect-libc "^2.1.2" + semver "^7.7.3" + optionalDependencies: + "@img/sharp-darwin-arm64" "0.34.5" + "@img/sharp-darwin-x64" "0.34.5" + "@img/sharp-libvips-darwin-arm64" "1.2.4" + "@img/sharp-libvips-darwin-x64" "1.2.4" + "@img/sharp-libvips-linux-arm" "1.2.4" + "@img/sharp-libvips-linux-arm64" "1.2.4" + "@img/sharp-libvips-linux-ppc64" "1.2.4" + "@img/sharp-libvips-linux-riscv64" "1.2.4" + "@img/sharp-libvips-linux-s390x" "1.2.4" + "@img/sharp-libvips-linux-x64" "1.2.4" + "@img/sharp-libvips-linuxmusl-arm64" "1.2.4" + "@img/sharp-libvips-linuxmusl-x64" "1.2.4" + "@img/sharp-linux-arm" "0.34.5" + "@img/sharp-linux-arm64" "0.34.5" + "@img/sharp-linux-ppc64" "0.34.5" + "@img/sharp-linux-riscv64" "0.34.5" + "@img/sharp-linux-s390x" "0.34.5" + "@img/sharp-linux-x64" "0.34.5" + "@img/sharp-linuxmusl-arm64" "0.34.5" + "@img/sharp-linuxmusl-x64" "0.34.5" + "@img/sharp-wasm32" "0.34.5" + "@img/sharp-win32-arm64" "0.34.5" + "@img/sharp-win32-ia32" "0.34.5" + "@img/sharp-win32-x64" "0.34.5" + sirv@^3.0.0: version "3.0.2" resolved "https://registry.yarnpkg.com/sirv/-/sirv-3.0.2.tgz#f775fccf10e22a40832684848d636346f41cd970"