feat(og-image): add social card images for projects, experience, and talks

This commit is contained in:
Prad Nukala
2026-06-30 13:05:14 -04:00
parent f701c951e4
commit 2178cef232
11 changed files with 173 additions and 55 deletions
+1
View File
@@ -25,6 +25,7 @@ const range = `${startDate.getFullYear()} ${endDate ? endDate.getFullYear()
const meta = {
description: exp.data.description ?? `${role ?? ""} at ${organization}`.trim(),
ogImage: `/og-image/experience/${exp.id}.png`,
title: exp.data.title,
};
---
+2 -4
View File
@@ -6,7 +6,7 @@ import { getAllPosts } from "@/data/post";
import { getAllSpeaking } from "@/data/speaking";
import PageLayout from "@/layouts/Base.astro";
import { siteConfig, socialLinks } from "@/site.config";
import { collectionDateSort } from "@/utils/date";
import { collectionDateSort, projectDateSort } from "@/utils/date";
const MAX_POSTS = 4;
const allPosts = await getAllPosts();
@@ -17,9 +17,7 @@ const latestPosts = (allPosts.sort(collectionDateSort) as CollectionEntry<"writi
const MAX_PROJECTS = 2;
const allProjects = await getCollection("projects");
const latestProjects = allProjects
.sort(collectionDateSort)
.slice(0, MAX_PROJECTS) as CollectionEntry<"projects">[];
const latestProjects = allProjects.sort(projectDateSort).slice(0, MAX_PROJECTS);
const MAX_TALKS = 3;
const latestTalks = (await getAllSpeaking()).sort(collectionDateSort).slice(0, MAX_TALKS);
+64 -23
View File
@@ -1,12 +1,14 @@
import type { APIContext, InferGetStaticPropsType } from "astro";
import { getCollection } from "astro:content";
import type { APIContext } from "astro";
import satori, { type SatoriOptions } from "satori";
import sharp from "sharp";
import RobotoMonoBold from "@/assets/roboto-mono-700.ttf";
import RobotoMono from "@/assets/roboto-mono-regular.ttf";
import { getAllPosts } from "@/data/post";
import { getAllSpeaking } from "@/data/speaking";
import { getFormattedDate } from "@/utils/date";
import { readCache, writeToCache } from "./_cacheUtil";
import { ogMarkup } from "./_ogMarkup";
import { type OgContent, ogMarkup } from "./_ogMarkup";
const ogOptions: SatoriOptions = {
// debug: true,
@@ -28,22 +30,17 @@ const ogOptions: SatoriOptions = {
width: 1200,
};
type Props = InferGetStaticPropsType<typeof getStaticPaths>;
type Props = OgContent & { cacheDate: Date };
export async function GET(context: APIContext) {
const { pubDate, title } = context.props as Props;
const { cacheDate, ...content } = context.props as Props;
// check the og-image cache
let pngBuffer = readCache(title, pubDate);
let pngBuffer = readCache(content.title, cacheDate);
if (!pngBuffer) {
console.info(`Generating new OG image for: ${title}`);
const postDate = getFormattedDate(pubDate, {
month: "long",
weekday: "long",
});
const svg = await satori(ogMarkup(title, postDate), ogOptions);
console.info(`Generating new OG image for: ${content.title}`);
const svg = await satori(ogMarkup(content), ogOptions);
pngBuffer = await sharp(Buffer.from(svg)).png().toBuffer();
writeToCache(title, pubDate, pngBuffer);
writeToCache(content.title, cacheDate, pngBuffer);
}
return new Response(new Uint8Array(pngBuffer), {
@@ -55,16 +52,60 @@ export async function GET(context: APIContext) {
}
export async function getStaticPaths() {
const posts = await getAllPosts();
return posts
.values()
.filter(({ data }) => !data.ogImage)
.map((post) => ({
params: { slug: post.id },
const date = (d: Date) => getFormattedDate(d, { month: "long" });
const [projects, experiences, speaking] = await Promise.all([
getCollection("projects"),
getCollection("experience"),
getAllSpeaking(),
]);
// Writing keeps its existing behaviour: skip posts with a custom ogImage.
const writing = (await getAllPosts())
.filter((p) => !p.data.ogImage)
.map((p) => ({
params: { slug: `writing/${p.id}` },
props: {
pubDate: post.data.updatedDate ?? post.data.publishDate,
title: post.data.title,
category: "Writing",
title: p.data.title,
excerpt: p.data.description,
meta: date(p.data.updatedDate ?? p.data.publishDate),
cacheDate: p.data.updatedDate ?? p.data.publishDate,
},
}))
.toArray();
}));
const projectPaths = projects.map((p) => ({
params: { slug: `projects/${p.id}` },
props: {
category: "Project",
title: p.data.title,
excerpt: p.data.description,
meta: `${p.data.startDate.getFullYear()} ${p.data.endDate ? p.data.endDate.getFullYear() : "Present"}`,
cacheDate: p.data.endDate ?? p.data.startDate,
},
}));
const speakingPaths = speaking.map((t) => ({
params: { slug: `speaking/${t.id}` },
props: {
category: "Talk",
title: t.data.title,
excerpt: t.data.description,
meta: t.data.event ?? date(t.data.publishDate),
cacheDate: t.data.publishDate,
},
}));
const experiencePaths = experiences.map((e) => ({
params: { slug: `experience/${e.id}` },
props: {
category: "Experience",
title: e.data.role ? `${e.data.role}, ${e.data.organization}` : e.data.organization,
excerpt: e.data.description,
meta: e.data.location ?? e.data.organization,
cacheDate: e.data.startDate,
},
}));
return [...writing, ...projectPaths, ...speakingPaths, ...experiencePaths];
}
+1 -1
View File
@@ -5,7 +5,7 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
// Update/bump version when changing markup in _ogMarkup.ts, or fonts/config in [...slug].png.ts
const CACHE_VERSION = "v1";
const CACHE_VERSION = "v5";
// Cache directory, defaults to node_modules/.astro/og-images
const CACHE_DIR = path.join(fileURLToPath(cacheDir), "og-images");
+87 -13
View File
@@ -1,15 +1,89 @@
import { html } from "satori-html";
import { siteConfig } from "@/site.config";
// OG image markup, use https://og-playground.vercel.app/ to design your own.
export const ogMarkup = (title: string, pubDate: string) =>
html`<div tw="flex flex-col w-full h-full bg-[#1d1f21] text-[#c9cacc]">
<div tw="flex flex-col flex-1 w-full p-10 justify-center">
<p tw="text-2xl mb-6">${pubDate}</p>
<h1 tw="text-6xl font-bold leading-snug text-white">${title}</h1>
</div>
<div tw="flex items-center justify-between w-full p-10 border-t-2 border-[#2bbc89] text-white">
<p tw="text-2xl ml-3 font-semibold">${siteConfig.title}</p>
<p>by ${siteConfig.author}</p>
</div>
</div>`;
// OG image markup. Structure adapted from ogimagecn's "Blog" component
// (https://www.ogimagecn.com/docs/components/blog) — a type badge, title,
// excerpt, and author/meta line — restyled to match the site's minimal
// social-card.png: dark background, green accent frame, mono type.
//
// Built as a plain Satori node tree (not satori-html) so dynamic text is
// rendered literally — satori-html escapes `&`/`<`/`>` in interpolations and
// would otherwise render e.g. "Founder & Lead Engineer" as "Founder &amp;…".
const BG = "#1d1f21";
const WHITE = "#ffffff";
const MUTED = "#8a8f98";
const ACCENT = "#2bbc89";
type Style = Record<string, string | number>;
type Node = { type: string; props: { style: Style; children?: unknown } };
const el = (type: string, style: Style, children?: unknown): Node => ({
type,
props: { style: { display: "flex", ...style }, ...(children !== undefined ? { children } : {}) },
});
export interface OgContent {
/** Content type shown as the eyebrow label, e.g. "Writing", "Project", "Talk". */
category: string;
title: string;
/** Short description shown under the title. */
excerpt?: string;
/** Secondary line next to the author, e.g. a formatted date or event. */
meta: string;
}
export const ogMarkup = ({ category, title, excerpt, meta }: OgContent): Node =>
el(
"div",
{
flexDirection: "column",
width: "100%",
height: "100%",
justifyContent: "space-between",
backgroundColor: BG,
color: MUTED,
fontFamily: "Roboto Mono",
border: `14px solid ${ACCENT}`,
padding: "72px",
},
[
el("div", { alignItems: "center", justifyContent: "space-between", width: "100%" }, [
el(
"div",
{
color: ACCENT,
fontSize: "26px",
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.18em",
},
category,
),
el("div", { color: WHITE, fontSize: "28px", fontWeight: 700 }, siteConfig.title),
]),
el("div", { flexDirection: "column" }, [
el(
"div",
{
color: WHITE,
fontWeight: 700,
fontSize: title.length > 48 ? "62px" : "78px",
lineHeight: 1.1,
letterSpacing: "-0.02em",
maxWidth: "1040px",
},
title,
),
...(excerpt
? [
el(
"div",
{ color: MUTED, fontSize: "32px", lineHeight: 1.4, maxWidth: "980px", marginTop: "32px" },
excerpt,
),
]
: []),
]),
el("div", { color: MUTED, fontSize: "26px" }, `by ${siteConfig.author} · ${meta}`),
],
);
+2 -2
View File
@@ -5,12 +5,12 @@ import { Icon } from "astro-icon/components";
import Pagination from "@/components/Paginator.astro";
import Project from "@/components/project/Project.astro";
import PageLayout from "@/layouts/Base.astro";
import { collectionDateSort } from "@/utils/date";
import { projectDateSort } from "@/utils/date";
export const getStaticPaths = (async ({ paginate }) => {
const MAX_PROJECTS_PER_PAGE = 10;
const allProjects = await getCollection("projects");
return paginate(allProjects.sort(collectionDateSort), { pageSize: MAX_PROJECTS_PER_PAGE });
return paginate(allProjects.sort(projectDateSort), { pageSize: MAX_PROJECTS_PER_PAGE });
}) satisfies GetStaticPaths;
interface Props {
+1
View File
@@ -19,6 +19,7 @@ const { project } = Astro.props;
const meta = {
description: project.data.description || `Project: ${project.data.title}`,
ogImage: `/og-image/projects/${project.id}.png`,
title: project.data.title,
};
---
+1 -1
View File
@@ -11,7 +11,7 @@ export const GET = async () => {
site: import.meta.env.SITE,
items: projects.map((project) => ({
title: project.data.title,
pubDate: project.data.publishDate,
pubDate: project.data.endDate ?? project.data.startDate,
link: `projects/${project.id}/`,
})),
});
+1 -1
View File
@@ -20,7 +20,7 @@ const { talk } = Astro.props as Props;
const { Content } = await render(talk);
const { title, description, videoId, originalSource, event, publishDate, experiences } = talk.data;
const meta = { description, title };
const meta = { description, ogImage: `/og-image/speaking/${talk.id}.png`, title };
---
<PageLayout meta={meta}>
+3 -8
View File
@@ -26,7 +26,6 @@ export const siteConfig: SiteConfig = {
export const streamOrigin = "https://customer-CODE.cloudflarestream.com";
// Social links shown in the homepage intro and the footer's icon row.
// ! Update the handles below to your own.
export const socialLinks: {
friendlyName: string;
link: string;
@@ -34,13 +33,9 @@ export const socialLinks: {
isWebmention?: boolean;
}[] = [
{ friendlyName: "GitHub", link: "https://github.com/prdlk", name: "mdi:github" },
{ friendlyName: "X", link: "https://x.com/prad_nukala", name: "mdi:twitter" },
{
friendlyName: "LinkedIn",
link: "https://www.linkedin.com/in/prad-nukala/",
name: "mdi:linkedin",
},
{ friendlyName: "Email", link: "mailto:prad@sonr.io", name: "mdi:email-outline" },
{ friendlyName: "X", link: "https://x.com/basedprad", name: "mdi:twitter" },
{ friendlyName: "LinkedIn", link: "https://linkedin.com/in/pradn", name: "mdi:linkedin" },
{ friendlyName: "Email", link: "mailto:prnk28@gmail.com", name: "mdi:email-outline" },
];
// Used to generate links in both the Header & Footer.
+10 -2
View File
@@ -16,8 +16,16 @@ export function getFormattedDate(
}
export function collectionDateSort(
a: CollectionEntry<"writing" | "projects">,
b: CollectionEntry<"writing" | "projects">,
a: CollectionEntry<"writing" | "speaking">,
b: CollectionEntry<"writing" | "speaking">,
) {
return b.data.publishDate.getTime() - a.data.publishDate.getTime();
}
/** Sort projects by end date, most recent first; ongoing (no end date) first. */
export function projectDateSort(a: CollectionEntry<"projects">, b: CollectionEntry<"projects">) {
const ae = a.data.endDate?.getTime() ?? Number.POSITIVE_INFINITY;
const be = b.data.endDate?.getTime() ?? Number.POSITIVE_INFINITY;
if (be !== ae) return be - ae;
return b.data.startDate.getTime() - a.data.startDate.getTime();
}