Files
indiekit-blog/lib/og.js
svemagie a166af2306 chore: sync upstream — performance, webmentions v2, OG v3
- _data: switch to cachedFetch wrapper (10s timeout + 4h watch cache)
- js/webmentions.js: owner reply threading, platform provenance badges, DOM dedup, Micropub reply support
- js/comments.js: owner detection, reply system, Alpine.store integration
- _includes/components/webmentions.njk: data-wm-* attrs, provenance badge slots, reply buttons
- _includes/components/comments.njk: owner-aware comment form, threaded replies
- widgets/toc.njk: Alpine.js tocScanner upgrade (replaces is-land/inline-JS)
- lib/og.js + og-cli.js: OG card v3 (light theme, avatar, batched spawn, DESIGN_VERSION=3)
- eleventy.config.js: hasOgImage cache, memoized date filters, batched OG/unfurl, post-build GC, YouTube check opt
- base.njk: Inter font preloads + toc-scanner.js script
- critical.css: font-face declarations (font-display:optional)
- tailwind.css: font-display swap→optional
- tailwind.config.js: prose link colors -700→-600
- Color design system: accent-700/300 → accent-600/400 across components

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 23:56:56 +01:00

518 lines
15 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.
*
* 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 { 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;
// Card design version — bump to force full regeneration
const DESIGN_VERSION = 3;
const COLORS = {
bg: "#ffffff",
title: "#24292f",
description: "#57606a",
meta: "#57606a",
accent: "#3b82f6",
badge: "#ddf4ff",
badgeText: "#0969da",
border: "#d8dee4",
bar: "#3b82f6",
};
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",
};
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,
"..",
"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, description, date, postType, siteName) {
return createHash("md5")
.update(`v${DESIGN_VERSION}|${title}|${description}|${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: "short",
day: "numeric",
});
} catch {
return "";
}
}
/**
* Use the full filename (with date prefix) as the OG image slug.
*/
function toOgSlug(filename) {
return filename;
}
/**
* 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();
}
/**
* Strip markdown formatting from raw content, returning plain text lines.
*/
function stripMarkdown(raw) {
return raw
// Strip frontmatter
.replace(/^---[\s\S]*?---\s*/, "")
// Strip images
.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
.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, "");
}
/**
* 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);
return {
type: "div",
props: {
style: {
display: "flex",
flexDirection: "column",
width: `${WIDTH}px`,
height: `${HEIGHT}px`,
backgroundColor: COLORS.bg,
},
children: [
// Top accent bar
{
type: "div",
props: {
style: {
width: "100%",
height: "6px",
backgroundColor: COLORS.bar,
flexShrink: 0,
},
},
},
// Main content — vertically centered
{
type: "div",
props: {
style: {
display: "flex",
flex: 1,
padding: "0 64px",
alignItems: "center",
},
children: [
// Left: text content
{
type: "div",
props: {
style: {
display: "flex",
flexDirection: "column",
flex: 1,
gap: "16px",
overflow: "hidden",
paddingRight: avatar ? "48px" : "0",
},
children: [
// Post type badge + date inline
{
type: "div",
props: {
style: {
display: "flex",
alignItems: "center",
gap: "12px",
color: COLORS.meta,
fontSize: "18px",
fontWeight: 400,
fontFamily: "Inter",
},
children: [
{
type: "span",
props: {
style: {
backgroundColor: COLORS.badge,
color: COLORS.badgeText,
fontSize: "14px",
fontWeight: 700,
fontFamily: "Inter",
padding: "4px 12px",
borderRadius: "999px",
textTransform: "uppercase",
letterSpacing: "0.05em",
},
children: postType,
},
},
formattedDate
? { type: "span", props: { children: formattedDate } }
: null,
].filter(Boolean),
},
},
// Title
{
type: "div",
props: {
style: {
color: COLORS.title,
fontSize: "48px",
fontWeight: 700,
fontFamily: "Inter",
lineHeight: 1.2,
overflow: "hidden",
},
children: truncate(title, 120),
},
},
// Description (if available)
description
? {
type: "div",
props: {
style: {
color: COLORS.description,
fontSize: "22px",
fontWeight: 400,
fontFamily: "Inter",
lineHeight: 1.4,
overflow: "hidden",
},
children: truncate(description, 160),
},
}
: null,
].filter(Boolean),
},
},
// Right: avatar
avatar
? {
type: "div",
props: {
style: {
display: "flex",
flexShrink: 0,
},
children: [
{
type: "img",
props: {
src: avatar,
width: 128,
height: 128,
style: {
borderRadius: "16px",
border: `2px solid ${COLORS.border}`,
},
},
},
],
},
}
: null,
].filter(Boolean),
},
},
// Footer: site name
{
type: "div",
props: {
style: {
display: "flex",
justifyContent: "flex-end",
alignItems: "center",
padding: "0 64px 32px 64px",
},
children: [
{
type: "div",
props: {
style: {
color: "#8b949e",
fontSize: "18px",
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.
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 postType = detectPostType(filePath);
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];
skipped++;
continue;
}
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 },
});
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.
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
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 };
}