Files
windwiderstand/src/lib/rusty-image.ts
T
Peter Meier ecff7b0bbf
Deploy / verify (push) Successful in 40s
Deploy / deploy (push) Successful in 1m1s
feat(images): cache-bust /cms-images URLs by post._updated
Browser caches /cms-images responses immutably for a year. When the
editor changes a focal point or alt text, the underlying transform
output changes too, but the URL stays the same — so the browser keeps
serving the previous bytes. Manual hard-reloads and /api/cache/clear
help once but not for visitors who already have the file.

- RustyImageTransformParams gains an optional `version` that
  serializes as `_v` in the /cms-images URL.
- RustyImage.svelte takes a matching `version` prop and forwards it
  through `baseParams`, so every <picture> source carries the tag.
- The post thumbnail passes `post._updated` (truncated to seconds)
  as version. Each save bumps `_updated`, the URL changes, browsers
  re-fetch.

`_v` is intentionally not consumed by `/cms-images` — its sole job is
to break the browser cache key. The server-side image-cache and the
RustyCMS transform cache key both ignore it, so disk usage doesn't
balloon when the version tag changes.
2026-04-25 09:43:34 +02:00

271 lines
9.5 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;
/**
* 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<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;
}
}
}
}