Initial SvelteKit frontend port of windwiderstand.de
Deploy / verify (push) Failing after 32s
Deploy / deploy (push) Has been skipped

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:
Peter Meier
2026-04-17 22:01:30 +02:00
parent 852cef0980
commit 2fef91a548
137 changed files with 28585 additions and 0 deletions
+203
View File
@@ -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;
}
}
}
}