Files
andwati.com/scripts/generate-og-images.astro-site.mjs
T
andwati c035aca132 Add OG image auto-generation for writings, portfolio, and bookshelf
Replaces the legacy Zola script, which was hard-coded to the retired
"CASE FILE" branding, with scripts/generate-og-images.astro-site.mjs
built on the new design system's dark palette + Fraunces/JetBrains Mono.
Base.astro now renders og:image/twitter:image from a per-page ogImage
prop, wired into the writings and bookshelf detail pages.

Uses static-weight Fraunces/Newsreader font packages rather than the
variable fonts the site ships elsewhere, since satori's opentype.js
parser throws on their fvar table.
2026-09-01 08:59:42 +03:00

366 lines
9.5 KiB
JavaScript

// Generates OG images for the new Astro site's writings, portfolio, and
// bookshelf entries, replacing the legacy Zola script (which was hard-coded
// to the retired "CASE FILE" branding). Output goes to
// apps/site/public/images/og/<type>/<slug>.png, wired into apps/site's
// build script (mirrors generate-llms.astro-site.mjs).
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { Resvg } from "@resvg/resvg-js";
import satori from "satori";
import { parse as parseToml } from "smol-toml";
import { decompress } from "wawoff2";
// Design tokens, duplicated from apps/site/src/styles/tokens.css's dark
// palette — OG images always render dark regardless of the viewer's theme.
const COLORS = {
bg: "#0d0d0b",
text: "#ece7db",
muted: "#8e897b",
accent: "#ff6b45",
border: "rgba(236, 231, 219, 0.12)",
};
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
const contentDir = path.join(root, "content");
const outDir = path.join(root, "apps", "site", "public", "images", "og");
const pnpmDir = path.join(root, "node_modules", ".pnpm");
const FRONTMATTER_RE = /^\+\+\+\r?\n([\s\S]*?)\r?\n\+\+\+\r?\n?([\s\S]*)$/;
function walk(dir) {
return fs
.readdir(dir, { withFileTypes: true })
.then((entries) =>
Promise.all(
entries.map((entry) => {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) return walk(full);
return entry.isFile() && entry.name.endsWith(".md") ? [full] : [];
}),
),
)
.then((lists) => lists.flat());
}
function slugFor(typeDir, file) {
const rel = path.relative(typeDir, file).split(path.sep).join("/");
return rel.endsWith("/index.md")
? rel.slice(0, -"/index.md".length)
: rel.slice(0, -".md".length);
}
async function readEntries(typeDir) {
if (
!(await fs.access(typeDir).then(
() => true,
() => false,
))
) {
return [];
}
const files = await walk(typeDir);
const entries = await Promise.all(
files.map(async (file) => {
const raw = await fs.readFile(file, "utf8");
const match = raw.match(FRONTMATTER_RE);
if (!match) return null;
return { slug: slugFor(typeDir, file), frontmatter: parseToml(match[1]) };
}),
);
return entries.filter(Boolean);
}
async function findPnpmPackageDir(prefix) {
const entries = await fs.readdir(pnpmDir);
const match = entries.find((e) => e.startsWith(prefix));
if (!match) throw new Error(`Could not find pnpm package matching ${prefix}`);
return path.join(pnpmDir, match, "node_modules");
}
async function loadFont(absPath) {
const woff2 = await fs.readFile(absPath);
const ttf = await decompress(woff2);
return Buffer.from(ttf);
}
function h(type, props, ...children) {
return {
type,
props: {
...props,
children: children.flat().filter((c) => c !== null && c !== undefined),
},
};
}
function formatDate(date) {
if (!date) return undefined;
// smol-toml parses TOML dates into Date objects directly.
return date.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
timeZone: "UTC",
});
}
function card({ kicker, title, meta, tags }) {
const stampTag = tags[0];
const footerTags =
tags.length > 4 ? `${tags.slice(0, 4).join(" · ")} ⋯` : tags.join(" · ");
const titleSize = title.length > 70 ? 46 : title.length > 45 ? 54 : 64;
return h(
"div",
{
style: {
width: "1200px",
height: "630px",
display: "flex",
backgroundColor: COLORS.bg,
color: COLORS.text,
fontFamily: "Newsreader",
},
},
h(
"div",
{
style: {
flex: 1,
margin: "40px",
border: `1px solid ${COLORS.border}`,
borderRadius: "6px",
padding: "48px 56px",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
},
},
h(
"div",
{
style: {
display: "flex",
flexDirection: "row",
justifyContent: "space-between",
alignItems: "flex-start",
},
},
h(
"div",
{
style: {
display: "flex",
fontFamily: "JetBrains Mono",
fontWeight: 400,
fontSize: "22px",
color: COLORS.muted,
letterSpacing: "2px",
},
},
`ANDWATI.COM · ${kicker}`,
),
stampTag
? h(
"div",
{
style: {
display: "flex",
border: `2px solid ${COLORS.accent}`,
borderRadius: "4px",
padding: "8px 16px",
color: COLORS.accent,
fontFamily: "JetBrains Mono",
fontWeight: 700,
fontSize: "20px",
letterSpacing: "2px",
textTransform: "uppercase",
transform: "rotate(3deg)",
},
},
stampTag,
)
: null,
),
h(
"div",
{
style: {
display: "flex",
fontFamily: "Fraunces",
fontWeight: 600,
fontSize: `${titleSize}px`,
lineHeight: 1.15,
letterSpacing: "-1px",
color: COLORS.text,
overflow: "hidden",
},
},
title,
),
h(
"div",
{
style: {
display: "flex",
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
borderTop: `1px solid ${COLORS.border}`,
paddingTop: "24px",
},
},
h(
"div",
{
style: {
display: "flex",
fontFamily: "JetBrains Mono",
fontWeight: 400,
fontSize: "20px",
color: COLORS.muted,
letterSpacing: "1px",
},
},
meta ?? "",
),
h(
"div",
{
style: {
display: "flex",
fontFamily: "JetBrains Mono",
fontWeight: 400,
fontSize: "20px",
color: COLORS.muted,
letterSpacing: "1px",
},
},
footerTags,
),
),
),
);
}
function writingsCard(entry) {
return card({
kicker: "WRITINGS",
title: entry.frontmatter.title,
meta: formatDate(entry.frontmatter.date),
tags: entry.frontmatter.taxonomies?.tags ?? [],
});
}
function portfolioCard(entry) {
return card({
kicker: "PORTFOLIO",
title: entry.frontmatter.title,
meta: entry.frontmatter.role ?? entry.frontmatter.outcome,
tags: entry.frontmatter.taxonomies?.tags ?? [],
});
}
function bookshelfCard(entry) {
const authors = entry.frontmatter.authors ?? [];
return card({
kicker: "BOOKSHELF",
title: entry.frontmatter.title,
meta: authors.length ? authors.join(", ") : undefined,
tags: entry.frontmatter.taxonomies?.tags ?? [],
});
}
// Static-weight builds, not the variable fonts the site ships — satori's
// opentype.js parser can't read these packages' fvar table (throws on the
// axis-name lookup), so a fixed weight per family is loaded instead.
const jetbrainsMonoDir = await findPnpmPackageDir("@fontsource+jetbrains-mono");
const fraunceDir = await findPnpmPackageDir("@fontsource+fraunces");
const newsreaderDir = await findPnpmPackageDir("@fontsource+newsreader");
const fonts = [
{
name: "Fraunces",
data: await loadFont(
path.join(
fraunceDir,
"@fontsource/fraunces/files/fraunces-latin-600-normal.woff2",
),
),
weight: 600,
style: "normal",
},
{
name: "Newsreader",
data: await loadFont(
path.join(
newsreaderDir,
"@fontsource/newsreader/files/newsreader-latin-400-normal.woff2",
),
),
weight: 400,
style: "normal",
},
{
name: "JetBrains Mono",
data: await loadFont(
path.join(
jetbrainsMonoDir,
"@fontsource/jetbrains-mono/files/jetbrains-mono-latin-400-normal.woff2",
),
),
weight: 400,
style: "normal",
},
{
name: "JetBrains Mono",
data: await loadFont(
path.join(
jetbrainsMonoDir,
"@fontsource/jetbrains-mono/files/jetbrains-mono-latin-700-normal.woff2",
),
),
weight: 700,
style: "normal",
},
];
const TYPES = [
{
name: "writings",
buildCard: writingsCard,
isDraft: (e) => e.frontmatter.draft,
},
{
name: "portfolio",
buildCard: portfolioCard,
isDraft: (e) => e.frontmatter.draft,
},
{ name: "bookshelf", buildCard: bookshelfCard, isDraft: () => false },
];
let count = 0;
for (const type of TYPES) {
const typeDir = path.join(contentDir, type.name);
const entries = await readEntries(typeDir);
const typeOutDir = path.join(outDir, type.name);
await fs.mkdir(typeOutDir, { recursive: true });
for (const entry of entries) {
if (!entry.frontmatter.title || type.isDraft(entry)) continue;
const svg = await satori(type.buildCard(entry), {
width: 1200,
height: 630,
fonts,
});
const png = new Resvg(svg).render().asPng();
await fs.writeFile(path.join(typeOutDir, `${entry.slug}.png`), png);
count += 1;
}
}
console.log(
`Generated ${count} OG image${count === 1 ? "" : "s"} in ${path.relative(root, outDir)}/`,
);