Initial SvelteKit frontend port of windwiderstand.de
Full parity with Astro site: content rows, post/tag routes, pagination, event badges + OSM map, comments, Live-Search via /api/search-index, CMS image proxy, RSS, sitemap. Deploy: Dockerfile + docker-compose.prod.yml + Gitea Actions workflow to build + push to git.pm86.de registry and ssh-deploy to Contabo.
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Normalisiert CMS-Loopback-URLs (127.0.0.1) zur konfigurierten PUBLIC_CMS_URL.
|
||||
* Für Browser-seitigen Code (kein Node.js/fs nötig).
|
||||
*/
|
||||
export function absoluteUrlFromCmsImageField(field: unknown): string | null {
|
||||
if (typeof field === 'string') {
|
||||
const s = field.trim();
|
||||
if (!s) return null;
|
||||
return s.startsWith('//') ? `https:${s}` : s;
|
||||
}
|
||||
if (field && typeof field === 'object') {
|
||||
const o = field as { src?: string; file?: { url?: string } };
|
||||
const u =
|
||||
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;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface RustyImageTransformParams {
|
||||
width?: number;
|
||||
height?: number;
|
||||
ar?: string;
|
||||
fit?: 'fill' | 'contain' | 'cover';
|
||||
format?: 'jpeg' | 'png' | 'webp' | 'avif';
|
||||
quality?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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));
|
||||
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 };
|
||||
|
||||
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[]> {
|
||||
return items
|
||||
.map(toImageUrl)
|
||||
.filter((u): u is string => u !== null && u.startsWith('http'))
|
||||
.map((u) => getCmsImageUrl(u, params));
|
||||
}
|
||||
|
||||
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 url = absoluteUrlFromCmsImageField(raw);
|
||||
if (url) {
|
||||
block.resolvedImageSrc = getCmsImageUrl(url, transformParams);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
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 url = absoluteUrlFromCmsImageField(o.logo);
|
||||
if (url) {
|
||||
o.resolvedLogoSrc = getCmsImageUrl(url, orgLogoParams);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user