mirror of
https://github.com/svemagie/blog-eleventy-indiekit.git
synced 2026-04-02 16:44:56 +02:00
The OG generation in the eleventy.before hook consumed too much memory alongside Eleventy's data cascade, causing the Eleventy process to be OOM-killed on Cloudron. Fix by running OG generation in a separate child process with its own 768MB heap limit. Also write the manifest incrementally (every 10 images) to preserve progress if interrupted.
336 lines
9.0 KiB
JavaScript
336 lines
9.0 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 "";
|
|
}
|
|
}
|
|
|
|
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
|
|
.replace(/^---[\s\S]*?---\s*/, "")
|
|
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
|
.replace(/[#*_~`>]/g, "")
|
|
.replace(/!\[[^\]]*\]\([^)]+\)/g, "")
|
|
.replace(/\n+/g, " ")
|
|
.trim();
|
|
if (!body) return "Untitled";
|
|
return body.length > 120 ? body.slice(0, 120).trim() + "\u2026" : body;
|
|
}
|
|
|
|
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
|
|
*/
|
|
export async function generateOgImages(contentDir, cacheDir, siteName) {
|
|
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;
|
|
const newManifest = {};
|
|
const SAVE_INTERVAL = 10;
|
|
|
|
for (const filePath of mdFiles) {
|
|
const raw = readFileSync(filePath, "utf8");
|
|
const { data: fm } = matter(raw);
|
|
|
|
if (fm.photo || fm.image) {
|
|
skipped++;
|
|
continue;
|
|
}
|
|
|
|
const slug = 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
|
|
if (generated % SAVE_INTERVAL === 0) {
|
|
writeFileSync(manifestPath, JSON.stringify(newManifest, null, 2));
|
|
}
|
|
}
|
|
|
|
writeFileSync(manifestPath, JSON.stringify(newManifest, null, 2));
|
|
console.log(
|
|
`[og] Generated ${generated} images, skipped ${skipped} (cached or have photos)`,
|
|
);
|
|
}
|