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 = { const meta = {
description: exp.data.description ?? `${role ?? ""} at ${organization}`.trim(), description: exp.data.description ?? `${role ?? ""} at ${organization}`.trim(),
ogImage: `/og-image/experience/${exp.id}.png`,
title: exp.data.title, title: exp.data.title,
}; };
--- ---
+2 -4
View File
@@ -6,7 +6,7 @@ import { getAllPosts } from "@/data/post";
import { getAllSpeaking } from "@/data/speaking"; import { getAllSpeaking } from "@/data/speaking";
import PageLayout from "@/layouts/Base.astro"; import PageLayout from "@/layouts/Base.astro";
import { siteConfig, socialLinks } from "@/site.config"; import { siteConfig, socialLinks } from "@/site.config";
import { collectionDateSort } from "@/utils/date"; import { collectionDateSort, projectDateSort } from "@/utils/date";
const MAX_POSTS = 4; const MAX_POSTS = 4;
const allPosts = await getAllPosts(); const allPosts = await getAllPosts();
@@ -17,9 +17,7 @@ const latestPosts = (allPosts.sort(collectionDateSort) as CollectionEntry<"writi
const MAX_PROJECTS = 2; const MAX_PROJECTS = 2;
const allProjects = await getCollection("projects"); const allProjects = await getCollection("projects");
const latestProjects = allProjects const latestProjects = allProjects.sort(projectDateSort).slice(0, MAX_PROJECTS);
.sort(collectionDateSort)
.slice(0, MAX_PROJECTS) as CollectionEntry<"projects">[];
const MAX_TALKS = 3; const MAX_TALKS = 3;
const latestTalks = (await getAllSpeaking()).sort(collectionDateSort).slice(0, MAX_TALKS); 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 satori, { type SatoriOptions } from "satori";
import sharp from "sharp"; import sharp from "sharp";
import RobotoMonoBold from "@/assets/roboto-mono-700.ttf"; import RobotoMonoBold from "@/assets/roboto-mono-700.ttf";
import RobotoMono from "@/assets/roboto-mono-regular.ttf"; import RobotoMono from "@/assets/roboto-mono-regular.ttf";
import { getAllPosts } from "@/data/post"; import { getAllPosts } from "@/data/post";
import { getAllSpeaking } from "@/data/speaking";
import { getFormattedDate } from "@/utils/date"; import { getFormattedDate } from "@/utils/date";
import { readCache, writeToCache } from "./_cacheUtil"; import { readCache, writeToCache } from "./_cacheUtil";
import { ogMarkup } from "./_ogMarkup"; import { type OgContent, ogMarkup } from "./_ogMarkup";
const ogOptions: SatoriOptions = { const ogOptions: SatoriOptions = {
// debug: true, // debug: true,
@@ -28,22 +30,17 @@ const ogOptions: SatoriOptions = {
width: 1200, width: 1200,
}; };
type Props = InferGetStaticPropsType<typeof getStaticPaths>; type Props = OgContent & { cacheDate: Date };
export async function GET(context: APIContext) { 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(content.title, cacheDate);
let pngBuffer = readCache(title, pubDate);
if (!pngBuffer) { if (!pngBuffer) {
console.info(`Generating new OG image for: ${title}`); console.info(`Generating new OG image for: ${content.title}`);
const postDate = getFormattedDate(pubDate, { const svg = await satori(ogMarkup(content), ogOptions);
month: "long",
weekday: "long",
});
const svg = await satori(ogMarkup(title, postDate), ogOptions);
pngBuffer = await sharp(Buffer.from(svg)).png().toBuffer(); pngBuffer = await sharp(Buffer.from(svg)).png().toBuffer();
writeToCache(title, pubDate, pngBuffer); writeToCache(content.title, cacheDate, pngBuffer);
} }
return new Response(new Uint8Array(pngBuffer), { return new Response(new Uint8Array(pngBuffer), {
@@ -55,16 +52,60 @@ export async function GET(context: APIContext) {
} }
export async function getStaticPaths() { export async function getStaticPaths() {
const posts = await getAllPosts(); const date = (d: Date) => getFormattedDate(d, { month: "long" });
return posts
.values() const [projects, experiences, speaking] = await Promise.all([
.filter(({ data }) => !data.ogImage) getCollection("projects"),
.map((post) => ({ getCollection("experience"),
params: { slug: post.id }, 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: { props: {
pubDate: post.data.updatedDate ?? post.data.publishDate, category: "Writing",
title: post.data.title, 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"; import { fileURLToPath } from "node:url";
// Update/bump version when changing markup in _ogMarkup.ts, or fonts/config in [...slug].png.ts // 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 // Cache directory, defaults to node_modules/.astro/og-images
const CACHE_DIR = path.join(fileURLToPath(cacheDir), "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"; import { siteConfig } from "@/site.config";
// OG image markup, use https://og-playground.vercel.app/ to design your own. // OG image markup. Structure adapted from ogimagecn's "Blog" component
export const ogMarkup = (title: string, pubDate: string) => // (https://www.ogimagecn.com/docs/components/blog) — a type badge, title,
html`<div tw="flex flex-col w-full h-full bg-[#1d1f21] text-[#c9cacc]"> // excerpt, and author/meta line — restyled to match the site's minimal
<div tw="flex flex-col flex-1 w-full p-10 justify-center"> // social-card.png: dark background, green accent frame, mono type.
<p tw="text-2xl mb-6">${pubDate}</p> //
<h1 tw="text-6xl font-bold leading-snug text-white">${title}</h1> // Built as a plain Satori node tree (not satori-html) so dynamic text is
</div> // rendered literally — satori-html escapes `&`/`<`/`>` in interpolations and
<div tw="flex items-center justify-between w-full p-10 border-t-2 border-[#2bbc89] text-white"> // would otherwise render e.g. "Founder & Lead Engineer" as "Founder &amp;…".
<p tw="text-2xl ml-3 font-semibold">${siteConfig.title}</p>
<p>by ${siteConfig.author}</p> const BG = "#1d1f21";
</div> const WHITE = "#ffffff";
</div>`; 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 Pagination from "@/components/Paginator.astro";
import Project from "@/components/project/Project.astro"; import Project from "@/components/project/Project.astro";
import PageLayout from "@/layouts/Base.astro"; import PageLayout from "@/layouts/Base.astro";
import { collectionDateSort } from "@/utils/date"; import { projectDateSort } from "@/utils/date";
export const getStaticPaths = (async ({ paginate }) => { export const getStaticPaths = (async ({ paginate }) => {
const MAX_PROJECTS_PER_PAGE = 10; const MAX_PROJECTS_PER_PAGE = 10;
const allProjects = await getCollection("projects"); 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; }) satisfies GetStaticPaths;
interface Props { interface Props {
+1
View File
@@ -19,6 +19,7 @@ const { project } = Astro.props;
const meta = { const meta = {
description: project.data.description || `Project: ${project.data.title}`, description: project.data.description || `Project: ${project.data.title}`,
ogImage: `/og-image/projects/${project.id}.png`,
title: project.data.title, title: project.data.title,
}; };
--- ---
+1 -1
View File
@@ -11,7 +11,7 @@ export const GET = async () => {
site: import.meta.env.SITE, site: import.meta.env.SITE,
items: projects.map((project) => ({ items: projects.map((project) => ({
title: project.data.title, title: project.data.title,
pubDate: project.data.publishDate, pubDate: project.data.endDate ?? project.data.startDate,
link: `projects/${project.id}/`, link: `projects/${project.id}/`,
})), })),
}); });
+1 -1
View File
@@ -20,7 +20,7 @@ const { talk } = Astro.props as Props;
const { Content } = await render(talk); const { Content } = await render(talk);
const { title, description, videoId, originalSource, event, publishDate, experiences } = talk.data; 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}> <PageLayout meta={meta}>
+3 -8
View File
@@ -26,7 +26,6 @@ export const siteConfig: SiteConfig = {
export const streamOrigin = "https://customer-CODE.cloudflarestream.com"; export const streamOrigin = "https://customer-CODE.cloudflarestream.com";
// Social links shown in the homepage intro and the footer's icon row. // Social links shown in the homepage intro and the footer's icon row.
// ! Update the handles below to your own.
export const socialLinks: { export const socialLinks: {
friendlyName: string; friendlyName: string;
link: string; link: string;
@@ -34,13 +33,9 @@ export const socialLinks: {
isWebmention?: boolean; isWebmention?: boolean;
}[] = [ }[] = [
{ friendlyName: "GitHub", link: "https://github.com/prdlk", name: "mdi:github" }, { friendlyName: "GitHub", link: "https://github.com/prdlk", name: "mdi:github" },
{ friendlyName: "X", link: "https://x.com/prad_nukala", name: "mdi:twitter" }, { friendlyName: "X", link: "https://x.com/basedprad", name: "mdi:twitter" },
{ { friendlyName: "LinkedIn", link: "https://linkedin.com/in/pradn", name: "mdi:linkedin" },
friendlyName: "LinkedIn", { friendlyName: "Email", link: "mailto:prnk28@gmail.com", name: "mdi:email-outline" },
link: "https://www.linkedin.com/in/prad-nukala/",
name: "mdi:linkedin",
},
{ friendlyName: "Email", link: "mailto:prad@sonr.io", name: "mdi:email-outline" },
]; ];
// Used to generate links in both the Header & Footer. // Used to generate links in both the Header & Footer.
+10 -2
View File
@@ -16,8 +16,16 @@ export function getFormattedDate(
} }
export function collectionDateSort( export function collectionDateSort(
a: CollectionEntry<"writing" | "projects">, a: CollectionEntry<"writing" | "speaking">,
b: CollectionEntry<"writing" | "projects">, b: CollectionEntry<"writing" | "speaking">,
) { ) {
return b.data.publishDate.getTime() - a.data.publishDate.getTime(); 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();
}