a139372ce2
- 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.
263 lines
9.1 KiB
TypeScript
263 lines
9.1 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|
|
|
|
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)));
|
|
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<string> {
|
|
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<string[]> {
|
|
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 = /<img\s+([^>]*?)src\s*=\s*["']([^"']+)["']([^>]*)>/gi;
|
|
|
|
export async function processMarkdownHtmlImages(html: string, _baseUrl?: string, transformParams: RustyImageTransformParams = MARKDOWN_IMAGE_PARAMS): Promise<string> {
|
|
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 = `<a href="${href}" target="_blank" rel="noopener noreferrer" class="markdown-image-link">${newImg}</a>`;
|
|
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<void> {
|
|
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<unknown>;
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
}
|