diff --git a/lib/og.js b/lib/og.js index 357a89b..d1bcdbb 100644 --- a/lib/og.js +++ b/lib/og.js @@ -2,6 +2,9 @@ * OpenGraph image generation for posts without photos. * Uses Satori (layout → SVG) + @resvg/resvg-js (SVG → PNG). * Generated images are cached in .cache/og/ and passthrough-copied to output. + * + * Card design inspired by GitHub's OG images: light background, clean + * typography hierarchy, avatar, metadata row, and accent color bar. */ import satori from "satori"; @@ -22,14 +25,19 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const WIDTH = 1200; const HEIGHT = 630; +// Card design version — bump to force full regeneration +const DESIGN_VERSION = 2; + const COLORS = { - bg: "#09090b", - title: "#f4f4f5", - date: "#a1a1aa", - siteName: "#71717a", + bg: "#ffffff", + title: "#24292f", + description: "#57606a", + meta: "#57606a", accent: "#3b82f6", - badge: "#2563eb", - badgeText: "#ffffff", + badge: "#ddf4ff", + badgeText: "#0969da", + border: "#d8dee4", + bar: "#3b82f6", }; const POST_TYPE_MAP = { @@ -48,6 +56,18 @@ const POST_TYPE_MAP = { events: "Event", }; +let avatarDataUri = null; + +function loadAvatar() { + if (avatarDataUri) return avatarDataUri; + const avatarPath = resolve(__dirname, "..", "images", "rick.jpg"); + if (existsSync(avatarPath)) { + const buf = readFileSync(avatarPath); + avatarDataUri = `data:image/jpeg;base64,${buf.toString("base64")}`; + } + return avatarDataUri; +} + function loadFonts() { const fontsDir = resolve( __dirname, @@ -73,9 +93,9 @@ function loadFonts() { ]; } -function computeHash(title, date, postType, siteName) { +function computeHash(title, description, date, postType, siteName) { return createHash("md5") - .update(`${title}|${date}|${postType}|${siteName}`) + .update(`v${DESIGN_VERSION}|${title}|${description}|${date}|${postType}|${siteName}`) .digest("hex") .slice(0, 12); } @@ -97,7 +117,7 @@ function formatDate(dateStr) { if (Number.isNaN(d.getTime())) return ""; return d.toLocaleDateString("en-US", { year: "numeric", - month: "long", + month: "short", day: "numeric", }); } catch { @@ -107,27 +127,31 @@ function formatDate(dateStr) { /** * Use the full filename (with date prefix) as the OG image slug. - * This matches the URL path segment directly, avoiding Eleventy's page.fileSlug - * race condition in Nunjucks parallel rendering. */ function toOgSlug(filename) { return filename; } -function truncateTitle(title, max = 120) { - if (!title || title.length <= max) return title || "Untitled"; - return title.slice(0, max).trim() + "\u2026"; +/** + * Sanitize text for Satori rendering — strip characters that cause NO GLYPH. + */ +function sanitize(text) { + if (!text) return ""; + return text.replace(/[^\x20-\x7E\u00A0-\u024F\u2010-\u2027\u2030-\u205E]/g, "").trim(); } -function extractBodyText(raw) { - const body = raw +/** + * Strip markdown formatting from raw content, returning plain text lines. + */ +function stripMarkdown(raw) { + return raw // Strip frontmatter .replace(/^---[\s\S]*?---\s*/, "") - // Strip images ![alt](url) + // Strip images .replace(/!\[[^\]]*\]\([^)]+\)/g, "") // Strip markdown tables (lines with pipes) .replace(/^\|.*\|$/gm, "") - // Strip table separator rows (|---|---|) + // Strip table separator rows .replace(/^\s*[-|: ]+$/gm, "") // Strip heading anchors {#id} .replace(/\{#[^}]+\}/g, "") @@ -135,127 +159,187 @@ function extractBodyText(raw) { .replace(/<[^>]+>/g, "") // Strip markdown links, keep text .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") - // Strip heading markers, bold, italic, strikethrough, code, blockquote - .replace(/[#*_~`>]/g, "") - // Strip list bullets (-, *, +) and numbered lists (1.) + // Strip heading markers + .replace(/^#{1,6}\s+/gm, "") + // Strip bold, italic, strikethrough, code, blockquote markers + .replace(/[*_~`>]/g, "") + // Strip list bullets and numbered lists .replace(/^\s*[-*+]\s+/gm, "") .replace(/^\s*\d+\.\s+/gm, "") // Strip horizontal rules - .replace(/^-{3,}$/gm, "") - // Collapse all whitespace (newlines, tabs, multiple spaces) - .replace(/\s+/g, " ") - .trim(); - if (!body) return "Untitled"; - // Strip any non-ASCII-printable characters that could cause NO GLYPH in Satori - const safe = body.replace(/[^\x20-\x7E\u00A0-\u024F\u2010-\u2027\u2030-\u205E]/g, "").trim(); - const text = safe || body; - return text.length > 120 ? text.slice(0, 120).trim() + "\u2026" : text; + .replace(/^-{3,}$/gm, ""); } -function buildCard(title, dateStr, postType, siteName) { +/** + * Extract the first paragraph from raw markdown content. + * Returns only the first meaningful block of text, ignoring headings, + * tables, lists, and other structural elements. + */ +function extractFirstParagraph(raw) { + const stripped = stripMarkdown(raw); + // Split into lines, find first non-empty line(s) that form a paragraph + const lines = stripped.split("\n"); + const paragraphLines = []; + let started = false; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) { + // Empty line: if we've started collecting, the paragraph is done + if (started) break; + continue; + } + started = true; + paragraphLines.push(trimmed); + } + + const text = paragraphLines.join(" ").replace(/\s+/g, " ").trim(); + if (!text) return ""; + const safe = sanitize(text); + return safe || text; +} + +function truncate(text, max) { + if (!text || text.length <= max) return text || ""; + return text.slice(0, max).trim() + "\u2026"; +} + +function buildCard(title, description, dateStr, postType, siteName) { + const avatar = loadAvatar(); + const formattedDate = formatDate(dateStr); + + // Bottom metadata: "Note · Mar 10, 2026" + const metaParts = [postType, formattedDate].filter(Boolean).join(" \u00b7 "); + return { type: "div", props: { style: { display: "flex", + flexDirection: "column", width: `${WIDTH}px`, height: `${HEIGHT}px`, backgroundColor: COLORS.bg, }, children: [ - { - type: "div", - props: { - style: { - width: "6px", - height: "100%", - backgroundColor: COLORS.accent, - flexShrink: 0, - }, - }, - }, + // Main content area { type: "div", props: { style: { display: "flex", - flexDirection: "column", - justifyContent: "space-between", - padding: "60px", flex: 1, - overflow: "hidden", + padding: "60px 60px 0 60px", }, children: [ + // Left: text content { type: "div", props: { style: { display: "flex", flexDirection: "column", - gap: "24px", + flex: 1, + gap: "20px", + overflow: "hidden", + paddingRight: avatar ? "40px" : "0", }, children: [ - { - type: "div", - props: { - style: { display: "flex" }, - children: [ - { - type: "span", - props: { - style: { - backgroundColor: COLORS.badge, - color: COLORS.badgeText, - fontSize: "16px", - fontWeight: 700, - fontFamily: "Inter", - padding: "6px 16px", - borderRadius: "999px", - textTransform: "uppercase", - letterSpacing: "0.05em", - }, - children: postType, - }, - }, - ], - }, - }, + // Title { type: "div", props: { style: { color: COLORS.title, - fontSize: "48px", + fontSize: "42px", fontWeight: 700, fontFamily: "Inter", - lineHeight: 1.2, + lineHeight: 1.25, overflow: "hidden", }, - children: truncateTitle(title), + children: truncate(title, 120), }, }, - dateStr + // Description (if available) + description ? { type: "div", props: { style: { - color: COLORS.date, + color: COLORS.description, fontSize: "24px", fontWeight: 400, fontFamily: "Inter", + lineHeight: 1.4, + overflow: "hidden", }, - children: formatDate(dateStr), + children: truncate(description, 160), }, } : null, ].filter(Boolean), }, }, + // Right: avatar + avatar + ? { + type: "div", + props: { + style: { + display: "flex", + flexShrink: 0, + alignItems: "flex-start", + }, + children: [ + { + type: "img", + props: { + src: avatar, + width: 120, + height: 120, + style: { + borderRadius: "12px", + border: `2px solid ${COLORS.border}`, + }, + }, + }, + ], + }, + } + : null, + ].filter(Boolean), + }, + }, + // Bottom metadata row + { + type: "div", + props: { + style: { + display: "flex", + justifyContent: "space-between", + alignItems: "center", + padding: "0 60px 40px 60px", + }, + children: [ + // Left: post type · date { type: "div", props: { style: { - color: COLORS.siteName, + color: COLORS.meta, + fontSize: "20px", + fontWeight: 400, + fontFamily: "Inter", + }, + children: metaParts, + }, + }, + // Right: site name + { + type: "div", + props: { + style: { + color: COLORS.meta, fontSize: "20px", fontWeight: 400, fontFamily: "Inter", @@ -266,6 +350,18 @@ function buildCard(title, dateStr, postType, siteName) { ], }, }, + // Bottom accent bar + { + type: "div", + props: { + style: { + width: "100%", + height: "6px", + backgroundColor: COLORS.bar, + flexShrink: 0, + }, + }, + }, ], }, }; @@ -323,9 +419,6 @@ export async function generateOgImages(contentDir, cacheDir, siteName, batchSize const newManifest = { ...manifest }; const SAVE_INTERVAL = 10; // GC every 5 images to keep WASM native memory bounded. - // Satori (Yoga WASM) + Resvg (Rust WASM) allocate ~50-100 MB native memory - // per image that V8 doesn't track. Without aggressive GC, native memory - // grows unbounded and OOM-kills the process in constrained containers. const GC_INTERVAL = 5; const hasGC = typeof global.gc === "function"; let peakRss = 0; @@ -340,10 +433,18 @@ export async function generateOgImages(contentDir, cacheDir, siteName, batchSize } const slug = toOgSlug(basename(filePath, ".md")); - const title = fm.title || fm.name || extractBodyText(raw); - const date = fm.published || fm.date || ""; const postType = detectPostType(filePath); - const hash = computeHash(title, date, postType, siteName); + const date = fm.published || fm.date || ""; + + // Title: use frontmatter title/name, or first paragraph of body + const fmTitle = fm.title || fm.name || ""; + const bodyText = extractFirstParagraph(raw); + const title = fmTitle || bodyText || "Untitled"; + + // Description: only show if we have a frontmatter title (so body adds context) + const description = fmTitle ? bodyText : ""; + + const hash = computeHash(title, description, date, postType, siteName); if (manifest[slug]?.hash === hash && existsSync(join(ogDir, `${slug}.png`))) { newManifest[slug] = manifest[slug]; @@ -351,7 +452,7 @@ export async function generateOgImages(contentDir, cacheDir, siteName, batchSize continue; } - const card = buildCard(title, date, postType, siteName); + const card = buildCard(title, description, date, postType, siteName); const svg = await satori(card, { width: WIDTH, height: HEIGHT, fonts }); const resvg = new Resvg(svg, { fitTo: { mode: "width", value: WIDTH }, @@ -368,9 +469,6 @@ export async function generateOgImages(contentDir, cacheDir, siteName, batchSize } // Force GC to reclaim Satori/Resvg WASM native memory. - // V8 doesn't track native heap (Satori Yoga WASM + Resvg Rust WASM), - // so without frequent GC the JS wrappers accumulate and native memory - // grows unbounded. Every 5 images keeps peak RSS under ~400 MB. if (hasGC && generated % GC_INTERVAL === 0) { global.gc(); const rss = process.memoryUsage().rss; @@ -378,7 +476,6 @@ export async function generateOgImages(contentDir, cacheDir, siteName, batchSize } // Batch limit: stop after N images so the caller can re-spawn - // (fully releasing WASM native memory between batches) if (batchSize > 0 && generated >= batchSize) { break; }