Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added public/fonts/anthropic-serif-static.ttf
Binary file not shown.
Binary file added public/fonts/space-grotesk-400.ttf
Binary file not shown.
114 changes: 104 additions & 10 deletions src/app/blog/[slug]/opengraph-image.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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(/<!DOCTYPE[\s\S]*?>/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);
Expand All @@ -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(
(
Expand All @@ -31,48 +87,78 @@ 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,
}}
>
<div
style={{
display: "flex",
position: "absolute",
inset: 0,
width: ogLayout.leftPanelWidth,
height: ogLayout.height,
backgroundColor: theme.leftPanel,
}}
/>
<div
style={{
display: "flex",
position: "absolute",
left: ogLayout.leftPanelWidth,
width: ogLayout.rightPanelWidth,
height: ogLayout.height,
backgroundColor: theme.rightPanel,
overflow: "visible",
}}
/>
<div
>
<img
src={scytheSrc}
alt=""
width={scytheFrame.width}
height={scytheFrame.height}
style={{
position: "absolute",
left: "50%",
top: "50%",
width: scytheFrame.width,
height: scytheFrame.height,
transform: `translate(-50%, -50%) rotate(${DEFAULT_SCYTHE_ROTATION}deg)`,
transformOrigin: "center",
zIndex: 2,
}}
/>
</div>
<img
src={logoSrc}
alt=""
width={ogLayout.logo.width}
height={ogLayout.logo.height}
style={{
position: "absolute",
left: ogLayout.logo.left,
top: ogLayout.logo.top,
width: ogLayout.logo.width,
height: ogLayout.logo.height,
backgroundColor: theme.logo,
borderRadius: 4,
}}
/>
<h1
style={{
display: "flex",
position: "absolute",
left: ogLayout.title.left,
top: ogLayout.title.top,
width: ogLayout.title.width,
margin: 0,
color: theme.title,
fontSize: Math.round(fontSize * 0.75),
fontFamily: headingFont.family,
fontWeight: 400,
lineHeight: 1.1,
fontSize: titleFontSize,
lineHeight: 1.02,
letterSpacing: "-0.05em",
whiteSpace: "pre-wrap",
}}
>
{post.title}
Expand All @@ -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,
},
] : [],
}
);
}
}