import type { RequestHandler } from "./$types"; import { error } from "@sveltejs/kit"; import satori from "satori"; import { html as htmlToReact } from "satori-html"; import { Resvg } from "@resvg/resvg-js"; import { toBuffer as qrToBuffer } from "qrcode"; import { env } from "$env/dynamic/public"; import { getPostBySlug, getPageConfigs } from "$lib/cms"; type Format = "og" | "square" | "story"; type Theme = "wald" | "transparent-onlight" | "transparent-ondark"; const SIZES: Record = { og: { w: 1200, h: 630 }, square: { w: 1080, h: 1080 }, story: { w: 1080, h: 1920 }, }; const FONT_URLS: Record = { regular: "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfMZhrib2Bg-4.ttf", semibold: "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuFuYMZhrib2Bg-4.ttf", bold: "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuGKYMZhrib2Bg-4.ttf", }; let fontCache: { regular: ArrayBuffer; semibold: ArrayBuffer; bold: ArrayBuffer } | null = null; let logoSvgCache: { svg: string; recolored: Record } | null = null; async function loadFonts() { if (fontCache) return fontCache; const [regular, semibold, bold] = await Promise.all( (["regular", "semibold", "bold"] as const).map(async (w) => { const res = await fetch(FONT_URLS[w]); if (!res.ok) throw new Error(`Font fetch failed (${w}): ${res.status}`); return res.arrayBuffer(); }), ); fontCache = { regular, semibold, bold }; return fontCache; } async function loadLogo(): Promise { if (logoSvgCache) return logoSvgCache.svg; try { const configs = await getPageConfigs({ locale: "de" }); const cfg = configs[0]; const rawLogo = (cfg as { logo?: unknown })?.logo; const rawSrc = typeof rawLogo === "string" ? rawLogo : (rawLogo as { src?: string } | undefined)?.src ?? null; if (!rawSrc) return null; const cmsBase = (env.PUBLIC_CMS_URL || "http://localhost:3000").replace(/\/$/, ""); const u = rawSrc.startsWith("//") ? `https:${rawSrc}` : rawSrc.startsWith("/") ? `${cmsBase}${rawSrc}` : rawSrc.startsWith("http") ? rawSrc : `${cmsBase}/api/assets/${rawSrc}`; if (!u.split("?")[0].toLowerCase().endsWith(".svg")) return null; const res = await fetch(u); if (!res.ok) return null; let text = (await res.text()).trim(); text = text.replace(/<\?xml[^?]*\?>/g, "").replace(/]*>/g, "").trim(); if (!text.startsWith("(); const CACHE_TTL = 10 * 60_000; const CACHE_VERSION = "v1"; function parseFormat(v: string | null): Format { if (v === "square") return "square"; if (v === "story") return "story"; return "og"; } function parseTheme(v: string | null): Theme { if (v === "transparent-ondark") return "transparent-ondark"; if (v === "transparent-onlight" || v === "transparent") return "transparent-onlight"; return "wald"; } function escapeHtml(s: string): string { return s.replace(//g, "›"); } function stripMd(s: string): string { return s .replace(/!\[[^\]]*\]\([^)]*\)/g, "") .replace(/\[[^\]]*\]\([^)]*\)/g, "") .replace(/[#*_`~>\[\]]/g, " ") .replace(/\s+/g, " ") .trim(); } export const GET: RequestHandler = async ({ params, url, setHeaders }) => { const slug = params.slug; const formatParam = parseFormat(url.searchParams.get("format")); const themeParam = parseTheme(url.searchParams.get("theme")); const cacheKey = `${CACHE_VERSION}:${slug}:${formatParam}:${themeParam}`; const now = Date.now(); const cached = cache.get(cacheKey); if (cached && now - cached.ts < CACHE_TTL) { setHeaders({ "content-type": "image/png", "cache-control": "public, max-age=600, stale-while-revalidate=86400", }); return new Response(new Uint8Array(cached.png), { status: 200 }); } const itemRaw = await getPostBySlug(slug, { locale: "de" }); if (!itemRaw) throw error(404, "Beitrag nicht gefunden"); const post = itemRaw as typeof itemRaw & { headline?: string; linkName?: string; subheadline?: string; excerpt?: string; content?: string; tags?: unknown; created?: string; updated?: string; }; const { w, h } = SIZES[formatParam]; const fonts = await loadFonts(); const logoSvg = await loadLogo(); const headline = (post.headline ?? post.linkName ?? "").trim(); const subheadline = (post.subheadline ?? "").trim(); const excerpt = (post.excerpt ?? "").trim(); const snippetSrc = subheadline || excerpt || stripMd(post.content ?? ""); const dateLabel = formatDateLong(post.updated ?? post.created); // Tags → Pill (erster Tag oder Kategorie aus Inhalt erkennen) const tagList = Array.isArray(post.tags) ? (post.tags as unknown[]).map((t) => typeof t === "string" ? t : (t as { _slug?: string; name?: string })?.name ?? (t as { _slug?: string })?._slug ?? "", ) : []; const titleLower = headline.toLowerCase(); const tagPillInfo = (() => { const candidates = [titleLower, ...tagList.map((t) => t.toLowerCase())]; const has = (kw: string) => candidates.some((c) => c.includes(kw)); if (has("pressemitteilung") || has("presse")) return { label: "Presse", color: "#b08a52" }; if (has("demo") || has("protest")) return { label: "Demo", color: "#e35651" }; if (has("rückblick") || has("retrospektive")) return { label: "Rückblick", color: "#5e6fa3" }; if (has("interview")) return { label: "Interview", color: "#436e85" }; if (has("studien") || has("studie") || has("forschung")) return { label: "Studie", color: "#6e8c46" }; if (has("urteil") || has("gericht") || has("rechtlich")) return { label: "Rechtlich", color: "#8a567a" }; if (has("kommentar") || has("lesermeinung") || has("meinung")) return { label: "Meinung", color: "#7f8c6e" }; if (tagList[0]) { const first = tagList[0].replace(/^tag-?/i, "").replace(/-/g, " ").trim(); if (first) return { label: first.slice(0, 20), color: "#3d6b4f" }; } return { label: "Beitrag", color: "#3d6b4f" }; })(); const siteUrl = (env.PUBLIC_SITE_URL || "https://windwiderstand.de").replace(/\/$/, ""); const eventUrl = `${siteUrl}/post/${encodeURIComponent(slug)}`; const isTransparent = themeParam !== "wald"; const isLight = themeParam === "transparent-ondark"; const palette = isTransparent ? isLight ? { bg: "transparent", text: "#ffffff", textMute: "rgba(255,255,255,0.85)", textDim: "rgba(255,255,255,0.7)", accent: "#a8e6c0", headerLabel: "rgba(255,255,255,0.75)", footerSep: "rgba(255,255,255,0.25)", qrBoxBg: "#ffffff", } : { bg: "transparent", text: "#0a2011", textMute: "#1a4d28", textDim: "#2d7a45", accent: "#1a4d28", headerLabel: "#236335", footerSep: "rgba(10,32,17,0.25)", qrBoxBg: "#0a2011", } : { bg: "linear-gradient(135deg,#0a2011 0%,#12361c 55%,#1a4d28 100%)", text: "#ffffff", textMute: "#d9e8dc", textDim: "#b3d1b9", accent: "#7fb58a", headerLabel: "#7fb58a", footerSep: "#1a4d28", qrBoxBg: "#ffffff", }; const isStory = formatParam === "story"; const isSquare = formatParam === "square"; const verticalStack = isStory || isSquare; // Dynamische Headline-Fontsize je Länge/Format const hLen = headline.length; const headlineFs = isStory ? hLen > 70 ? 64 : 80 : isSquare ? hLen > 60 ? 48 : 60 : hLen > 40 ? 42 : 56; const subFs = isStory ? 32 : isSquare ? 26 : 24; const datePillFs = isStory ? 26 : 20; const padX = isStory ? 90 : isSquare ? 80 : 90; const qrSize = isStory ? 260 : 170; const headerFs = isStory ? 26 : 20; const footerFs = isStory ? 24 : 20; // Snippet clampen const snipMaxLen = isStory ? 220 : isSquare ? 160 : 130; const snipClamped = snippetSrc ? snippetSrc.length > snipMaxLen ? snippetSrc.slice(0, snipMaxLen - 3) + "…" : snippetSrc : ""; const headlineClamped = hLen > 140 ? headline.slice(0, 137) + "…" : headline; const qrDark = "#0a2011"; const qrLight = "#ffffff"; const qrPngBuf = await qrToBuffer(eventUrl, { errorCorrectionLevel: "M", margin: 1, scale: 8, color: { dark: `${qrDark}ff`, light: `${qrLight}ff` }, }); const qrDataUrl = `data:image/png;base64,${qrPngBuf.toString("base64")}`; const logoColor = palette.headerLabel; const logoDataUrl = logoSvg ? `data:image/svg+xml;base64,${Buffer.from(colorizeLogo(logoSvg, logoColor)).toString("base64")}` : null; const logoH = isStory ? 60 : isSquare ? 44 : 36; // Tag-Pill rechts oben const tagPill = `
${escapeHtml(tagPillInfo.label)}
`; const brandBlock = logoDataUrl ? `
Windwiderstand Thüringen
` : `
Windwiderstand Thüringen
`; const headerRow = `
${brandBlock} ${tagPill}
`; // Datum-Pill (klein, oben-links nach Header) const dateBlock = dateLabel ? `
${dateLabel}
` : ""; const headlineBlock = `
${escapeHtml(headlineClamped)}
${ snipClamped ? `
${escapeHtml(snipClamped)}
` : "" }
`; const qrAlign = verticalStack ? "flex-start" : "center"; const qrBlock = `
Beitrag lesen
`; const footerBlock = `
windwiderstand.de
Mit Vernunft in die Zukunft
`; const mainArea = verticalStack ? `
${dateBlock} ${headlineBlock}
${qrBlock}
` : `
${dateBlock} ${headlineBlock}
${qrBlock}
`; const template = `
${headerRow} ${mainArea} ${footerBlock}
`; const reactLike = htmlToReact(template); const svg = await satori(reactLike as Parameters[0], { width: w, height: h, fonts: [ { name: "Inter", data: fonts.regular, weight: 400, style: "normal" }, { name: "Inter", data: fonts.semibold, weight: 500, style: "normal" }, { name: "Inter", data: fonts.semibold, weight: 600, style: "normal" }, { name: "Inter", data: fonts.bold, weight: 700, style: "normal" }, ], }); const resvg = new Resvg(svg, { fitTo: { mode: "width", value: w }, background: isTransparent ? "rgba(0,0,0,0)" : undefined, }); const png = resvg.render().asPng(); const pngBuf = Buffer.from(png); cache.set(cacheKey, { png: pngBuf, ts: now }); setHeaders({ "content-type": "image/png", "cache-control": "public, max-age=600, stale-while-revalidate=86400", }); return new Response(new Uint8Array(pngBuf), { status: 200 }); };