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); --- 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..c1e1428 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} +
+ ); +} + +function SiteBadge(): ReactNode { + return ( +
+
+ + + + +
+ + Python Insider + +
+ ); +} + +function LogoWatermark(): ReactNode { + return ( +
+ + + + +
+ ); +} - {/* Content area */} +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, + bio, + postCount, +}: { + name: string; + bio?: string; + postCount: number; +}): ReactNode { + return ( + +
+ + +
+
Author
+ {name} +
+ {bio && (
- - - - -
- - Python Insider - -
+ {bio} +
+ )} +
+ +
+ + {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 ( + +
+ + - {/* Middle section: title */}
- {/* 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..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` : ""); --- - +
+

+

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..fff8b15 --- /dev/null +++ b/src/pages/og/authors/[author].png.ts @@ -0,0 +1,53 @@ +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, + bio: author.data.bio, + postCount: authorCounts.get(author.id) || 0, + }, + })); +}; + +export const GET: APIRoute = async ({ props }) => { + const { name, bio, postCount } = props as { name: string; bio?: string; postCount: number }; + + const fonts = await getFonts(); + + const svg = await satori( + OgAuthorImage({ name, bio, 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", + }, + }); +};