mirror of
https://github.com/svemagie/blog-eleventy-indiekit.git
synced 2026-05-14 22:48:50 +02:00
feat(og): GitHub-inspired card design + first-paragraph-only extraction
- Light background, clean typography hierarchy, avatar, metadata row, accent bar - extractBodyText → extractFirstParagraph (stops at first paragraph break) - Articles with fm.title get body text as description; notes show first paragraph as title - DESIGN_VERSION bump forces full regeneration without manual cache clearing - sanitize() strips non-renderable chars to prevent Satori NO GLYPH artifacts Confab-Link: http://localhost:8080/sessions/5565387e-4eb5-4441-89fb-2c6347de8e0c
This commit is contained in:
@@ -2,6 +2,9 @@
|
|||||||
* OpenGraph image generation for posts without photos.
|
* OpenGraph image generation for posts without photos.
|
||||||
* Uses Satori (layout → SVG) + @resvg/resvg-js (SVG → PNG).
|
* Uses Satori (layout → SVG) + @resvg/resvg-js (SVG → PNG).
|
||||||
* Generated images are cached in .cache/og/ and passthrough-copied to output.
|
* 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";
|
import satori from "satori";
|
||||||
@@ -22,14 +25,19 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|||||||
const WIDTH = 1200;
|
const WIDTH = 1200;
|
||||||
const HEIGHT = 630;
|
const HEIGHT = 630;
|
||||||
|
|
||||||
|
// Card design version — bump to force full regeneration
|
||||||
|
const DESIGN_VERSION = 2;
|
||||||
|
|
||||||
const COLORS = {
|
const COLORS = {
|
||||||
bg: "#09090b",
|
bg: "#ffffff",
|
||||||
title: "#f4f4f5",
|
title: "#24292f",
|
||||||
date: "#a1a1aa",
|
description: "#57606a",
|
||||||
siteName: "#71717a",
|
meta: "#57606a",
|
||||||
accent: "#3b82f6",
|
accent: "#3b82f6",
|
||||||
badge: "#2563eb",
|
badge: "#ddf4ff",
|
||||||
badgeText: "#ffffff",
|
badgeText: "#0969da",
|
||||||
|
border: "#d8dee4",
|
||||||
|
bar: "#3b82f6",
|
||||||
};
|
};
|
||||||
|
|
||||||
const POST_TYPE_MAP = {
|
const POST_TYPE_MAP = {
|
||||||
@@ -48,6 +56,18 @@ const POST_TYPE_MAP = {
|
|||||||
events: "Event",
|
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() {
|
function loadFonts() {
|
||||||
const fontsDir = resolve(
|
const fontsDir = resolve(
|
||||||
__dirname,
|
__dirname,
|
||||||
@@ -73,9 +93,9 @@ function loadFonts() {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeHash(title, date, postType, siteName) {
|
function computeHash(title, description, date, postType, siteName) {
|
||||||
return createHash("md5")
|
return createHash("md5")
|
||||||
.update(`${title}|${date}|${postType}|${siteName}`)
|
.update(`v${DESIGN_VERSION}|${title}|${description}|${date}|${postType}|${siteName}`)
|
||||||
.digest("hex")
|
.digest("hex")
|
||||||
.slice(0, 12);
|
.slice(0, 12);
|
||||||
}
|
}
|
||||||
@@ -97,7 +117,7 @@ function formatDate(dateStr) {
|
|||||||
if (Number.isNaN(d.getTime())) return "";
|
if (Number.isNaN(d.getTime())) return "";
|
||||||
return d.toLocaleDateString("en-US", {
|
return d.toLocaleDateString("en-US", {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "long",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
@@ -107,27 +127,31 @@ function formatDate(dateStr) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Use the full filename (with date prefix) as the OG image slug.
|
* 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) {
|
function toOgSlug(filename) {
|
||||||
return filename;
|
return filename;
|
||||||
}
|
}
|
||||||
|
|
||||||
function truncateTitle(title, max = 120) {
|
/**
|
||||||
if (!title || title.length <= max) return title || "Untitled";
|
* Sanitize text for Satori rendering — strip characters that cause NO GLYPH.
|
||||||
return title.slice(0, max).trim() + "\u2026";
|
*/
|
||||||
|
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
|
// Strip frontmatter
|
||||||
.replace(/^---[\s\S]*?---\s*/, "")
|
.replace(/^---[\s\S]*?---\s*/, "")
|
||||||
// Strip images 
|
// Strip images
|
||||||
.replace(/!\[[^\]]*\]\([^)]+\)/g, "")
|
.replace(/!\[[^\]]*\]\([^)]+\)/g, "")
|
||||||
// Strip markdown tables (lines with pipes)
|
// Strip markdown tables (lines with pipes)
|
||||||
.replace(/^\|.*\|$/gm, "")
|
.replace(/^\|.*\|$/gm, "")
|
||||||
// Strip table separator rows (|---|---|)
|
// Strip table separator rows
|
||||||
.replace(/^\s*[-|: ]+$/gm, "")
|
.replace(/^\s*[-|: ]+$/gm, "")
|
||||||
// Strip heading anchors {#id}
|
// Strip heading anchors {#id}
|
||||||
.replace(/\{#[^}]+\}/g, "")
|
.replace(/\{#[^}]+\}/g, "")
|
||||||
@@ -135,127 +159,187 @@ function extractBodyText(raw) {
|
|||||||
.replace(/<[^>]+>/g, "")
|
.replace(/<[^>]+>/g, "")
|
||||||
// Strip markdown links, keep text
|
// Strip markdown links, keep text
|
||||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
||||||
// Strip heading markers, bold, italic, strikethrough, code, blockquote
|
// Strip heading markers
|
||||||
.replace(/[#*_~`>]/g, "")
|
.replace(/^#{1,6}\s+/gm, "")
|
||||||
// Strip list bullets (-, *, +) and numbered lists (1.)
|
// Strip bold, italic, strikethrough, code, blockquote markers
|
||||||
|
.replace(/[*_~`>]/g, "")
|
||||||
|
// Strip list bullets and numbered lists
|
||||||
.replace(/^\s*[-*+]\s+/gm, "")
|
.replace(/^\s*[-*+]\s+/gm, "")
|
||||||
.replace(/^\s*\d+\.\s+/gm, "")
|
.replace(/^\s*\d+\.\s+/gm, "")
|
||||||
// Strip horizontal rules
|
// Strip horizontal rules
|
||||||
.replace(/^-{3,}$/gm, "")
|
.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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
return {
|
||||||
type: "div",
|
type: "div",
|
||||||
props: {
|
props: {
|
||||||
style: {
|
style: {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
width: `${WIDTH}px`,
|
width: `${WIDTH}px`,
|
||||||
height: `${HEIGHT}px`,
|
height: `${HEIGHT}px`,
|
||||||
backgroundColor: COLORS.bg,
|
backgroundColor: COLORS.bg,
|
||||||
},
|
},
|
||||||
children: [
|
children: [
|
||||||
{
|
// Main content area
|
||||||
type: "div",
|
|
||||||
props: {
|
|
||||||
style: {
|
|
||||||
width: "6px",
|
|
||||||
height: "100%",
|
|
||||||
backgroundColor: COLORS.accent,
|
|
||||||
flexShrink: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
type: "div",
|
type: "div",
|
||||||
props: {
|
props: {
|
||||||
style: {
|
style: {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
padding: "60px",
|
|
||||||
flex: 1,
|
flex: 1,
|
||||||
overflow: "hidden",
|
padding: "60px 60px 0 60px",
|
||||||
},
|
},
|
||||||
children: [
|
children: [
|
||||||
|
// Left: text content
|
||||||
{
|
{
|
||||||
type: "div",
|
type: "div",
|
||||||
props: {
|
props: {
|
||||||
style: {
|
style: {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
gap: "24px",
|
flex: 1,
|
||||||
|
gap: "20px",
|
||||||
|
overflow: "hidden",
|
||||||
|
paddingRight: avatar ? "40px" : "0",
|
||||||
},
|
},
|
||||||
children: [
|
children: [
|
||||||
{
|
// Title
|
||||||
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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
type: "div",
|
type: "div",
|
||||||
props: {
|
props: {
|
||||||
style: {
|
style: {
|
||||||
color: COLORS.title,
|
color: COLORS.title,
|
||||||
fontSize: "48px",
|
fontSize: "42px",
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
fontFamily: "Inter",
|
fontFamily: "Inter",
|
||||||
lineHeight: 1.2,
|
lineHeight: 1.25,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
},
|
},
|
||||||
children: truncateTitle(title),
|
children: truncate(title, 120),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
dateStr
|
// Description (if available)
|
||||||
|
description
|
||||||
? {
|
? {
|
||||||
type: "div",
|
type: "div",
|
||||||
props: {
|
props: {
|
||||||
style: {
|
style: {
|
||||||
color: COLORS.date,
|
color: COLORS.description,
|
||||||
fontSize: "24px",
|
fontSize: "24px",
|
||||||
fontWeight: 400,
|
fontWeight: 400,
|
||||||
fontFamily: "Inter",
|
fontFamily: "Inter",
|
||||||
|
lineHeight: 1.4,
|
||||||
|
overflow: "hidden",
|
||||||
},
|
},
|
||||||
children: formatDate(dateStr),
|
children: truncate(description, 160),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
].filter(Boolean),
|
].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",
|
type: "div",
|
||||||
props: {
|
props: {
|
||||||
style: {
|
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",
|
fontSize: "20px",
|
||||||
fontWeight: 400,
|
fontWeight: 400,
|
||||||
fontFamily: "Inter",
|
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 newManifest = { ...manifest };
|
||||||
const SAVE_INTERVAL = 10;
|
const SAVE_INTERVAL = 10;
|
||||||
// GC every 5 images to keep WASM native memory bounded.
|
// 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 GC_INTERVAL = 5;
|
||||||
const hasGC = typeof global.gc === "function";
|
const hasGC = typeof global.gc === "function";
|
||||||
let peakRss = 0;
|
let peakRss = 0;
|
||||||
@@ -340,10 +433,18 @@ export async function generateOgImages(contentDir, cacheDir, siteName, batchSize
|
|||||||
}
|
}
|
||||||
|
|
||||||
const slug = toOgSlug(basename(filePath, ".md"));
|
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 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`))) {
|
if (manifest[slug]?.hash === hash && existsSync(join(ogDir, `${slug}.png`))) {
|
||||||
newManifest[slug] = manifest[slug];
|
newManifest[slug] = manifest[slug];
|
||||||
@@ -351,7 +452,7 @@ export async function generateOgImages(contentDir, cacheDir, siteName, batchSize
|
|||||||
continue;
|
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 svg = await satori(card, { width: WIDTH, height: HEIGHT, fonts });
|
||||||
const resvg = new Resvg(svg, {
|
const resvg = new Resvg(svg, {
|
||||||
fitTo: { mode: "width", value: WIDTH },
|
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.
|
// 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) {
|
if (hasGC && generated % GC_INTERVAL === 0) {
|
||||||
global.gc();
|
global.gc();
|
||||||
const rss = process.memoryUsage().rss;
|
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
|
// Batch limit: stop after N images so the caller can re-spawn
|
||||||
// (fully releasing WASM native memory between batches)
|
|
||||||
if (batchSize > 0 && generated >= batchSize) {
|
if (batchSize > 0 && generated >= batchSize) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user