From 623af75962c92a8b37b56deca74b8c3040be1fb9 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 20 May 2026 14:27:02 +0900 Subject: [PATCH] feat(web): dynamic OG + Twitter card images via next/og app/opengraph-image.tsx (new): - next/og ImageResponse, 1200x630 PNG - Dark #0a0a0a background + cyan #00d4ff brand wordmark + headline + install command pill + terminal cursor - Google Fonts loadGoogleFont helper for Geist 400/700 + Geist Mono 500 (runtime fetch, Cloudflare Workers compatible) - Cache-Control: public, immutable, max-age=31536000 app/twitter-image.tsx (new): - Same shape as OG with Twitter-card alt text - Inherits twitter:card=summary_large_image from layout app/layout.tsx: - metadata.alternates.languages: explicit canonical URLs for en/ko/ ja/zh so search engines pick up hreflang - metadata.openGraph.alternateLocale: ko_KR, ja_JP, zh_CN - Drop manual openGraph.images / twitter.images: file-based metadata convention now auto-emits og:image:width, og:image:height, og:image:type, twitter:image:width, twitter:image:height - metadata.robots: max-image-preview=large, max-snippet=-1 (richer SERP previews) Verified: curl /opengraph-image returns 200, content-type image/png, 1200x630 PNG body. All 4 hreflang links present in . --- packages/web/app/layout.tsx | 19 ++- packages/web/app/opengraph-image.tsx | 167 +++++++++++++++++++++++++++ packages/web/app/twitter-image.tsx | 167 +++++++++++++++++++++++++++ 3 files changed, 350 insertions(+), 3 deletions(-) create mode 100644 packages/web/app/opengraph-image.tsx create mode 100644 packages/web/app/twitter-image.tsx diff --git a/packages/web/app/layout.tsx b/packages/web/app/layout.tsx index fdb3c8e75..5fcc996e3 100644 --- a/packages/web/app/layout.tsx +++ b/packages/web/app/layout.tsx @@ -1,4 +1,5 @@ import type { Metadata } from "next" +import type { JSX, ReactNode } from "react" import { GeistSans } from "geist/font/sans" import { GeistMono } from "geist/font/mono" import Script from "next/script" @@ -32,21 +33,31 @@ export const metadata: Metadata = { ], authors: [{ name: "Yeongyu Kim", url: "https://github.com/code-yeongyu" }], creator: "Yeongyu Kim", + alternates: { + canonical: "/", + languages: { + en: "/", + ko: "/ko", + ja: "/ja", + zh: "/zh", + }, + }, openGraph: { type: "website", locale: "en_US", + alternateLocale: ["ko_KR", "ja_JP", "zh_CN"], url: primarySiteUrl, siteName: "Oh My OpenAgent", title: "Oh My OpenAgent — The Best Agent Harness", description: "Meet Sisyphus: The batteries-included agent that codes like you. Multi-model orchestration, Team Mode, background agents, 54+ lifecycle hooks.", - images: [{ url: "/images/hero.webp", width: 1024, height: 683, alt: "Oh My OpenAgent" }], + // og:image is supplied by app/opengraph-image.tsx via Next.js file-based metadata convention. }, twitter: { card: "summary_large_image", title: "Oh My OpenAgent — The Best Agent Harness", description: "Meet Sisyphus: The batteries-included agent that codes like you.", - images: ["/images/hero.webp"], + // twitter:image is supplied by app/twitter-image.tsx via the file-based convention. }, robots: { index: true, @@ -54,6 +65,8 @@ export const metadata: Metadata = { googleBot: { index: true, follow: true, + "max-image-preview": "large", + "max-snippet": -1, }, }, } @@ -82,7 +95,7 @@ const jsonLd = { const gaMeasurementId = "G-S0QJFKT46Q" const gaTrackedDomain = "ohmyopenagent.com" -export default function RootLayout({ children }: { children: React.ReactNode }) { +export default function RootLayout({ children }: { readonly children: ReactNode }): JSX.Element { return ( { + const url = `https://fonts.googleapis.com/css2?family=${encodeURIComponent(family)}:wght@${weight}&display=swap` + const css = await (await fetch(url)).text() + const match = css.match(/src: url\((.+?)\) format\('(?:opentype|truetype)'\)/) + if (!match?.[1]) throw new Error(`Font URL not found for ${family} ${weight}`) + const fontResponse = await fetch(match[1]) + return fontResponse.arrayBuffer() +} + +export default async function OpenGraphImage(): Promise { + const [geistBold, geistRegular, geistMonoMedium] = await Promise.all([ + loadGoogleFont("Geist", 700), + loadGoogleFont("Geist", 400), + loadGoogleFont("Geist Mono", 500), + ]) + + return new ImageResponse( +
+ {/* Dotted grid background: flexbox-only, no grid */} +
+ + {/* Top-left: brand wordmark */} +
+
+
+ oh-my-openagent +
+
+ + {/* Center stack: headline + subtitle */} +
+
+ The Best Agent + Harness +
+
+ Sisyphus orchestrates the team. Type ultrawork. Done. +
+
+ + {/* Bottom row: install command + cursor */} +
+
+ $ + bunx oh-my-openagent install +
+
+ ▌ +
+
+
, + { + ...size, + fonts: [ + { name: "Geist", data: geistRegular, weight: 400, style: "normal" }, + { name: "Geist", data: geistBold, weight: 700, style: "normal" }, + { name: "Geist Mono", data: geistMonoMedium, weight: 500, style: "normal" }, + ], + }, + ) +} diff --git a/packages/web/app/twitter-image.tsx b/packages/web/app/twitter-image.tsx new file mode 100644 index 000000000..96df64adc --- /dev/null +++ b/packages/web/app/twitter-image.tsx @@ -0,0 +1,167 @@ +import { ImageResponse } from "next/og" + +export const alt = "oh-my-openagent — The Best Agent Harness (Twitter)" +export const size = { width: 1200, height: 630 } +export const contentType = "image/png" + +async function loadGoogleFont(family: string, weight: number): Promise { + const url = `https://fonts.googleapis.com/css2?family=${encodeURIComponent(family)}:wght@${weight}&display=swap` + const css = await (await fetch(url)).text() + const match = css.match(/src: url\((.+?)\) format\('(?:opentype|truetype)'\)/) + if (!match?.[1]) throw new Error(`Font URL not found for ${family} ${weight}`) + const fontResponse = await fetch(match[1]) + return fontResponse.arrayBuffer() +} + +export default async function TwitterImage(): Promise { + const [geistBold, geistRegular, geistMonoMedium] = await Promise.all([ + loadGoogleFont("Geist", 700), + loadGoogleFont("Geist", 400), + loadGoogleFont("Geist Mono", 500), + ]) + + return new ImageResponse( +
+ {/* Dotted grid background: flexbox-only, no grid */} +
+ + {/* Top-left: brand wordmark */} +
+
+
+ oh-my-openagent +
+
+ + {/* Center stack: headline + subtitle */} +
+
+ The Best Agent + Harness +
+
+ Sisyphus orchestrates the team. Type ultrawork. Done. +
+
+ + {/* Bottom row: install command + cursor */} +
+
+ $ + bunx oh-my-openagent install +
+
+ ▌ +
+
+
, + { + ...size, + fonts: [ + { name: "Geist", data: geistRegular, weight: 400, style: "normal" }, + { name: "Geist", data: geistBold, weight: 700, style: "normal" }, + { name: "Geist Mono", data: geistMonoMedium, weight: 500, style: "normal" }, + ], + }, + ) +}