/* eslint-disable @next/next/no-img-element -- Satori (ImageResponse) only renders raw ; next/image doesn't work inside OG image generation. */ /** * OG card for a single post. * * Renders a 1200×630 image with: * * • The post caption (the quote) as the visual centrepiece, in * serif-toned weight so it reads as a passage rather than UI chrome. * • The source reference below — citation for the underlying scripture. * • Author byline (avatar + name + @handle) bottom-left. * • The post's first attached image as a dimmed background when * available — same content the author sees on the card itself. * * Falls back to a flat cream background when the post has no images. * The light/dark text treatment swaps based on which surface is showing. */ import { ImageResponse } from "next/og"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { bff } from "@/lib/bff"; export const runtime = "nodejs"; type PostImage = { id: string; url: string; position: number }; type Post = { id: string; user_id: string; caption: string; source_url: string; source_title: string; images: PostImage[]; author_name?: string; author_handle?: string; author_avatar_url?: string | null; }; let notoRegularData: Buffer | null = null; let notoBoldData: Buffer | null = null; async function loadFonts() { const fontsDir = join(process.cwd(), "public/fonts/Noto_Sans/static"); if (!notoRegularData) { notoRegularData = await readFile(join(fontsDir, "NotoSans-Regular.ttf")); } if (!notoBoldData) { notoBoldData = await readFile(join(fontsDir, "NotoSans-Bold.ttf")); } return { regular: notoRegularData, bold: notoBoldData }; } function initialsFor(name: string): string { return ( name .split(/\s+/) .filter(Boolean) .map((p) => p[0]) .slice(0, 2) .join("") .toUpperCase() || "?" ); } export async function GET( _request: Request, context: { params: Promise<{ id: string }> }, ) { try { const { id } = await context.params; const post = await bff(`/posts/${encodeURIComponent(id)}`, { revalidate: 60, }).catch(() => null); if (!post) { return new Response("Post not found", { status: 404 }); } const fonts = await loadFonts(); const caption = post.caption || ""; const displayCaption = caption.length > 280 ? caption.slice(0, 277) + "…" : caption; const reference = post.source_title || "Source"; const authorName = post.author_name || "Unknown"; const authorHandle = post.author_handle ? `@${post.author_handle}` : ""; const bgImageUrl = post.images?.[0]?.url ?? null; const hasImage = !!bgImageUrl; // Colour palette flips for image-backed cards so the caption stays // legible over the dimmed photo. const textPrimary = hasImage ? "#ffffff" : "#2d2418"; const textSecondary = hasImage ? "rgba(255,255,255,0.85)" : "#5a4a3a"; const accent = hasImage ? "#ffd9a8" : "#c47a5a"; return new ImageResponse( (
{/* Background image + dimming overlay */} {hasImage ? ( <> {/* Bottom-up gradient anchors the caption against the image */}
) : null} {/* Content layer — holds the padding so the bg image stays full-bleed (absolute children measure from the padding box, so padding on the root would inset the image and show a strip). */}
{/* Caption — the centrepiece */}
160 ? 36 : 44, fontWeight: 700, color: textPrimary, lineHeight: 1.35, marginTop: 20, maxHeight: 380, overflow: "hidden", zIndex: 1, }} > “{displayCaption}”
{/* Reference (source citation) */}
{reference}
{/* Spacer pushes the byline + branding to the bottom */}
{/* Bottom row: author + branding */}
{post.author_avatar_url ? ( ) : (
{initialsFor(authorName)}
)}
{authorName}
{authorHandle ? (
{authorHandle}
) : null}
prabhupadasays.org
), { width: 1200, height: 630, fonts: [ { name: "Noto Sans", data: fonts.regular, style: "normal" as const, weight: 400, }, { name: "Noto Sans", data: fonts.bold, style: "normal" as const, weight: 700, }, ], }, ); } catch (e) { console.error("Post OG image generation failed:", e); return new Response("Failed to generate OG image", { status: 500 }); } }