Skip to content

Commit 2221427

Browse files
Merge pull request #334 from basementstudio/og-images
Dynamic og images
2 parents 1c6b21b + 78cd3c0 commit 2221427

File tree

15 files changed

+1556
-124
lines changed

15 files changed

+1556
-124
lines changed
Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
import { ImageResponse } from "@vercel/og";
2+
import { getBlogMetadata } from "@/utils/blog";
3+
import { getDocsMetadata } from "@/utils/docs";
4+
import { formatDate } from "@/utils/format-date";
5+
import type { NextRequest } from "next/server";
6+
import { getBaseUrl } from "@/lib/base-url";
7+
8+
const OG_WIDTH = 1200;
9+
const OG_HEIGHT = 630;
10+
11+
const geistRegular = fetch(
12+
new URL(
13+
"https://cdn.jsdelivr.net/fontsource/fonts/geist-sans@latest/latin-400-normal.woff"
14+
)
15+
).then((res) => res.arrayBuffer());
16+
17+
const XmcpLogo = () => (
18+
<svg
19+
width="84"
20+
height="32"
21+
viewBox="0 0 63 24"
22+
fill="none"
23+
xmlns="http://www.w3.org/2000/svg"
24+
>
25+
<path
26+
fillRule="evenodd"
27+
clipRule="evenodd"
28+
d="M52.5 18.1313V18.9556H58.5116V18.1313H56.794V14.2731H57.6526V15.1333H59.3703V15.9934H60.229V15.1333H61.9469V14.2731H62.8055V7.39184H61.9469V5.67147H61.0877V4.81129H59.3703V3.95117H57.6526V4.81129H55.9353V5.67147H55.0763V6.53165H52.5V7.39184H53.3587V18.1313H52.5ZM57.6526 14.2731V13.4129H56.794V5.67147H57.6526V6.53165H58.5116V7.39184H59.3703V14.2731H57.6526Z"
29+
fill="#F7F7F7"
30+
/>
31+
<path
32+
fillRule="evenodd"
33+
clipRule="evenodd"
34+
d="M46.3668 16.9969H47.3703V15.9934H48.3739V14.9899H50.3809V15.9934H49.3775V16.9969H48.3739V18.0005H47.3703V19.004H44.3598V18.0005H42.3526V16.9969H41.3493V9.97232H40.3457V8.96879H41.3493V7.96527H42.3526V6.96175H43.3562V5.95822H45.3634V4.9547H47.3703V3.95117H48.3739V4.9547H49.3775V5.95822H50.3809V6.96175H51.3845V7.96527H50.3809V10.9758H49.3775V9.97232H48.3739V8.96879H47.3703V7.96527H46.3668V6.96175H45.3634V15.9934H46.3668V16.9969ZM50.3809 14.9899V13.9864H51.3845V14.9899H50.3809Z"
35+
fill="#F7F7F7"
36+
/>
37+
<path
38+
fillRule="evenodd"
39+
clipRule="evenodd"
40+
d="M36.0598 3.95117V4.9547H35.0562V5.95822H34.0529V6.96175H32.0457V5.95822H31.0423V4.9547H30.0388V3.95117H29.0352V4.9547H28.0316V5.95822H27.0282V6.96175H25.021V5.95822H24.0175V4.9547H23.0141V3.95117H22.0105V4.9547H21.0069V5.95822H19V6.96175H21.0069V16.9969H20.0033V18.0005H22.0105V19.004H24.0175V18.0005H26.0246V16.9969H25.021V7.96527H27.0282V6.96175H28.0316V16.9969H27.0282V18.0005H29.0352V19.004H31.0423V18.0005H33.0493V16.9969H32.0457V7.96527H34.0529V6.96175H35.0562V16.9969H34.0529V18.0005H36.0598V19.004H38.067V18.0005H40.0739V16.9969H39.0703V7.96527H40.0739V6.96175H39.0703V5.95822H38.067V4.9547H37.0634V3.95117H36.0598Z"
41+
fill="#F7F7F7"
42+
/>
43+
<path
44+
d="M0 5H1.00213V6H0V5ZM1.00213 22V19H2.00426V18H3.0064V17H5.01066V16H6.01279V15H7.01492V17H9.01919V18H10.0213V19H11.0235V20H10.0213V21H9.01919V22H8.01706V21H7.01492V20H5.01066V21H4.00853V23H5.01066V24H3.0064V23H2.00426V22H1.00213ZM1.00213 5V3H2.00426V2H3.0064V1H7.01492V2H9.01919V3H10.0213V5H11.0235V6H12.0256V7H13.0277V8H12.0256V9H17.0362V10H16.0341V11H14.0298V13H15.032V14H16.0341V15H17.0362V17H18.0384V18H19.0405V19H17.0362V20H16.0341V21H15.032V20H14.0298V18H13.0277V16H12.0256V15H11.0235V13H9.01919V12H2.00426V11H3.0064V10H4.00853V9H8.01706V8H7.01492V6H6.01279V4H5.01066V3H4.00853V4H2.00426V5H1.00213ZM7.01492 15V14H8.01706V15H7.01492ZM8.01706 14V13H9.01919V14H8.01706ZM11.0235 19V18H12.0256V19H11.0235ZM11.0235 5V4H12.0256V5H11.0235ZM12.0256 4V3H13.0277V1H15.032V2H17.0362V1H19.0405V4H18.0384V5H16.0341V6H15.032V5H14.0298V7H13.0277V4H12.0256ZM19.0405 18V17H20.0426V18H19.0405ZM19.0405 1V0H20.0426V1H19.0405Z"
45+
fill="#F7F7F7"
46+
/>
47+
</svg>
48+
);
49+
50+
interface OgContent {
51+
readonly title: string;
52+
readonly description: string;
53+
readonly summary: string | undefined;
54+
readonly date: string | undefined;
55+
}
56+
57+
type ResolveResult =
58+
| { readonly ok: true; readonly content: OgContent }
59+
| { readonly ok: false; readonly response: Response };
60+
61+
function resolveOgContent(
62+
type: string,
63+
rest: string[],
64+
baseUrl: string
65+
): ResolveResult {
66+
switch (type) {
67+
case "blog": {
68+
const slug = rest.join("/");
69+
if (!slug) {
70+
return {
71+
ok: true,
72+
content: {
73+
title: "Blog - xmcp",
74+
description: "Latest updates, guides, and insights about xmcp.",
75+
summary: undefined,
76+
date: undefined,
77+
},
78+
};
79+
}
80+
81+
const meta = getBlogMetadata(slug, baseUrl);
82+
if (!meta) {
83+
return {
84+
ok: false,
85+
response: new Response("Blog post not found", { status: 404 }),
86+
};
87+
}
88+
89+
return {
90+
ok: true,
91+
content: {
92+
title: meta.title,
93+
description: meta.description,
94+
summary: meta.summary,
95+
date: meta.date,
96+
},
97+
};
98+
}
99+
100+
case "docs": {
101+
const slug = rest.length === 0 ? undefined : rest;
102+
if (!slug) {
103+
return {
104+
ok: true,
105+
content: {
106+
title: "xmcp Documentation",
107+
description: "The framework for building & shipping MCP servers.",
108+
summary: undefined,
109+
date: undefined,
110+
},
111+
};
112+
}
113+
114+
const meta = getDocsMetadata(slug, baseUrl);
115+
if (!meta) {
116+
return {
117+
ok: false,
118+
response: new Response("Documentation page not found", {
119+
status: 404,
120+
}),
121+
};
122+
}
123+
124+
return {
125+
ok: true,
126+
content: {
127+
title: meta.title,
128+
description: meta.description,
129+
summary: meta.summary,
130+
date: undefined,
131+
},
132+
};
133+
}
134+
135+
case "examples": {
136+
return {
137+
ok: true,
138+
content: {
139+
title: "Shaping the future of MCP tooling",
140+
description:
141+
"xmcp now supports building compatible UI resources and tools with the OpenAI Apps SDK, out of the box.",
142+
summary: undefined,
143+
date: "2025-12-11",
144+
},
145+
};
146+
}
147+
148+
default:
149+
return {
150+
ok: false,
151+
response: new Response("Invalid content type", { status: 400 }),
152+
};
153+
}
154+
}
155+
156+
export async function GET(
157+
_request: NextRequest,
158+
props: { params: Promise<{ path: string[] }> }
159+
): Promise<Response> {
160+
try {
161+
const params = await props.params;
162+
const path = Array.isArray(params.path) ? params.path : [params.path];
163+
164+
if (path.length === 0) {
165+
return new Response("Invalid path", { status: 400 });
166+
}
167+
168+
const [type, ...rest] = path;
169+
const baseUrl = getBaseUrl();
170+
171+
const result = resolveOgContent(type, rest, baseUrl);
172+
if (!result.ok) {
173+
return result.response;
174+
}
175+
176+
const { title, description, summary, date } = result.content;
177+
178+
return new ImageResponse(
179+
<div
180+
tw={`h-[${OG_HEIGHT}px] w-[${OG_WIDTH}px] flex bg-black relative overflow-hidden`}
181+
style={{
182+
padding: "64px",
183+
boxShadow: "0 0 250px 8px rgba(0, 0, 0, 0.80) inset",
184+
}}
185+
>
186+
{/* Background image */}
187+
<img
188+
src={`${baseUrl}/og/bg.png`}
189+
alt="Background"
190+
width={OG_WIDTH}
191+
height={OG_HEIGHT}
192+
tw={`absolute top-0 left-0 w-[${OG_WIDTH}px] h-[${OG_HEIGHT}px] object-cover`}
193+
style={{ mixBlendMode: "plus-lighter" }}
194+
/>
195+
{/* Noise overlay */}
196+
<img
197+
src={`${baseUrl}/og/noise.png`}
198+
alt="Noise"
199+
width={OG_WIDTH}
200+
height={OG_HEIGHT}
201+
tw={`absolute top-0 left-0 w-[${OG_WIDTH}px] h-[${OG_HEIGHT}px] object-cover opacity-40`}
202+
style={{ mixBlendMode: "overlay" }}
203+
/>
204+
{/* Logo - top right */}
205+
<div
206+
style={{
207+
position: "absolute",
208+
top: "64px",
209+
right: "64px",
210+
display: "flex",
211+
}}
212+
>
213+
<XmcpLogo />
214+
</div>
215+
{/* Date tag - top left */}
216+
{date && (
217+
<div
218+
style={{
219+
position: "absolute",
220+
top: "64px",
221+
left: "64px",
222+
display: "flex",
223+
alignItems: "center",
224+
padding: "4px 8px",
225+
border: "1px dashed #525252",
226+
backgroundColor: "rgba(245, 245, 245, 0.07)",
227+
color: "#A8A8A8",
228+
fontFamily: "Geist",
229+
fontStyle: "normal",
230+
fontSize: "24px",
231+
fontWeight: 500,
232+
lineHeight: "100%",
233+
letterSpacing: "1.92px",
234+
textTransform: "uppercase",
235+
}}
236+
>
237+
{formatDate(date)}
238+
</div>
239+
)}
240+
{/* Text content - bottom left */}
241+
<div
242+
style={{
243+
position: "absolute",
244+
bottom: "64px",
245+
left: "64px",
246+
right: "64px",
247+
display: "flex",
248+
flexDirection: "column",
249+
alignItems: "flex-start",
250+
justifyContent: "flex-end",
251+
}}
252+
>
253+
<h1
254+
style={{
255+
display: "flex",
256+
color: "white",
257+
margin: 0,
258+
marginBottom: "16px",
259+
fontFamily: "Geist",
260+
fontSize: "74px",
261+
fontWeight: 400,
262+
lineHeight: "110%",
263+
letterSpacing: "-1.04px",
264+
textShadow: "0 0 52px rgba(255, 255, 255, 0.60)",
265+
textWrap: "balance",
266+
}}
267+
>
268+
{title}
269+
</h1>
270+
{(summary || description) && (
271+
<p
272+
key={summary ?? description}
273+
style={{
274+
display: "flex",
275+
margin: 0,
276+
fontFamily: "Geist",
277+
fontSize: "32px",
278+
fontWeight: 400,
279+
color: "#A8A8A8",
280+
lineHeight: "120%",
281+
letterSpacing: "0.26px",
282+
textShadow: "0 0 40px rgba(0, 0, 0, 0.50)",
283+
textWrap: "balance",
284+
}}
285+
>
286+
{summary ?? description}
287+
</p>
288+
)}
289+
</div>
290+
</div>,
291+
{
292+
width: OG_WIDTH,
293+
height: OG_HEIGHT,
294+
fonts: [
295+
{
296+
name: "Geist",
297+
data: await geistRegular,
298+
style: "normal",
299+
weight: 400,
300+
},
301+
],
302+
}
303+
);
304+
} catch (error) {
305+
console.error("Error generating OG image:", error);
306+
return new Response("Error generating image", { status: 500 });
307+
}
308+
}

