/** * Bild-URLs für SvelteKit: /cms-images mit persistentem Server-Disk-Cache. * 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; } /** * Extrahiert URL + Focal + Alt aus einem CMS-Image-Field (string oder {src, alt?, focal?}). * Normalisiert Protokoll-lose URLs zu https:. */ export function extractCmsImageField(field: unknown): CmsImageField | null { if (typeof field === 'string') { const s = field.trim(); if (!s) return null; return { url: s.startsWith('//') ? `https:${s}` : s }; } if (field && typeof field === 'object') { 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 (!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; ar?: string; 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; /** * Cache-busting tag (e.g. post._updated). Serialized as `_v` and is the * only param the cms-images endpoint deliberately ignores when forwarding * to RustyCMS — its sole job is to invalidate the browser HTTP cache when * the underlying entry (focal, alt, replacement) changes. */ version?: string; } function roundFocal(v: number): number { return Math.round(v * 10000) / 10000; } /** * Lokale Bild-URL: /cms-images?src=…&w=…&q=… * `src` = volle Bild-URL oder CMS-Asset-Dateiname (wird serverseitig zu /api/assets/… aufgelöst). */ export function getCmsImageUrl(src: string, params: RustyImageTransformParams = {}): string { const searchParams = new URLSearchParams(); searchParams.set('src', src); if (params.width != null) searchParams.set('w', String(params.width)); if (params.height != null) searchParams.set('h', String(params.height)); if (params.ar != null) searchParams.set('ar', params.ar); 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))); if (params.version) searchParams.set('_v', params.version); return `/cms-images?${searchParams.toString()}`; } /** * @deprecated Bevorzuge getCmsImageUrl — gleiche Signatur, liefert /cms-images (Disk-Cache). */ export function getTransformUrl(url: string, params: RustyImageTransformParams = {}): string { return getCmsImageUrl(url, params); } /** Alias für Kompatibilität (z. B. rustyastro / Layout). */ export async function ensureTransformedImage(url: string, params: RustyImageTransformParams = {}): Promise { return getCmsImageUrl(url, params); } 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; if (item && typeof item === 'object' && typeof item.src === 'string') return item.src.startsWith('//') ? `https:${item.src}` : item.src; return null; } export async function resolveImageUrls(items: ImageArrayItem[], params: RustyImageTransformParams = {}): Promise { 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 { row1Content?: unknown[]; row2Content?: unknown[]; row3Content?: unknown[]; } const MARKDOWN_IMAGE_PARAMS: RustyImageTransformParams = { width: 1200, fit: 'contain', format: 'webp', quality: 85, }; const IMG_SRC_REGEX = /]*?)src\s*=\s*["']([^"']+)["']([^>]*)>/gi; export async function processMarkdownHtmlImages(html: string, _baseUrl?: string, transformParams: RustyImageTransformParams = MARKDOWN_IMAGE_PARAMS): Promise { const matches = [...html.matchAll(IMG_SRC_REGEX)]; const replacements: { index: number; length: number; newBlock: string }[] = []; for (const m of matches) { const rawSrc = m[2]; const url = rawSrc.startsWith('//') ? `https:${rawSrc}` : rawSrc; if (!url.startsWith('http')) continue; const transformedPath = getCmsImageUrl(url, transformParams); const href = transformedPath; const newImg = m[0].replace(rawSrc, transformedPath); const newBlock = `${newImg}`; replacements.push({ index: m.index ?? 0, length: m[0].length, newBlock }); } if (replacements.length === 0) return html; replacements.sort((a, b) => b.index - a.index); let out = html; for (const r of replacements) { out = out.slice(0, r.index) + r.newBlock + out.slice(r.index + r.length); } return out; } export async function resolveContentImages(layout: RowContentLayoutLike, transformParamsOrOptions: RustyImageTransformParams | { transformParams?: RustyImageTransformParams; baseUrl?: string } = {}): Promise { const options = transformParamsOrOptions && 'baseUrl' in transformParamsOrOptions ? (transformParamsOrOptions as { transformParams?: RustyImageTransformParams; baseUrl?: string }) : { transformParams: transformParamsOrOptions as RustyImageTransformParams }; const transformParams = options.transformParams ?? {}; const rows = [layout.row1Content, layout.row2Content, layout.row3Content].filter((r): r is unknown[] => Array.isArray(r)); const galleryParams: RustyImageTransformParams = { width: 1280, fit: 'contain', format: 'webp', quality: 80, ...transformParams, }; const orgLogoParams: RustyImageTransformParams = { width: 256, fit: 'contain', format: 'webp', quality: 85, ...transformParams, }; const { marked } = await import('marked'); marked.setOptions({ gfm: true }); for (const content of rows) { for (const item of content) { if (typeof item !== 'object' || item === null) continue; const block = item as { _type?: string; content?: string; image?: unknown; img?: unknown; images?: Array<{ src?: string; file?: { url?: string }; resolvedSrc?: string }>; organisations?: Array; resolvedImageSrc?: string; resolvedContent?: string; }; if (block._type === 'markdown' && typeof block.content === 'string' && block.content.trim()) { const html = marked.parse(block.content) as string; block.resolvedContent = await processMarkdownHtmlImages(html, undefined, { ...MARKDOWN_IMAGE_PARAMS, ...transformParams, }); continue; } if (block._type === 'image') { const raw = block.image ?? block.img; const field = extractCmsImageField(raw); if (field) { block.resolvedImageSrc = getCmsImageUrl(field.url, { ...transformParams, ...(field.focal ? { focalX: field.focal.x, focalY: field.focal.y } : {}), }); } continue; } if (block._type === 'image_gallery' && Array.isArray(block.images)) { for (const img of block.images) { if (!img || typeof img !== 'object') continue; 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; } if (block._type === 'organisations' && Array.isArray(block.organisations)) { for (const org of block.organisations) { if (!org || typeof org !== 'object') continue; const o = org as { logo?: unknown; resolvedLogoSrc?: string }; const field = extractCmsImageField(o.logo); if (field) { o.resolvedLogoSrc = getCmsImageUrl(field.url, { ...orgLogoParams, ...(field.focal ? { focalX: field.focal.x, focalY: field.focal.y } : {}), }); } } continue; } } } }