From d4e699103289e1b8d7e9e0e14d5ee61ed46112a0 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Fri, 27 Feb 2026 19:05:54 -0600 Subject: [PATCH 1/4] opengraph fixes --- src/lib/og-fonts.ts | 23 +++ src/lib/og-image.tsx | 288 +++++++++++++++++---------- src/pages/authors/[author].astro | 2 +- src/pages/authors/index.astro | 2 +- src/pages/blog/[page].astro | 2 +- src/pages/blog/index.astro | 4 +- src/pages/index.astro | 2 +- src/pages/og/[...slug].png.ts | 27 +-- src/pages/og/authors/[author].png.ts | 52 +++++ src/pages/og/authors/index.png.ts | 35 ++++ src/pages/og/blog.png.ts | 34 ++++ src/pages/og/index.png.ts | 30 +++ 12 files changed, 366 insertions(+), 135 deletions(-) create mode 100644 src/lib/og-fonts.ts create mode 100644 src/pages/og/authors/[author].png.ts create mode 100644 src/pages/og/authors/index.png.ts create mode 100644 src/pages/og/blog.png.ts create mode 100644 src/pages/og/index.png.ts diff --git a/src/lib/og-fonts.ts b/src/lib/og-fonts.ts new file mode 100644 index 0000000..0dc5b36 --- /dev/null +++ b/src/lib/og-fonts.ts @@ -0,0 +1,23 @@ +// Shared font loading for OG image generation +async function loadFont(weight: number): Promise { + const url = `https://fonts.googleapis.com/css2?family=Inter:wght@${weight}&display=swap`; + const css = await fetch(url).then((r) => r.text()); + const match = css.match(/src: url\((.+?)\) format/); + if (!match?.[1]) throw new Error(`Failed to load Inter ${weight}`); + return fetch(match[1]).then((r) => r.arrayBuffer()); +} + +let fontsPromise: Promise< + { name: string; data: ArrayBuffer; weight: 400 | 600 | 700; style: "normal" }[] +> | null = null; + +export function getFonts() { + if (!fontsPromise) { + fontsPromise = Promise.all([ + loadFont(400).then((data) => ({ name: "Inter" as const, data, weight: 400 as const, style: "normal" as const })), + loadFont(600).then((data) => ({ name: "Inter" as const, data, weight: 600 as const, style: "normal" as const })), + loadFont(700).then((data) => ({ name: "Inter" as const, data, weight: 700 as const, style: "normal" as const })), + ]); + } + return fontsPromise; +} diff --git a/src/lib/og-image.tsx b/src/lib/og-image.tsx index c72aeba..a772056 100644 --- a/src/lib/og-image.tsx +++ b/src/lib/og-image.tsx @@ -7,11 +7,18 @@ interface OgImageProps { tags: string[]; } -export function OgImage({ title, author, date, tags }: OgImageProps): ReactNode { - // Truncate title if too long - const displayTitle = title.length > 100 ? title.slice(0, 97) + "…" : title; - const displayTags = tags.slice(0, 4); +interface OgPageImageProps { + title: string; + subtitle: string; + detail?: string; +} + +const pythonLogoBlue = + "M126.916.072c-64.832 0-60.784 28.115-60.784 28.115l.072 29.128h61.868v8.745H41.631S.145 61.355.145 126.77c0 65.417 36.21 63.097 36.21 63.097h21.61v-30.356s-1.165-36.21 35.632-36.21h61.362s34.475.557 34.475-33.319V33.97S194.67.072 126.916.072zM92.802 19.66a11.12 11.12 0 0 1 11.13 11.13 11.12 11.12 0 0 1-11.13 11.13 11.12 11.12 0 0 1-11.13-11.13 11.12 11.12 0 0 1 11.13-11.13z"; +const pythonLogoYellow = + "M128.757 254.126c64.832 0 60.784-28.115 60.784-28.115l-.072-29.127H127.6v-8.745h86.441s41.486 4.705 41.486-60.712c0-65.416-36.21-63.096-36.21-63.096h-21.61v30.355s1.165 36.21-35.632 36.21h-61.362s-34.475-.557-34.475 33.32v56.013s-5.235 33.897 62.518 33.897zm34.114-19.586a11.12 11.12 0 0 1-11.13-11.13 11.12 11.12 0 0 1 11.13-11.131 11.12 11.12 0 0 1 11.13 11.13 11.12 11.12 0 0 1-11.13 11.13z"; +function OgShell({ children }: { children: ReactNode }): ReactNode { return (
- {/* Subtle geometric pattern — diagonal lines */} + {/* Subtle geometric pattern */}
- {/* Top accent bar */}
+ {children} +
+ ); +} - {/* Content area */} +function SiteBadge(): ReactNode { + return ( +
+
+ + + + +
+ + Python Insider + +
+ ); +} + +function LogoWatermark(): ReactNode { + return ( +
+ + + + +
+ ); +} + +export function OgPageImage({ title, subtitle, detail }: OgPageImageProps): ReactNode { + return ( +
- {/* Python logo watermark */} + + + + {/* Center: large title + subtitle */}
- - - - + {title} +
+
+ {subtitle} +
- {/* Top section: site name */} + {/* Bottom detail */} + {detail && ( +
+ {detail} +
+ )} +
+ + ); +} + +export function OgAuthorImage({ + name, + postCount, +}: { + name: string; + postCount: number; +}): ReactNode { + return ( + +
+ + +
+
Author
-
- - - - -
- - Python Insider - + {name}
- {/* Middle section: title */} +
+ + {postCount} {postCount === 1 ? "post" : "posts"} + + · + Python Insider +
+
+
+ ); +} + +export function OgImage({ title, author, date, tags }: OgImageProps): ReactNode { + const displayTitle = title.length > 100 ? title.slice(0, 97) + "…" : title; + const displayTags = tags.slice(0, 4); + + return ( + +
+ + +
- {/* Bottom section: author, date, tags */}
-
- - {author} - +
+ {author} · - - {date} - + {date}
{displayTags.length > 0 && ( -
+
{displayTags.map((tag) => (
-
+ ); } diff --git a/src/pages/authors/[author].astro b/src/pages/authors/[author].astro index 917e8ba..30b284d 100644 --- a/src/pages/authors/[author].astro +++ b/src/pages/authors/[author].astro @@ -45,7 +45,7 @@ const { slug, author, posts } = Astro.props; const avatarUrl = author.avatar || (author.github ? `https://github.com/${author.github}.png` : ""); --- - +
+

+

All Posts

{totalPosts} posts · Page {currentPage} of {totalPages}

diff --git a/src/pages/blog/index.astro b/src/pages/blog/index.astro index b79baa8..4cce310 100644 --- a/src/pages/blog/index.astro +++ b/src/pages/blog/index.astro @@ -28,6 +28,8 @@ const topTags = [...tagCounts.entries()] .sort((a, b) => b[1] - a[1]) .slice(0, 20); +const ogImage = withBase("/og/blog.png"); + // Collect years for sidebar const yearSet = new Set(); for (const post of posts) { @@ -36,7 +38,7 @@ for (const post of posts) { const years = [...yearSet].sort((a, b) => b - a); --- - +

All Posts

{posts.length} posts from the Python core development team

diff --git a/src/pages/index.astro b/src/pages/index.astro index 88816d1..88c714e 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -17,7 +17,7 @@ const totalPosts = posts.length; const authors = new Set(posts.map((p) => p.data.author)); --- - +
diff --git a/src/pages/og/[...slug].png.ts b/src/pages/og/[...slug].png.ts index 4a9ee38..b81ce81 100644 --- a/src/pages/og/[...slug].png.ts +++ b/src/pages/og/[...slug].png.ts @@ -3,32 +3,7 @@ import { getCollection } from "astro:content"; import satori from "satori"; import { Resvg } from "@resvg/resvg-js"; import { OgImage } from "../../lib/og-image"; - -// Load Inter font from Google Fonts at build time -async function loadFont( - weight: number, -): Promise { - const url = `https://fonts.googleapis.com/css2?family=Inter:wght@${weight}&display=swap`; - const css = await fetch(url).then((r) => r.text()); - const match = css.match(/src: url\((.+?)\) format/); - if (!match?.[1]) throw new Error(`Failed to load Inter ${weight}`); - return fetch(match[1]).then((r) => r.arrayBuffer()); -} - -let fontsPromise: Promise< - { name: string; data: ArrayBuffer; weight: 400 | 600 | 700; style: "normal" }[] -> | null = null; - -function getFonts() { - if (!fontsPromise) { - fontsPromise = Promise.all([ - loadFont(400).then((data) => ({ name: "Inter" as const, data, weight: 400 as const, style: "normal" as const })), - loadFont(600).then((data) => ({ name: "Inter" as const, data, weight: 600 as const, style: "normal" as const })), - loadFont(700).then((data) => ({ name: "Inter" as const, data, weight: 700 as const, style: "normal" as const })), - ]); - } - return fontsPromise; -} +import { getFonts } from "../../lib/og-fonts"; export const prerender = true; diff --git a/src/pages/og/authors/[author].png.ts b/src/pages/og/authors/[author].png.ts new file mode 100644 index 0000000..4d23fc1 --- /dev/null +++ b/src/pages/og/authors/[author].png.ts @@ -0,0 +1,52 @@ +import type { APIRoute, GetStaticPaths } from "astro"; +import { getCollection } from "astro:content"; +import satori from "satori"; +import { Resvg } from "@resvg/resvg-js"; +import { OgAuthorImage } from "../../../lib/og-image"; +import { getFonts } from "../../../lib/og-fonts"; +import { slugify } from "../../../lib/utils"; + +export const prerender = true; + +export const getStaticPaths: GetStaticPaths = async () => { + const allPosts = await getCollection("posts"); + const publishedPosts = allPosts.filter((p) => p.data.published); + const allAuthors = await getCollection("authors"); + + const authorCounts = new Map(); + for (const post of publishedPosts) { + const slug = slugify(post.data.author); + authorCounts.set(slug, (authorCounts.get(slug) || 0) + 1); + } + + return allAuthors + .filter((a) => authorCounts.has(a.id)) + .map((author) => ({ + params: { author: author.id }, + props: { + name: author.data.name, + postCount: authorCounts.get(author.id) || 0, + }, + })); +}; + +export const GET: APIRoute = async ({ props }) => { + const { name, postCount } = props as { name: string; postCount: number }; + + const fonts = await getFonts(); + + const svg = await satori( + OgAuthorImage({ name, postCount }), + { width: 1200, height: 630, fonts }, + ); + + const resvg = new Resvg(svg, { fitTo: { mode: "width", value: 1200 } }); + const png = resvg.render().asPng(); + + return new Response(new Uint8Array(png), { + headers: { + "Content-Type": "image/png", + "Cache-Control": "public, max-age=31536000, immutable", + }, + }); +}; diff --git a/src/pages/og/authors/index.png.ts b/src/pages/og/authors/index.png.ts new file mode 100644 index 0000000..3721f37 --- /dev/null +++ b/src/pages/og/authors/index.png.ts @@ -0,0 +1,35 @@ +import type { APIRoute } from "astro"; +import { getCollection } from "astro:content"; +import satori from "satori"; +import { Resvg } from "@resvg/resvg-js"; +import { OgPageImage } from "../../../lib/og-image"; +import { getFonts } from "../../../lib/og-fonts"; + +export const prerender = true; + +export const GET: APIRoute = async () => { + const allPosts = await getCollection("posts"); + const publishedPosts = allPosts.filter((p) => p.data.published); + const authors = new Set(publishedPosts.map((p) => p.data.author)); + + const fonts = await getFonts(); + + const svg = await satori( + OgPageImage({ + title: "Authors", + subtitle: "Contributors to the Python Insider blog.", + detail: `${authors.size} authors`, + }), + { width: 1200, height: 630, fonts }, + ); + + const resvg = new Resvg(svg, { fitTo: { mode: "width", value: 1200 } }); + const png = resvg.render().asPng(); + + return new Response(new Uint8Array(png), { + headers: { + "Content-Type": "image/png", + "Cache-Control": "public, max-age=31536000, immutable", + }, + }); +}; diff --git a/src/pages/og/blog.png.ts b/src/pages/og/blog.png.ts new file mode 100644 index 0000000..4fce952 --- /dev/null +++ b/src/pages/og/blog.png.ts @@ -0,0 +1,34 @@ +import type { APIRoute } from "astro"; +import { getCollection } from "astro:content"; +import satori from "satori"; +import { Resvg } from "@resvg/resvg-js"; +import { OgPageImage } from "../../lib/og-image"; +import { getFonts } from "../../lib/og-fonts"; + +export const prerender = true; + +export const GET: APIRoute = async () => { + const allPosts = await getCollection("posts"); + const postCount = allPosts.filter((p) => p.data.published).length; + + const fonts = await getFonts(); + + const svg = await satori( + OgPageImage({ + title: "Blog", + subtitle: "News and updates from the Python core development team.", + detail: `${postCount} posts`, + }), + { width: 1200, height: 630, fonts }, + ); + + const resvg = new Resvg(svg, { fitTo: { mode: "width", value: 1200 } }); + const png = resvg.render().asPng(); + + return new Response(new Uint8Array(png), { + headers: { + "Content-Type": "image/png", + "Cache-Control": "public, max-age=31536000, immutable", + }, + }); +}; diff --git a/src/pages/og/index.png.ts b/src/pages/og/index.png.ts new file mode 100644 index 0000000..faf48ef --- /dev/null +++ b/src/pages/og/index.png.ts @@ -0,0 +1,30 @@ +import type { APIRoute } from "astro"; +import satori from "satori"; +import { Resvg } from "@resvg/resvg-js"; +import { OgPageImage } from "../../lib/og-image"; +import { getFonts } from "../../lib/og-fonts"; + +export const prerender = true; + +export const GET: APIRoute = async () => { + const fonts = await getFonts(); + + const svg = await satori( + OgPageImage({ + title: "Python Insider", + subtitle: "The official blog of the Python core development team.", + detail: "blog.python.org", + }), + { width: 1200, height: 630, fonts }, + ); + + const resvg = new Resvg(svg, { fitTo: { mode: "width", value: 1200 } }); + const png = resvg.render().asPng(); + + return new Response(new Uint8Array(png), { + headers: { + "Content-Type": "image/png", + "Cache-Control": "public, max-age=31536000, immutable", + }, + }); +}; From 9c9044aa1860ea4ad6e934b7569c740ef65bd3fc Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Fri, 27 Feb 2026 19:06:19 -0600 Subject: [PATCH 2/4] add bio to author --- src/lib/og-image.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/lib/og-image.tsx b/src/lib/og-image.tsx index a772056..c1e1428 100644 --- a/src/lib/og-image.tsx +++ b/src/lib/og-image.tsx @@ -180,9 +180,11 @@ export function OgPageImage({ title, subtitle, detail }: OgPageImageProps): Reac export function OgAuthorImage({ name, + bio, postCount, }: { name: string; + bio?: string; postCount: number; }): ReactNode { return ( @@ -222,6 +224,19 @@ export function OgAuthorImage({ > {name}
+ {bio && ( +
+ {bio} +
+ )}
From 0104c7db7ad87f2473f137f7c878f5d7d0f54016 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Fri, 27 Feb 2026 19:07:34 -0600 Subject: [PATCH 3/4] update author og img with bio in all the places --- src/pages/authors/[author].astro | 2 +- src/pages/og/authors/[author].png.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/pages/authors/[author].astro b/src/pages/authors/[author].astro index 30b284d..b784079 100644 --- a/src/pages/authors/[author].astro +++ b/src/pages/authors/[author].astro @@ -45,7 +45,7 @@ const { slug, author, posts } = Astro.props; const avatarUrl = author.avatar || (author.github ? `https://github.com/${author.github}.png` : ""); --- - +
{ params: { author: author.id }, props: { name: author.data.name, + bio: author.data.bio, postCount: authorCounts.get(author.id) || 0, }, })); }; export const GET: APIRoute = async ({ props }) => { - const { name, postCount } = props as { name: string; postCount: number }; + const { name, bio, postCount } = props as { name: string; bio?: string; postCount: number }; const fonts = await getFonts(); const svg = await satori( - OgAuthorImage({ name, postCount }), + OgAuthorImage({ name, bio, postCount }), { width: 1200, height: 630, fonts }, ); From 78930be6667d89bf45c924379fc0adf0bf2157d8 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Fri, 27 Feb 2026 19:10:29 -0600 Subject: [PATCH 4/4] use local url during dev so opengraph loads locally too --- src/components/BaseHead.astro | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/BaseHead.astro b/src/components/BaseHead.astro index 33ea49b..1f27183 100644 --- a/src/components/BaseHead.astro +++ b/src/components/BaseHead.astro @@ -7,8 +7,9 @@ interface Props { import { withBase } from "../lib/utils"; const { title, description = "The official blog of the Python core development team.", image } = Astro.props; +const baseUrl = import.meta.env.DEV ? Astro.url.origin : Astro.site; const canonicalURL = new URL(Astro.url.pathname, Astro.site); -const ogImage = image ? new URL(image, Astro.site) : new URL(withBase("/og-default.png"), Astro.site); +const ogImage = image ? new URL(image, baseUrl) : new URL(withBase("/og-default.png"), baseUrl); ---