diff --git a/public/fonts/anthropic-serif-static.ttf b/public/fonts/anthropic-serif-static.ttf new file mode 100644 index 0000000..971b4a4 Binary files /dev/null and b/public/fonts/anthropic-serif-static.ttf differ diff --git a/public/fonts/space-grotesk-400.ttf b/public/fonts/space-grotesk-400.ttf new file mode 100644 index 0000000..576f9b5 Binary files /dev/null and b/public/fonts/space-grotesk-400.ttf differ diff --git a/src/app/blog/[slug]/opengraph-image.tsx b/src/app/blog/[slug]/opengraph-image.tsx index 9c33085..424b393 100644 --- a/src/app/blog/[slug]/opengraph-image.tsx +++ b/src/app/blog/[slug]/opengraph-image.tsx @@ -1,6 +1,17 @@ import { ImageResponse } from "next/og"; +import { promises as fs } from "node:fs"; +import path from "node:path"; import { getPostBySlug, getAllPostSlugs } from "@/lib/posts"; -import { getOgPreviewTheme, ogLayout } from "@/lib/og"; +import { + DEFAULT_SCYTHE_ROTATION, + DEFAULT_SCYTHE_SCALE, + DEFAULT_TITLE_SCALE, + getOgPreviewTheme, + getOgTitleStyle, + getScytheFrame, + ogLayout, +} from "@/lib/og"; +import type { FontDefinition } from "@/types/post"; export const dynamic = "force-static"; @@ -9,6 +20,41 @@ export async function generateStaticParams() { return slugs.map((slug) => ({ slug })); } +function tintSvg(svg: string, color: string) { + return svg + .replace(/<\?xml[\s\S]*?\?>/i, "") + .replace(//gi, "") + .replace(/#000000/gi, color) + .replace(/#000\b/gi, color) + .replace(/\bblack\b/gi, color); +} + +function cropScytheSvg(svg: string) { + return svg.replace(/viewBox="[^"]*"/i, 'viewBox="600 120 6938 2788"'); +} + +function svgToDataUri(svg: string) { + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; +} + +async function loadOgHeadingFont(font: FontDefinition) { + let fontPath: string | null = null; + + if (font.source === "local") { + fontPath = font.value === "/fonts/anthropic-serif-regular.woff2" + ? path.join(process.cwd(), "public", "fonts", "anthropic-serif-static.ttf") + : path.join(process.cwd(), "public", font.value.replace(/^\//, "")); + } else if (font.source === "google" && font.family === "Space Grotesk") { + fontPath = path.join(process.cwd(), "public", "fonts", "space-grotesk-400.ttf"); + } + + if (!fontPath) { + return null; + } + + return fs.readFile(fontPath); +} + export default async function Image({ params }: { params: Promise<{ slug: string }> }) { const { slug } = await params; const post = await getPostBySlug(slug); @@ -21,8 +67,18 @@ export default async function Image({ params }: { params: Promise<{ slug: string } const theme = getOgPreviewTheme(post.theme); + const headingFont = post.theme.fonts.heading; + const [fontData, logoSvg, scytheSvg] = await Promise.all([ + loadOgHeadingFont(headingFont), + fs.readFile(path.join(process.cwd(), "public", "brand", "logo.svg"), "utf8"), + fs.readFile(path.join(process.cwd(), "public", "brand", "scythe.svg"), "utf8"), + ]); - const fontSize = post.title.length > 72 ? 68 : post.title.length > 52 ? 86 : post.title.length > 34 ? 98 : 112; + const titleStyle = getOgTitleStyle(post.title, DEFAULT_TITLE_SCALE); + const titleFontSize = typeof titleStyle.fontSize === "number" ? titleStyle.fontSize : 72; + const scytheFrame = getScytheFrame(DEFAULT_SCYTHE_SCALE); + const logoSrc = svgToDataUri(tintSvg(logoSvg, theme.logo)); + const scytheSrc = svgToDataUri(tintSvg(cropScytheSvg(scytheSvg), theme.scythe)); return new ImageResponse( ( @@ -31,12 +87,17 @@ export default async function Image({ params }: { params: Promise<{ slug: string display: "flex", width: ogLayout.width, height: ogLayout.height, - fontFamily: "Georgia, serif", + position: "relative", + overflow: "hidden", + backgroundColor: theme.leftPanel, + fontFamily: headingFont.family, }} >
+ -
+

{post.title} @@ -82,6 +168,14 @@ export default async function Image({ params }: { params: Promise<{ slug: string { width: ogLayout.width, height: ogLayout.height, + fonts: fontData ? [ + { + name: headingFont.family, + data: fontData, + style: "normal", + weight: 400, + }, + ] : [], } ); -} \ No newline at end of file +}