feat(social): add post image thumbnail to OG social card
Deploy / verify (push) Successful in 1m0s
Deploy / deploy (push) Successful in 1m57s

Ports the same loadPostImage + thumbBlock pattern from the calendar
social route. Reads postImage.src (fallback: image.src), loads via
CMS transform API as base64 JPEG, renders next to QR code.
Posts without an image are unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Peter Meier
2026-06-10 09:24:41 +02:00
parent 08181129c4
commit 1c63f68c0b
+42 -5
View File
@@ -98,7 +98,7 @@ function formatDateLong(iso: string | undefined | null): string {
const cache = new Map<string, { png: Buffer; ts: number }>();
const CACHE_TTL = 10 * 60_000;
const CACHE_VERSION = "v1";
const CACHE_VERSION = "v2";
function parseFormat(v: string | null): Format {
if (v === "square") return "square";
@@ -111,6 +111,24 @@ function parseTheme(v: string | null): Theme {
return "wald";
}
async function loadPostImage(
imageSrc: string | undefined,
w: number,
h: number,
): Promise<string | null> {
if (!imageSrc) return null;
try {
const cmsBase = (env.PUBLIC_CMS_URL || "http://localhost:3000").replace(/\/$/, "");
const transformUrl = `${cmsBase}/api/transform?url=${encodeURIComponent(imageSrc)}&w=${w}&h=${h}&fit=inside&format=jpeg&quality=80`;
const res = await fetch(transformUrl);
if (!res.ok) return null;
const buf = Buffer.from(await res.arrayBuffer());
return `data:image/jpeg;base64,${buf.toString("base64")}`;
} catch {
return null;
}
}
function escapeHtml(s: string): string {
return s.replace(/</g, "").replace(/>/g, "");
}
@@ -150,12 +168,24 @@ export const GET: RequestHandler = async ({ params, url, setHeaders }) => {
tags?: unknown;
created?: string;
updated?: string;
postImage?: { src?: string } | string;
image?: { src?: string } | string;
};
const { w, h } = SIZES[formatParam];
const fonts = await loadFonts();
const logoSvg = await loadLogo();
const rawImageSrc = post.postImage ?? post.image;
const imageSrc =
typeof rawImageSrc === "string"
? rawImageSrc
: rawImageSrc && typeof rawImageSrc === "object"
? (rawImageSrc as { src?: string }).src
: undefined;
const thumbSize = formatParam === "story" ? 280 : 180;
const postThumb = imageSrc ? await loadPostImage(imageSrc, thumbSize * 2, thumbSize * 2) : null;
const headline = (post.headline ?? post.linkName ?? "").trim();
const subheadline = (post.subheadline ?? "").trim();
const excerpt = (post.excerpt ?? "").trim();
@@ -302,12 +332,19 @@ export const GET: RequestHandler = async ({ params, url, setHeaders }) => {
`;
const qrAlign = verticalStack ? "flex-start" : "center";
const thumbMax = qrSize + 28;
const thumbBlock = postThumb
? `<div style="display:flex;align-items:center;justify-content:center;max-width:${thumbMax}px;max-height:${thumbMax}px;"><img src="${postThumb}" style="max-width:${thumbMax}px;max-height:${thumbMax}px;object-fit:contain;" /></div>`
: "";
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 style="display:flex;align-items:flex-start;gap:${verticalStack ? 16 : 20}px;">
<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>
<div style="display:flex;font-size:${isStory ? 22 : 16}px;color:${palette.textDim};font-weight:500;letter-spacing:0.05em;">Beitrag lesen</div>
${thumbBlock}
</div>
`;