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.
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 40 KiB |
@@ -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}
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}/`,
|
||||
);
|
||||