apps/website/app/blog/[...slug]/page.tsx

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import type { Metadata } from "next";
66
import { createRelativeLink } from "fumadocs-ui/mdx";
77
import { CodeBlock } from "@/components/codeblock";
88
import { BlogPage } from "@/components/layout/blog";
9+
import { getBlogMetadata } from "@/utils/blog";
10+
import { getBaseUrl } from "@/lib/base-url";
911

1012
export default async function Page(props: PageProps<"/blog/[...slug]">) {
1113
const params = await props.params;
@@ -43,14 +45,37 @@ export async function generateMetadata(
4345
props: PageProps<"/blog/[...slug]">
4446
): Promise<Metadata> {
4547
const params = await props.params;
46-
const page = blogSource.getPage(params.slug);
47-
if (!page) notFound();
48+
const slug = Array.isArray(params.slug) ? params.slug.join("/") : params.slug;
49+
const baseUrl = getBaseUrl();
50+
const meta = getBlogMetadata(slug, baseUrl);
51+
if (!meta) notFound();
52+
53+
const { title, description, ogImageUrl } = meta;
4854

4955
return {
50-
title: page.data.title + " | xmcp Blog",
51-
description: page.data.description,
52-
/* openGraph: {
53-
images: getPageImage(page).url,
54-
}, */
56+
title,
57+
description,
58+
openGraph: {
59+
title,
60+
description,
61+
siteName: "xmcp",
62+
type: "article",
63+
locale: "en_US",
64+
images: {
65+
url: ogImageUrl,
66+
width: 1200,
67+
height: 630,
68+
},
69+
},
70+
twitter: {
71+
card: "summary_large_image",
72+
title,
73+
description,
74+
images: {
75+
url: ogImageUrl,
76+
width: 1200,
77+
height: 630,
78+
},
79+
},
5580
};
5681
}

0 commit comments

Comments
 (0)