Files
blog-eleventy-indiekit/lib/og.js
Ricardo b3b65bf891 fix(og): strip markdown tables, lists, and non-renderable chars from body text
extractBodyText() was too naive - markdown tables (|...|), heading anchors
({#id}), list numbering, and HTML tags leaked into OG image titles for
notes without explicit titles. Characters outside Inter font coverage
caused Satori to render "NO GLYPH" vertically on the card.

Confab-Link: http://localhost:8080/sessions/5565387e-4eb5-4441-89fb-2c6347de8e0c
2026-03-10 19:51:50 +01:00

401 lines
12 KiB
JavaScript

/**
* 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.
*/
import satori from "satori";
import { Resvg } from "@resvg/resvg-js";
import {
readFileSync,
writeFileSync,
mkdirSync,
existsSync,
readdirSync,
} from "node:fs";
import { resolve, join, basename, dirname } from "node:path";
import { createHash } from "node:crypto";
import { fileURLToPath } from "node:url";
import matter from "gray-matter";
const __dirname = dirname(fileURLToPath(import.meta.url));
const WIDTH = 1200;
const HEIGHT = 630;
const COLORS = {
bg: "#09090b",
title: "#f4f4f5",
date: "#a1a1aa",
siteName: "#71717a",
accent: "#3b82f6",
badge: "#2563eb",
badgeText: "#ffffff",
};
const POST_TYPE_MAP = {
articles: "Article",
notes: "Note",
bookmarks: "Bookmark",
photos: "Photo",
likes: "Like",
replies: "Reply",
reposts: "Repost",
pages: "Page",
videos: "Video",
audio: "Audio",
jams: "Jam",
rsvps: "RSVP",
events: "Event",
};
function loadFonts() {
const fontsDir = resolve(
__dirname,
"..",
"node_modules",
"@fontsource",
"inter",
"files",
);
return [
{
name: "Inter",
data: readFileSync(join(fontsDir, "inter-latin-400-normal.woff")),
weight: 400,
style: "normal",
},
{
name: "Inter",
data: readFileSync(join(fontsDir, "inter-latin-700-normal.woff")),
weight: 700,
style: "normal",
},
];
}
function computeHash(title, date, postType, siteName) {
return createHash("md5")
.update(`${title}|${date}|${postType}|${siteName}`)
.digest("hex")
.slice(0, 12);
}
function detectPostType(filePath) {
const parts = filePath.split("/");
const contentIdx = parts.indexOf("content");
if (contentIdx >= 0 && contentIdx + 1 < parts.length) {
const typeDir = parts[contentIdx + 1];
if (POST_TYPE_MAP[typeDir]) return POST_TYPE_MAP[typeDir];
}
return "Post";
}
function formatDate(dateStr) {
if (!dateStr) return "";
try {
const d = new Date(dateStr);
if (Number.isNaN(d.getTime())) return "";
return d.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
} catch {
return "";
}
}
/**
* 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";
}
function extractBodyText(raw) {
const body = raw
// Strip frontmatter
.replace(/^---[\s\S]*?---\s*/, "")
// Strip images ![alt](url)
.replace(/!\[[^\]]*\]\([^)]+\)/g, "")
// Strip markdown tables (lines with pipes)
.replace(/^\|.*\|$/gm, "")
// Strip table separator rows (|---|---|)
.replace(/^\s*[-|: ]+$/gm, "")
// Strip heading anchors {#id}
.replace(/\{#[^}]+\}/g, "")
// Strip HTML tags
.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.)
.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;
}
function buildCard(title, dateStr, postType, siteName) {
return {
type: "div",
props: {
style: {
display: "flex",
width: `${WIDTH}px`,
height: `${HEIGHT}px`,
backgroundColor: COLORS.bg,
},
children: [
{
type: "div",
props: {
style: {
width: "6px",
height: "100%",
backgroundColor: COLORS.accent,
flexShrink: 0,
},
},
},
{
type: "div",
props: {
style: {
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
padding: "60px",
flex: 1,
overflow: "hidden",
},
children: [
{
type: "div",
props: {
style: {
display: "flex",
flexDirection: "column",
gap: "24px",
},
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,
},
},
],
},
},
{
type: "div",
props: {
style: {
color: COLORS.title,
fontSize: "48px",
fontWeight: 700,
fontFamily: "Inter",
lineHeight: 1.2,
overflow: "hidden",
},
children: truncateTitle(title),
},
},
dateStr
? {
type: "div",
props: {
style: {
color: COLORS.date,
fontSize: "24px",
fontWeight: 400,
fontFamily: "Inter",
},
children: formatDate(dateStr),
},
}
: null,
].filter(Boolean),
},
},
{
type: "div",
props: {
style: {
color: COLORS.siteName,
fontSize: "20px",
fontWeight: 400,
fontFamily: "Inter",
},
children: siteName,
},
},
],
},
},
],
},
};
}
function scanContentFiles(contentDir) {
const files = [];
function walk(dir) {
let entries;
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (entry.name === ".indiekit") continue;
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
walk(fullPath);
} else if (entry.isFile() && entry.name.endsWith(".md")) {
files.push(fullPath);
}
}
}
walk(contentDir);
return files;
}
/**
* Generate OG images for all content posts without photos.
* @param {string} contentDir - Path to content/ directory
* @param {string} cacheDir - Path to .cache/ directory
* @param {string} siteName - Site name for the card
* @param {number} batchSize - Max images to generate (0 = unlimited)
* @returns {{ hasMore: boolean }} Whether more images need generation
*/
export async function generateOgImages(contentDir, cacheDir, siteName, batchSize = 0) {
const ogDir = join(cacheDir, "og");
mkdirSync(ogDir, { recursive: true });
const manifestPath = join(ogDir, "manifest.json");
let manifest = {};
try {
manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
} catch {
// First run
}
const fonts = loadFonts();
const mdFiles = scanContentFiles(contentDir);
let generated = 0;
let skipped = 0;
// Seed with existing manifest so unscanned entries survive batch writes
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;
for (const filePath of mdFiles) {
const raw = readFileSync(filePath, "utf8");
const { data: fm } = matter(raw);
if (fm.photo || fm.image) {
skipped++;
continue;
}
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);
if (manifest[slug]?.hash === hash && existsSync(join(ogDir, `${slug}.png`))) {
newManifest[slug] = manifest[slug];
skipped++;
continue;
}
const card = buildCard(title, date, postType, siteName);
const svg = await satori(card, { width: WIDTH, height: HEIGHT, fonts });
const resvg = new Resvg(svg, {
fitTo: { mode: "width", value: WIDTH },
});
const pngBuffer = resvg.render().asPng();
writeFileSync(join(ogDir, `${slug}.png`), pngBuffer);
newManifest[slug] = { title: slug, hash };
generated++;
// Save manifest periodically to preserve progress on OOM kill
if (generated % SAVE_INTERVAL === 0) {
writeFileSync(manifestPath, JSON.stringify(newManifest, null, 2));
}
// 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;
if (rss > peakRss) peakRss = rss;
}
// 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;
}
}
const hasMore = batchSize > 0 && generated >= batchSize;
if (hasGC) global.gc();
writeFileSync(manifestPath, JSON.stringify(newManifest, null, 2));
const mem = process.memoryUsage();
if (mem.rss > peakRss) peakRss = mem.rss;
console.log(
`[og] Generated ${generated} images, skipped ${skipped} (cached or have photos)` +
(hasMore ? ` [batch, more remain]` : ``) +
` | RSS: ${(mem.rss / 1024 / 1024).toFixed(0)} MB, peak: ${(peakRss / 1024 / 1024).toFixed(0)} MB, heap: ${(mem.heapUsed / 1024 / 1024).toFixed(0)} MB`,
);
return { hasMore };
}