518 lines
15 KiB
JavaScript
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", "svemagie.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 };
|
|
}
|