From 2178cef232d0664b6dfa59bbe0ccafa460dfeead Mon Sep 17 00:00:00 2001 From: Prad Nukala Date: Tue, 30 Jun 2026 13:05:14 -0400 Subject: [PATCH] feat(og-image): add social card images for projects, experience, and talks --- src/pages/experience/[...slug].astro | 1 + src/pages/index.astro | 6 +- src/pages/og-image/[...slug].png.ts | 87 +++++++++++++++++------ src/pages/og-image/_cacheUtil.ts | 2 +- src/pages/og-image/_ogMarkup.ts | 100 +++++++++++++++++++++++---- src/pages/projects/[...page].astro | 4 +- src/pages/projects/[...slug].astro | 1 + src/pages/projects/rss.xml.ts | 2 +- src/pages/speaking/[...slug].astro | 2 +- src/site.config.ts | 11 +-- src/utils/date.ts | 12 +++- 11 files changed, 173 insertions(+), 55 deletions(-) diff --git a/src/pages/experience/[...slug].astro b/src/pages/experience/[...slug].astro index e81e40e..16dd831 100644 --- a/src/pages/experience/[...slug].astro +++ b/src/pages/experience/[...slug].astro @@ -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, }; --- diff --git a/src/pages/index.astro b/src/pages/index.astro index 93e42ea..688ef3e 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -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); diff --git a/src/pages/og-image/[...slug].png.ts b/src/pages/og-image/[...slug].png.ts index 5cefac0..a78b4d3 100644 --- a/src/pages/og-image/[...slug].png.ts +++ b/src/pages/og-image/[...slug].png.ts @@ -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; +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]; } diff --git a/src/pages/og-image/_cacheUtil.ts b/src/pages/og-image/_cacheUtil.ts index 367383b..c27e17b 100644 --- a/src/pages/og-image/_cacheUtil.ts +++ b/src/pages/og-image/_cacheUtil.ts @@ -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"); diff --git a/src/pages/og-image/_ogMarkup.ts b/src/pages/og-image/_ogMarkup.ts index 76fb211..bd075d0 100644 --- a/src/pages/og-image/_ogMarkup.ts +++ b/src/pages/og-image/_ogMarkup.ts @@ -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`
-
-

${pubDate}

-

${title}

-
-
-

${siteConfig.title}

-

by ${siteConfig.author}

-
-
`; +// 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 &…". + +const BG = "#1d1f21"; +const WHITE = "#ffffff"; +const MUTED = "#8a8f98"; +const ACCENT = "#2bbc89"; + +type Style = Record; +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}`), + ], + ); diff --git a/src/pages/projects/[...page].astro b/src/pages/projects/[...page].astro index 5c1f7ae..fd442af 100644 --- a/src/pages/projects/[...page].astro +++ b/src/pages/projects/[...page].astro @@ -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 { diff --git a/src/pages/projects/[...slug].astro b/src/pages/projects/[...slug].astro index bb0c2a8..94a3737 100644 --- a/src/pages/projects/[...slug].astro +++ b/src/pages/projects/[...slug].astro @@ -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, }; --- diff --git a/src/pages/projects/rss.xml.ts b/src/pages/projects/rss.xml.ts index 7a9e04d..0ca1e88 100644 --- a/src/pages/projects/rss.xml.ts +++ b/src/pages/projects/rss.xml.ts @@ -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}/`, })), }); diff --git a/src/pages/speaking/[...slug].astro b/src/pages/speaking/[...slug].astro index 568640e..12f2a1c 100644 --- a/src/pages/speaking/[...slug].astro +++ b/src/pages/speaking/[...slug].astro @@ -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 }; --- diff --git a/src/site.config.ts b/src/site.config.ts index cd1b9f8..397c072 100644 --- a/src/site.config.ts +++ b/src/site.config.ts @@ -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. diff --git a/src/utils/date.ts b/src/utils/date.ts index 150f87d..e7da15b 100644 --- a/src/utils/date.ts +++ b/src/utils/date.ts @@ -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(); +}