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.
This commit is contained in:
2026-09-01 08:59:42 +03:00
parent 9ee9795645
commit c035aca132
32 changed files with 410 additions and 11 deletions
+8 -6
View File
@@ -43,12 +43,14 @@ approved plan for full rationale on each decision referenced below.
Scholar nor arXiv offers a reliable image source for papers, so
`cover_image` stays manually-set for papers (or gets one later from
the OG-image pipeline below)
- [ ] OG image auto-generation extended from the legacy per-post script to
all three content types (writings, portfolio, bookshelf) — **blocked
on the design system**: `scripts/generate-og-images.mjs` is hard-coded
to the old "CASE FILE" hacker-terminal branding being fully replaced;
building this now would just be redone once the new visual identity
lands
- [x] OG image auto-generation extended to all three content types
(writings, portfolio, bookshelf) using the new design system —
`scripts/generate-og-images.astro-site.mjs`, wired into `apps/site`'s
build script; `Base.astro` renders `og:image`/`twitter:image` from a
per-page `ogImage` prop (writings/bookshelf detail pages wire it in;
portfolio has no detail page yet to wire it into). Uses static-weight
Fraunces/Newsreader font packages, not the variable fonts the site
ships — satori's opentype.js parser can't read the variable fvar table
- [x] `llms.txt` generation extended to cover all content types — unblocked
once `site.config.ts` existed (built above for feeds/meta anyway):
`scripts/generate-llms.astro-site.mjs` reads all four content
+2 -1
View File
@@ -8,7 +8,8 @@
"scripts": {
"dev": "astro dev",
"generate:llms": "node ../../scripts/generate-llms.astro-site.mjs",
"build": "pnpm run generate:llms && astro build && pagefind --site dist",
"generate:og": "node ../../scripts/generate-og-images.astro-site.mjs",
"build": "pnpm run generate:llms && pnpm run generate:og && astro build && pagefind --site dist",
"preview": "astro preview",
"check": "astro check",
"astro": "astro"
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

+15 -2
View File
@@ -11,22 +11,35 @@ interface Props {
description?: string;
/** Widens .container for dashboard-style layouts (currently just the homepage). */
wide?: boolean;
/** Site-root-relative path to a per-page OG image, e.g. /images/og/writings/foo.png */
ogImage?: string;
}
const { title, description, wide = false } = Astro.props;
const { title, description, wide = false, ogImage } = Astro.props;
const metaDescription = description ?? siteConfig.description;
const currentPath = Astro.url.pathname;
const copyrightRange = await copyrightYearRange();
const pageTitle = `${title} — ${siteConfig.title}`;
const ogImageUrl = new URL(ogImage ?? "/favicon.svg", siteConfig.baseUrl).href;
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title} — {siteConfig.title}</title>
<title>{pageTitle}</title>
<meta name="description" content={metaDescription} />
<link rel="alternate" type="application/rss+xml" title={siteConfig.title} href="/rss.xml" />
<link rel="alternate" type="application/feed+json" title={siteConfig.title} href="/feed.json" />
<link rel="webmention" href="https://webmention.io/andwati.com/webmention" />
<meta property="og:type" content="website" />
<meta property="og:title" content={pageTitle} />
<meta property="og:description" content={metaDescription} />
<meta property="og:url" content={Astro.url.href} />
<meta property="og:image" content={ogImageUrl} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={pageTitle} />
<meta name="twitter:description" content={metaDescription} />
<meta name="twitter:image" content={ogImageUrl} />
<script is:inline>
// Runs before paint to avoid a flash of the wrong theme.
(function () {
@@ -21,7 +21,7 @@ const items = (await getCollection("bookshelf")).sort(
);
---
<Base title={entry.data.title} wide>
<Base title={entry.data.title} wide ogImage={`/images/og/bookshelf/${entry.id}.png`}>
<BookSpines
items={items.map((e) => ({ slug: e.id, title: e.data.title, coverImage: e.data.cover_image }))}
activeSlug={entry.id}
+1 -1
View File
@@ -51,7 +51,7 @@ const description =
const tocHeadings = headings.filter((h) => h.depth <= 3);
---
<Base title={entry.data.title} description={description}>
<Base title={entry.data.title} description={description} ogImage={`/images/og/writings/${entry.id}.png`}>
<h1>{entry.data.title}</h1>
<p class="meta">
{entry.data.date.toDateString()}
+2
View File
@@ -16,6 +16,8 @@
},
"devDependencies": {
"@biomejs/biome": "^2.5.11",
"@fontsource/fraunces": "^5.3.0",
"@fontsource/newsreader": "^5.3.0",
"@resvg/resvg-js": "^2.6.2",
"concurrently": "^10.0.5",
"satori": "^0.29.0",
+16
View File
@@ -11,6 +11,12 @@ importers:
'@biomejs/biome':
specifier: ^2.5.11
version: 2.5.11
'@fontsource/fraunces':
specifier: ^5.3.0
version: 5.3.0
'@fontsource/newsreader':
specifier: ^5.3.0
version: 5.3.0
'@resvg/resvg-js':
specifier: ^2.6.2
version: 2.6.2
@@ -1026,9 +1032,15 @@ packages:
'@fontsource-variable/[email protected]':
resolution: {integrity: sha512-rrzYi43qMpbzwuFtf9OkWH8sxAPVPcQQQEwXpPtwaKYeJ8yVg5aLs5kawmo1f2Q1t1M38TLmEKCkGVDsYwgdFw==}
'@fontsource/[email protected]':
resolution: {integrity: sha512-LEHTOBiZufQzAvnAe2rZ8ZknbDS28Q9kzwb0L+gZJEmjLGr8hhZ/5NuHXwDuHBWhVXYoiGGXmrb0H6Ko/RrHTg==}
'@fontsource/[email protected]':
resolution: {integrity: sha512-fqDfB5I9f1p1TV486aUgB9t8zP84P0O1FtQR5Ol9vjwPy+S+EIGlVYm1cvj2W5shcZMTg2nZFdVMoH5wFu8a1A==}
'@fontsource/[email protected]':
resolution: {integrity: sha512-AFR6ZKY89GNL+9qBI2P2+66NhD5bhOj/v6fsLnFYLM4E0JnMB1yKb+Ro8cA64i1hwAWoBfTq5CHJGWH1toLDkw==}
'@formatjs/[email protected]':
resolution: {integrity: sha512-+QoPW4csYALsQIl8GbN14igZzDbuwzcpWrku9nyMXlaqAlwRBgl5V+p0vWMGFqHOw37czNXaP/lEk4wbLgcmtA==}
@@ -8813,8 +8825,12 @@ snapshots:
'@fontsource-variable/[email protected]': {}
'@fontsource/[email protected]': {}
'@fontsource/[email protected]': {}
'@fontsource/[email protected]': {}
'@formatjs/[email protected]':
dependencies:
'@formatjs/intl-localematcher': 0.5.4
+365
View File
@@ -0,0 +1,365 @@
// 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)}/`,
);