feat: Social-Bild-Generator auch für Posts (Endpoint, Modal, PostActions-Button)
Deploy / verify (push) Successful in 1m4s
Deploy / deploy (push) Successful in 1m23s

Neuer Endpoint /api/social/post/[slug] mit gleichem Pattern wie für
Termine: Logo, Tag-Pill aus Title/Tags-Keywords (Presse, Demo,
Rückblick, Interview, Studie, Rechtlich, Meinung etc.), Datum,
Headline mit dynamischer Fontsize, Subheadline/Excerpt-Snippet, QR
zum Beitrag, Footer. 3 Formate × 3 Themes wie Kalender.

SocialImageModal in eigene wiederverwendbare Komponente extrahiert
und sowohl im PostActions als auch im CalendarBlock eingebunden.
PostActions akzeptiert socialSlug-Prop und blendet bei vorhandenem
Slug einen Modal-Trigger-Button neben den anderen Aktions-Icons ein.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Peter Meier
2026-06-07 13:14:14 +02:00
parent 287a44ff6b
commit 155d9fb4b0
6 changed files with 555 additions and 141 deletions
@@ -0,0 +1,375 @@
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<Format, { w: number; h: number }> = {
og: { w: 1200, h: 630 },
square: { w: 1080, h: 1080 },
story: { w: 1080, h: 1920 },
};
const FONT_URLS: Record<string, string> = {
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<string, string> } | 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<string | null> {
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(/<!DOCTYPE[^>]*>/g, "").trim();
if (!text.startsWith("<svg")) return null;
logoSvgCache = { svg: text, recolored: {} };
return text;
} catch {
return null;
}
}
function colorizeLogo(svg: string, color: string): string {
if (!logoSvgCache) return svg;
if (logoSvgCache.recolored[color]) return logoSvgCache.recolored[color];
const recolored = svg
.replace(/fill="(?!none)[^"]*"/gi, `fill="${color}"`)
.replace(/stroke="(?!none)[^"]*"/gi, `stroke="${color}"`)
.replace(/currentColor/gi, color);
logoSvgCache.recolored[color] = recolored;
return recolored;
}
function formatDateLong(iso: string | undefined | null): string {
if (!iso) return "";
try {
return new Date(iso).toLocaleDateString("de-DE", {
day: "2-digit",
month: "long",
year: "numeric",
});
} catch {
return "";
}
}
const cache = new Map<string, { png: Buffer; ts: number }>();
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, "").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 = `<div style="display:flex;align-items:center;background:${tagPillInfo.color};color:#ffffff;padding:6px 14px;border-radius:999px;font-size:${headerFs - 4}px;letter-spacing:0.12em;font-weight:700;text-transform:uppercase;">${escapeHtml(tagPillInfo.label)}</div>`;
const brandBlock = logoDataUrl
? `<div style="display:flex;align-items:center;"><img src="${logoDataUrl}" width="${logoH}" height="${logoH}" style="width:${logoH}px;height:${logoH}px;" /><div style="display:flex;margin-left:14px;">Windwiderstand Thüringen</div></div>`
: `<div style="display:flex;">Windwiderstand Thüringen</div>`;
const headerRow = `
<div style="display:flex;justify-content:space-between;align-items:center;font-size:${headerFs}px;letter-spacing:0.18em;font-weight:600;color:${palette.headerLabel};text-transform:uppercase;">
${brandBlock}
${tagPill}
</div>
`;
// Datum-Pill (klein, oben-links nach Header)
const dateBlock = dateLabel
? `<div style="display:flex;align-items:center;font-size:${datePillFs}px;font-weight:600;color:${palette.textDim};letter-spacing:0.05em;text-transform:uppercase;margin-top:${isStory ? 48 : 28}px;">${dateLabel}</div>`
: "";
const headlineBlock = `
<div style="display:flex;flex-direction:column;margin-top:${isStory ? 20 : 14}px;">
<div style="display:flex;font-size:${headlineFs}px;font-weight:700;color:${palette.text};line-height:1.1;letter-spacing:-0.015em;">${escapeHtml(headlineClamped)}</div>
${
snipClamped
? `<div style="display:flex;font-size:${subFs}px;font-weight:400;color:${palette.textMute};line-height:1.35;margin-top:${isStory ? 32 : 20}px;">${escapeHtml(snipClamped)}</div>`
: ""
}
</div>
`;
const qrAlign = verticalStack ? "flex-start" : "center";
const qrBlock = `
<div style="display:flex;flex-direction:column;align-items:${qrAlign};gap:8px;">
<div style="display:flex;background:${palette.qrBoxBg};padding:14px;border-radius:12px;">
<img src="${qrDataUrl}" style="width:${qrSize}px;height:${qrSize}px;" />
</div>
<div style="display:flex;font-size:${isStory ? 22 : 16}px;color:${palette.textDim};font-weight:500;letter-spacing:0.05em;">Beitrag lesen</div>
</div>
`;
const footerBlock = `
<div style="display:flex;justify-content:space-between;align-items:center;font-size:${footerFs}px;color:${palette.accent};border-top:1px solid ${palette.footerSep};padding-top:${isStory ? 24 : 18}px;margin-top:${isStory ? 36 : 28}px;flex-shrink:0;">
<div style="display:flex;align-items:center;font-weight:600;">windwiderstand.de</div>
<div style="display:flex;align-items:center;font-weight:500;letter-spacing:0.04em;">Mit Vernunft in die Zukunft</div>
</div>
`;
const mainArea = verticalStack
? `
<div style="display:flex;flex-direction:column;flex:1;">
${dateBlock}
${headlineBlock}
</div>
<div style="display:flex;justify-content:flex-start;margin-top:${isStory ? 40 : 28}px;margin-bottom:${isStory ? 40 : 24}px;">
${qrBlock}
</div>
`
: `
<div style="display:flex;flex:1;gap:40px;">
<div style="display:flex;flex-direction:column;flex:1;min-width:0;">
${dateBlock}
${headlineBlock}
</div>
${qrBlock}
</div>
`;
const template = `
<div style="display:flex;flex-direction:column;width:${w}px;height:${h}px;background:${palette.bg};color:${palette.text};padding:${padX}px;font-family:Inter;position:relative;">
${headerRow}
${mainArea}
${footerBlock}
</div>
`;
const reactLike = htmlToReact(template);
const svg = await satori(reactLike as Parameters<typeof satori>[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 });
};