155d9fb4b0
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>
376 lines
14 KiB
TypeScript
376 lines
14 KiB
TypeScript
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 });
|
||
};
|