feat: Social-Media-Bild-Generator für Kalender-Termine

Neuer Endpoint /api/social/calendar/[slug] rendert Termin via satori +
resvg zu PNG. Formate: og (1200x630) und square (1080x1080).
Gebrandetes Layout mit Datum, Titel, Ort. Inter-Font lazy von gstatic,
10min In-Memory-Cache pro Slug+Format.

CalendarBlock: zwei Download-Buttons pro Termin (Bild quer / quadr.).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Peter Meier
2026-06-06 09:28:00 +02:00
parent d921b2758b
commit f0d9fecfb8
6 changed files with 619 additions and 0 deletions
@@ -886,6 +886,36 @@
>
</button>
{/if}
{#if item._slug}
<a
href={`/api/social/calendar/${item._slug}?format=og`}
download={`termin-${item._slug}-og.png`}
class="btn-outline btn-sm group"
aria-label="Social-Bild (Quer 1200×630)"
title="Social-Bild Quer (1200×630)"
>
<Icon
icon="mdi:image-outline"
class="size-3.5 shrink-0"
aria-hidden="true"
/>
<span class="whitespace-nowrap">Bild quer</span>
</a>
<a
href={`/api/social/calendar/${item._slug}?format=square`}
download={`termin-${item._slug}-square.png`}
class="btn-outline btn-sm group"
aria-label="Social-Bild (Quadrat 1080×1080)"
title="Social-Bild Quadrat (1080×1080)"
>
<Icon
icon="mdi:crop-square"
class="size-3.5 shrink-0"
aria-hidden="true"
/>
<span class="whitespace-nowrap">Bild quadr.</span>
</a>
{/if}
<button
type="button"
onclick={() => jumpToEventInGrid(item)}
@@ -114,6 +114,12 @@
"google": {
"body": "<path fill=\"currentColor\" d=\"M21.35 11.1h-9.17v2.73h6.51c-.33 3.81-3.5 5.44-6.5 5.44C8.36 19.27 5 16.25 5 12c0-4.1 3.2-7.27 7.2-7.27c3.09 0 4.9 1.97 4.9 1.97L19 4.72S16.56 2 12.1 2C6.42 2 2.03 6.8 2.03 12c0 5.05 4.13 10 10.22 10c5.35 0 9.25-3.67 9.25-9.09c0-1.15-.15-1.81-.15-1.81\"/>"
},
"image-outline": {
"body": "<path fill=\"currentColor\" d=\"M19 19H5V5h14m0-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2m-5.04 9.29l-2.75 3.54l-1.96-2.36L6.5 17h11z\"/>"
},
"crop-square": {
"body": "<path fill=\"currentColor\" d=\"M18 18H6V6h12m0-2H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2\"/>"
},
"calendar-search": {
"body": "<path fill=\"currentColor\" d=\"M15.5 12c2.5 0 4.5 2 4.5 4.5c0 .88-.25 1.71-.69 2.4l3.08 3.1L21 23.39l-3.12-3.07c-.69.43-1.51.68-2.38.68c-2.5 0-4.5-2-4.5-4.5s2-4.5 4.5-4.5m0 2a2.5 2.5 0 0 0-2.5 2.5a2.5 2.5 0 0 0 2.5 2.5a2.5 2.5 0 0 0 2.5-2.5a2.5 2.5 0 0 0-2.5-2.5M19 8H5v11h4.5c.31.75.76 1.42 1.31 2H5a2 2 0 0 1-2-2V5c0-1.11.89-2 2-2h1V1h2v2h8V1h2v2h1a2 2 0 0 1 2 2v8.03c-.5-.81-1.2-1.49-2-2.03z\"/>"
},
@@ -0,0 +1,154 @@
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 { getCalendarItemBySlug } from "$lib/cms";
type Format = "og" | "square";
const SIZES: Record<Format, { w: number; h: number }> = {
og: { w: 1200, h: 630 },
square: { w: 1080, h: 1080 },
};
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;
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;
}
function formatDate(iso: string | undefined): { date: string; time: string | null } {
if (!iso) return { date: "", time: null };
const d = new Date(iso);
const date = d.toLocaleDateString("de-DE", { day: "2-digit", month: "long", year: "numeric" });
const hasTime = !/T00:00:00/.test(iso);
const time = hasTime ? d.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }) : null;
return { date, time };
}
function weekday(iso: string | undefined): string {
if (!iso) return "";
return new Date(iso).toLocaleDateString("de-DE", { weekday: "long" });
}
const cache = new Map<string, { png: Buffer; ts: number }>();
const CACHE_TTL = 10 * 60_000; // 10min
export const GET: RequestHandler = async ({ params, url, setHeaders }) => {
const slug = params.slug;
const formatParam = url.searchParams.get("format") === "square" ? "square" : "og";
const cacheKey = `${slug}:${formatParam}`;
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 getCalendarItemBySlug(slug, { locale: "de" });
if (!itemRaw) throw error(404, "Termin nicht gefunden");
const item = itemRaw as typeof itemRaw & { location?: unknown };
const { w, h } = SIZES[formatParam];
const fonts = await loadFonts();
const title = (item.title ?? "").trim();
const { date, time } = formatDate(item.terminZeit);
const day = weekday(item.terminZeit);
const locationText =
typeof item.location === "object" && item.location !== null
? ((item.location as { text?: string }).text ?? "").trim()
: "";
const square = formatParam === "square";
const titleFs = square ? 56 : 54;
const dateFs = square ? 84 : 78;
const padX = square ? 80 : 90;
const titleClamped = title.length > 110 ? title.slice(0, 107) + "…" : title;
const locClamped =
locationText.length > 70 ? locationText.slice(0, 67) + "…" : locationText;
const pinSvg = `<svg width="34" height="34" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 22s7-7.58 7-13a7 7 0 1 0-14 0c0 5.42 7 13 7 13z" fill="#7fb58a"/><circle cx="12" cy="9" r="2.5" fill="#0a2011"/></svg>`;
const template = `
<div style="display:flex;flex-direction:column;width:${w}px;height:${h}px;background:linear-gradient(135deg,#0a2011 0%,#12361c 55%,#1a4d28 100%);color:#f0f5f1;padding:${padX}px;font-family:Inter;position:relative;">
<div style="display:flex;justify-content:space-between;align-items:center;font-size:20px;letter-spacing:0.18em;font-weight:600;color:#7fb58a;text-transform:uppercase;">
<div style="display:flex;align-items:center;">Windwiderstand Thüringen</div>
<div style="display:flex;align-items:center;color:#b3d1b9;">Termin</div>
</div>
<div style="display:flex;flex-direction:column;margin-top:${square ? 56 : 28}px;flex:1;">
<div style="display:flex;font-size:28px;color:#b3d1b9;font-weight:500;text-transform:capitalize;margin-bottom:6px;">${day}</div>
<div style="display:flex;align-items:baseline;flex-wrap:wrap;">
<div style="display:flex;font-size:${dateFs}px;font-weight:700;color:#ffffff;letter-spacing:-0.02em;line-height:1;">${date}</div>
${time ? `<div style="display:flex;font-size:44px;font-weight:600;color:#d9e8dc;margin-left:26px;line-height:1;">${time} Uhr</div>` : ""}
</div>
<div style="display:flex;font-size:${titleFs}px;font-weight:700;color:#ffffff;line-height:1.15;margin-top:${square ? 48 : 30}px;letter-spacing:-0.015em;">${escapeHtml(titleClamped)}</div>
</div>
${
locClamped
? `<div style="display:flex;align-items:center;font-size:26px;color:#d9e8dc;font-weight:500;margin-bottom:24px;gap:14px;">${pinSvg}<div style="display:flex;">${escapeHtml(locClamped)}</div></div>`
: ""
}
<div style="display:flex;justify-content:space-between;align-items:center;font-size:20px;color:#7fb58a;border-top:1px solid #1a4d28;padding-top:18px;">
<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>
</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 } });
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 });
};
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}