Files
windwiderstand/src/routes/api/social/post/[slug]/+server.ts
T
Peter Meier 155d9fb4b0
Deploy / verify (push) Successful in 1m4s
Deploy / deploy (push) Successful in 1m23s
feat: Social-Bild-Generator auch für Posts (Endpoint, Modal, PostActions-Button)
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>
2026-06-07 13:14:14 +02:00

376 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 });
};