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 <head>.
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import type { Metadata } from "next"
|
import type { Metadata } from "next"
|
||||||
|
import type { JSX, ReactNode } from "react"
|
||||||
import { GeistSans } from "geist/font/sans"
|
import { GeistSans } from "geist/font/sans"
|
||||||
import { GeistMono } from "geist/font/mono"
|
import { GeistMono } from "geist/font/mono"
|
||||||
import Script from "next/script"
|
import Script from "next/script"
|
||||||
@@ -32,21 +33,31 @@ export const metadata: Metadata = {
|
|||||||
],
|
],
|
||||||
authors: [{ name: "Yeongyu Kim", url: "https://github.com/code-yeongyu" }],
|
authors: [{ name: "Yeongyu Kim", url: "https://github.com/code-yeongyu" }],
|
||||||
creator: "Yeongyu Kim",
|
creator: "Yeongyu Kim",
|
||||||
|
alternates: {
|
||||||
|
canonical: "/",
|
||||||
|
languages: {
|
||||||
|
en: "/",
|
||||||
|
ko: "/ko",
|
||||||
|
ja: "/ja",
|
||||||
|
zh: "/zh",
|
||||||
|
},
|
||||||
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
type: "website",
|
type: "website",
|
||||||
locale: "en_US",
|
locale: "en_US",
|
||||||
|
alternateLocale: ["ko_KR", "ja_JP", "zh_CN"],
|
||||||
url: primarySiteUrl,
|
url: primarySiteUrl,
|
||||||
siteName: "Oh My OpenAgent",
|
siteName: "Oh My OpenAgent",
|
||||||
title: "Oh My OpenAgent — The Best Agent Harness",
|
title: "Oh My OpenAgent — The Best Agent Harness",
|
||||||
description:
|
description:
|
||||||
"Meet Sisyphus: The batteries-included agent that codes like you. Multi-model orchestration, Team Mode, background agents, 54+ lifecycle hooks.",
|
"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: {
|
twitter: {
|
||||||
card: "summary_large_image",
|
card: "summary_large_image",
|
||||||
title: "Oh My OpenAgent — The Best Agent Harness",
|
title: "Oh My OpenAgent — The Best Agent Harness",
|
||||||
description: "Meet Sisyphus: The batteries-included agent that codes like you.",
|
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: {
|
robots: {
|
||||||
index: true,
|
index: true,
|
||||||
@@ -54,6 +65,8 @@ export const metadata: Metadata = {
|
|||||||
googleBot: {
|
googleBot: {
|
||||||
index: true,
|
index: true,
|
||||||
follow: true,
|
follow: true,
|
||||||
|
"max-image-preview": "large",
|
||||||
|
"max-snippet": -1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -82,7 +95,7 @@ const jsonLd = {
|
|||||||
const gaMeasurementId = "G-S0QJFKT46Q"
|
const gaMeasurementId = "G-S0QJFKT46Q"
|
||||||
const gaTrackedDomain = "ohmyopenagent.com"
|
const gaTrackedDomain = "ohmyopenagent.com"
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default function RootLayout({ children }: { readonly children: ReactNode }): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
lang="en"
|
lang="en"
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { ImageResponse } from "next/og"
|
||||||
|
|
||||||
|
export const alt = "oh-my-openagent — The Best Agent Harness"
|
||||||
|
export const size = { width: 1200, height: 630 }
|
||||||
|
export const contentType = "image/png"
|
||||||
|
|
||||||
|
async function loadGoogleFont(family: string, weight: number): Promise<ArrayBuffer> {
|
||||||
|
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<ImageResponse> {
|
||||||
|
const [geistBold, geistRegular, geistMonoMedium] = await Promise.all([
|
||||||
|
loadGoogleFont("Geist", 700),
|
||||||
|
loadGoogleFont("Geist", 400),
|
||||||
|
loadGoogleFont("Geist Mono", 500),
|
||||||
|
])
|
||||||
|
|
||||||
|
return new ImageResponse(
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
background: "#0a0a0a",
|
||||||
|
padding: "64px 80px",
|
||||||
|
position: "relative",
|
||||||
|
fontFamily: "Geist",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Dotted grid background: flexbox-only, no grid */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
backgroundImage: "radial-gradient(rgba(255,255,255,0.04) 1px, transparent 1px)",
|
||||||
|
backgroundSize: "32px 32px",
|
||||||
|
display: "flex",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Top-left: brand wordmark */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 12,
|
||||||
|
position: "relative",
|
||||||
|
zIndex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
background: "#00d4ff",
|
||||||
|
borderRadius: 2,
|
||||||
|
display: "flex",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: "Geist Mono",
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "#00d4ff",
|
||||||
|
letterSpacing: "-0.01em",
|
||||||
|
display: "flex",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
oh-my-openagent
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Center stack: headline + subtitle */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 28,
|
||||||
|
position: "relative",
|
||||||
|
zIndex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 88,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "#ededed",
|
||||||
|
letterSpacing: "-0.04em",
|
||||||
|
lineHeight: 1.0,
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ display: "flex" }}>The Best Agent</span>
|
||||||
|
<span style={{ display: "flex", color: "#00d4ff", marginLeft: 24 }}>Harness</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: 400,
|
||||||
|
color: "#a1a1a1",
|
||||||
|
lineHeight: 1.4,
|
||||||
|
maxWidth: 900,
|
||||||
|
display: "flex",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Sisyphus orchestrates the team. Type ultrawork. Done.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom row: install command + cursor */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
position: "relative",
|
||||||
|
zIndex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
padding: "12px 20px",
|
||||||
|
background: "rgba(255,255,255,0.04)",
|
||||||
|
border: "1px solid rgba(255,255,255,0.08)",
|
||||||
|
borderRadius: 9999,
|
||||||
|
fontFamily: "Geist Mono",
|
||||||
|
fontSize: 22,
|
||||||
|
color: "#d4d4d8",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: "#00d4ff", display: "flex" }}>$</span>
|
||||||
|
<span style={{ display: "flex" }}>bunx oh-my-openagent install</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: "Geist Mono",
|
||||||
|
fontSize: 36,
|
||||||
|
color: "#00d4ff",
|
||||||
|
display: "flex",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
▌
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
{
|
||||||
|
...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" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<ArrayBuffer> {
|
||||||
|
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<ImageResponse> {
|
||||||
|
const [geistBold, geistRegular, geistMonoMedium] = await Promise.all([
|
||||||
|
loadGoogleFont("Geist", 700),
|
||||||
|
loadGoogleFont("Geist", 400),
|
||||||
|
loadGoogleFont("Geist Mono", 500),
|
||||||
|
])
|
||||||
|
|
||||||
|
return new ImageResponse(
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
background: "#0a0a0a",
|
||||||
|
padding: "64px 80px",
|
||||||
|
position: "relative",
|
||||||
|
fontFamily: "Geist",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Dotted grid background: flexbox-only, no grid */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
backgroundImage: "radial-gradient(rgba(255,255,255,0.04) 1px, transparent 1px)",
|
||||||
|
backgroundSize: "32px 32px",
|
||||||
|
display: "flex",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Top-left: brand wordmark */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 12,
|
||||||
|
position: "relative",
|
||||||
|
zIndex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
background: "#00d4ff",
|
||||||
|
borderRadius: 2,
|
||||||
|
display: "flex",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: "Geist Mono",
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "#00d4ff",
|
||||||
|
letterSpacing: "-0.01em",
|
||||||
|
display: "flex",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
oh-my-openagent
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Center stack: headline + subtitle */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 28,
|
||||||
|
position: "relative",
|
||||||
|
zIndex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 88,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "#ededed",
|
||||||
|
letterSpacing: "-0.04em",
|
||||||
|
lineHeight: 1.0,
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ display: "flex" }}>The Best Agent</span>
|
||||||
|
<span style={{ display: "flex", color: "#00d4ff", marginLeft: 24 }}>Harness</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: 400,
|
||||||
|
color: "#a1a1a1",
|
||||||
|
lineHeight: 1.4,
|
||||||
|
maxWidth: 900,
|
||||||
|
display: "flex",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Sisyphus orchestrates the team. Type ultrawork. Done.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom row: install command + cursor */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
position: "relative",
|
||||||
|
zIndex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
padding: "12px 20px",
|
||||||
|
background: "rgba(255,255,255,0.04)",
|
||||||
|
border: "1px solid rgba(255,255,255,0.08)",
|
||||||
|
borderRadius: 9999,
|
||||||
|
fontFamily: "Geist Mono",
|
||||||
|
fontSize: 22,
|
||||||
|
color: "#d4d4d8",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: "#00d4ff", display: "flex" }}>$</span>
|
||||||
|
<span style={{ display: "flex" }}>bunx oh-my-openagent install</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: "Geist Mono",
|
||||||
|
fontSize: 36,
|
||||||
|
color: "#00d4ff",
|
||||||
|
display: "flex",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
▌
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
{
|
||||||
|
...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" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user