|
| 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 | +} |
0 commit comments