mirror of
https://github.com/svemagie/blog-eleventy-indiekit.git
synced 2026-04-02 16:44:56 +02:00
feat: auto-generate OpenGraph images for posts without photos
Uses Satori + @resvg/resvg-js to create branded 1200x630 social preview cards at build time. Cards show post title, type badge, date, and site name on a dark background with blue accent. Generated images are cached in .cache/og/ (persistent on Cloudron) and passthrough-copied to the output. Posts with photos continue using their own images. Untitled posts (notes) use body text.
This commit is contained in:
329
lib/og.js
Normal file
329
lib/og.js
Normal file
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* 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 = {};
|
||||
|
||||
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++;
|
||||
}
|
||||
|
||||
writeFileSync(manifestPath, JSON.stringify(newManifest, null, 2));
|
||||
console.log(
|
||||
`[og] Generated ${generated} images, skipped ${skipped} (cached or have photos)`,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user