mirror of
https://github.com/prdlk/website.git
synced 2026-08-02 17:31:41 +00:00
feat(og-image): add social card images for projects, experience, and talks
This commit is contained in:
@@ -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,
|
||||
};
|
||||
---
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 &…".
|
||||
|
||||
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}`),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
---
|
||||
|
||||
@@ -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}/`,
|
||||
})),
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user