diff --git a/.github/workflows/deploy-cloudflare.yml b/.github/workflows/deploy-cloudflare.yml
index 1d55ad1..0f1f9b5 100644
--- a/.github/workflows/deploy-cloudflare.yml
+++ b/.github/workflows/deploy-cloudflare.yml
@@ -61,7 +61,9 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Build OpenNext Worker
- run: pnpm exec opennextjs-cloudflare build
+ run: |
+ pnpm og:build
+ pnpm exec opennextjs-cloudflare build
- name: Upload preview version
id: upload
diff --git a/app/layout.tsx b/app/layout.tsx
index a5fd592..88cf4da 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -10,12 +10,14 @@ import { ThemeProvider } from "@/components/theme-provider"
import { TooltipProvider } from "@/components/ui/tooltip"
import { Toaster } from "sonner"
import {
+ OG_IMAGE_SIZE,
SITE_AUTHOR,
SITE_DESCRIPTION,
SITE_KEYWORDS,
SITE_NAME,
SITE_TITLE,
SITE_URL,
+ docsOgImagePath,
rootJsonLd,
} from "@/lib/seo"
import { cn } from "@/lib/utils"
@@ -66,11 +68,21 @@ export const metadata: Metadata = {
siteName: SITE_NAME,
title: SITE_TITLE,
description: SITE_DESCRIPTION,
+ images: [
+ {
+ url: docsOgImagePath(),
+ width: OG_IMAGE_SIZE.width,
+ height: OG_IMAGE_SIZE.height,
+ alt: SITE_TITLE,
+ type: "image/png",
+ },
+ ],
},
twitter: {
card: "summary_large_image",
title: SITE_TITLE,
description: SITE_DESCRIPTION,
+ images: [docsOgImagePath()],
},
appleWebApp: {
title: SITE_NAME,
diff --git a/app/og/docs/[...slug]/route.tsx b/app/og/docs/[...slug]/route.tsx
deleted file mode 100644
index eed7b8a..0000000
--- a/app/og/docs/[...slug]/route.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-import { notFound } from "next/navigation"
-
-import { renderDocsOgImage } from "@/lib/og-image"
-import { docsOgImageSegments, SITE_NAME, SITE_TAGLINE } from "@/lib/seo"
-import { source } from "@/lib/source"
-
-export const dynamic = "force-static"
-export const dynamicParams = false
-
-export function generateStaticParams() {
- return source.generateParams().map((param) => ({
- slug: docsOgImageSegments(param.slug),
- }))
-}
-
-export async function GET(
- _request: Request,
- { params }: { params: Promise<{ slug: string[] }> }
-) {
- const { slug } = await params
- const pageSlug = slug.at(-1) === "image.png" ? slug.slice(0, -1) : slug
- const page = source.getPage(pageSlug.length ? pageSlug : undefined)
-
- if (!page && pageSlug.length) notFound()
-
- const isIndex = pageSlug.length === 0
-
- return renderDocsOgImage({
- title: isIndex ? SITE_NAME : (page?.data.title ?? SITE_NAME),
- description: isIndex
- ? SITE_TAGLINE
- : (page?.data.description ?? SITE_TAGLINE),
- })
-}
diff --git a/lib/og-image.tsx b/lib/og-image.tsx
deleted file mode 100644
index f9a4cc1..0000000
--- a/lib/og-image.tsx
+++ /dev/null
@@ -1,106 +0,0 @@
-import { ImageResponse } from "next/og"
-import { readFile } from "node:fs/promises"
-import { join } from "node:path"
-
-import { OG_IMAGE_SIZE, SITE_NAME } from "@/lib/seo"
-
-export async function renderDocsOgImage({
- title,
- description,
-}: {
- title: string
- description: string
-}) {
- const logo = await readFile(join(process.cwd(), "app/apple-icon.png"))
- const logoSrc = `data:image/png;base64,${logo.toString("base64")}`
-
- return new ImageResponse(
-
-
-
- {/* ImageResponse only supports
![]()
, not next/image */}
- {/* eslint-disable-next-line @next/next/no-img-element -- ImageResponse */}
-

-
- {SITE_NAME}
-
-
-
- 23rd.dev
-
-
-
-
28 ? 56 : 64,
- fontWeight: 600,
- letterSpacing: "-0.04em",
- lineHeight: 1.1,
- }}
- >
- {title}
-
-
- {description}
-
-
-
- shadcn registry
-
-
,
- { ...OG_IMAGE_SIZE }
- )
-}
diff --git a/lib/og-paths.mjs b/lib/og-paths.mjs
new file mode 100644
index 0000000..1bc8624
--- /dev/null
+++ b/lib/og-paths.mjs
@@ -0,0 +1,16 @@
+import { join } from "node:path"
+
+export const OG_IMAGE_SIZE = { width: 1200, height: 630 }
+
+/** Catch-alls cannot host `opengraph-image`; pages use static `/og/docs/.../image.png`. */
+export function docsOgImageSegments(slug) {
+ return [...(slug ?? []), "image.png"]
+}
+
+export function docsOgImagePath(slug) {
+ return `/og/docs/${docsOgImageSegments(slug).join("/")}`
+}
+
+export function docsOgPublicFile(slug, root = process.cwd()) {
+ return join(root, "public", ...docsOgImagePath(slug).split("/").filter(Boolean))
+}
diff --git a/lib/seo.ts b/lib/seo.ts
index 2cefb87..4e74dd0 100644
--- a/lib/seo.ts
+++ b/lib/seo.ts
@@ -52,7 +52,7 @@ export function isComponentPage(slug?: string[]): boolean {
export const OG_IMAGE_SIZE = { width: 1200, height: 630 } as const
-/** Catch-alls cannot host `opengraph-image`; pages point at `/og/docs/.../image.png`. */
+/** Catch-alls cannot host `opengraph-image`; pages use static `/og/docs/.../image.png`. */
export function docsOgImageSegments(slug?: string[]): string[] {
return [...(slug ?? []), "image.png"]
}
@@ -85,6 +85,7 @@ export function buildPageMetadata({
width: OG_IMAGE_SIZE.width,
height: OG_IMAGE_SIZE.height,
alt: ogTitle,
+ type: "image/png",
}
return {
diff --git a/package.json b/package.json
index f3ded19..b930423 100644
--- a/package.json
+++ b/package.json
@@ -6,10 +6,11 @@
"packageManager": "pnpm@11.20.0",
"scripts": {
"dev": "next dev",
- "build": "pnpm registry:build && next build",
+ "build": "pnpm registry:build && pnpm og:build && next build",
"start": "next start",
- "preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
- "cf:deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy",
+ "preview": "pnpm og:build && opennextjs-cloudflare build && opennextjs-cloudflare preview",
+ "cf:deploy": "pnpm og:build && opennextjs-cloudflare build && opennextjs-cloudflare deploy",
+ "og:build": "node scripts/build-og-images.mjs",
"cf:upload": "opennextjs-cloudflare build && opennextjs-cloudflare upload",
"cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts",
"lint": "eslint",
diff --git a/public/og/docs/components/ascii-fluid/image.png b/public/og/docs/components/ascii-fluid/image.png
new file mode 100644
index 0000000..7bea116
Binary files /dev/null and b/public/og/docs/components/ascii-fluid/image.png differ
diff --git a/public/og/docs/components/ascii-logo/image.png b/public/og/docs/components/ascii-logo/image.png
new file mode 100644
index 0000000..ebbac6f
Binary files /dev/null and b/public/og/docs/components/ascii-logo/image.png differ
diff --git a/public/og/docs/components/dithered-404/image.png b/public/og/docs/components/dithered-404/image.png
new file mode 100644
index 0000000..09c4b6a
Binary files /dev/null and b/public/og/docs/components/dithered-404/image.png differ
diff --git a/public/og/docs/components/folio/image.png b/public/og/docs/components/folio/image.png
new file mode 100644
index 0000000..b78c54b
Binary files /dev/null and b/public/og/docs/components/folio/image.png differ
diff --git a/public/og/docs/components/gooey-color-picker/image.png b/public/og/docs/components/gooey-color-picker/image.png
new file mode 100644
index 0000000..1352582
Binary files /dev/null and b/public/og/docs/components/gooey-color-picker/image.png differ
diff --git a/public/og/docs/components/live-orb/image.png b/public/og/docs/components/live-orb/image.png
new file mode 100644
index 0000000..8ceb176
Binary files /dev/null and b/public/og/docs/components/live-orb/image.png differ
diff --git a/public/og/docs/components/logo-burst/image.png b/public/og/docs/components/logo-burst/image.png
new file mode 100644
index 0000000..eba3d3a
Binary files /dev/null and b/public/og/docs/components/logo-burst/image.png differ
diff --git a/public/og/docs/components/phosphor-score/image.png b/public/og/docs/components/phosphor-score/image.png
new file mode 100644
index 0000000..8112780
Binary files /dev/null and b/public/og/docs/components/phosphor-score/image.png differ
diff --git a/public/og/docs/components/radiant-lines/image.png b/public/og/docs/components/radiant-lines/image.png
new file mode 100644
index 0000000..aaa5f39
Binary files /dev/null and b/public/og/docs/components/radiant-lines/image.png differ
diff --git a/public/og/docs/components/shader-fire/image.png b/public/og/docs/components/shader-fire/image.png
new file mode 100644
index 0000000..e6942cb
Binary files /dev/null and b/public/og/docs/components/shader-fire/image.png differ
diff --git a/public/og/docs/components/shader-gradient/image.png b/public/og/docs/components/shader-gradient/image.png
new file mode 100644
index 0000000..787b0bc
Binary files /dev/null and b/public/og/docs/components/shader-gradient/image.png differ
diff --git a/public/og/docs/components/stretchy-footer/image.png b/public/og/docs/components/stretchy-footer/image.png
new file mode 100644
index 0000000..46c4ea2
Binary files /dev/null and b/public/og/docs/components/stretchy-footer/image.png differ
diff --git a/public/og/docs/components/tangle-footer/image.png b/public/og/docs/components/tangle-footer/image.png
new file mode 100644
index 0000000..acba35d
Binary files /dev/null and b/public/og/docs/components/tangle-footer/image.png differ
diff --git a/public/og/docs/getting-started/image.png b/public/og/docs/getting-started/image.png
new file mode 100644
index 0000000..a36feb1
Binary files /dev/null and b/public/og/docs/getting-started/image.png differ
diff --git a/public/og/docs/image.png b/public/og/docs/image.png
new file mode 100644
index 0000000..88ffbcd
Binary files /dev/null and b/public/og/docs/image.png differ
diff --git a/scripts/build-og-images.mjs b/scripts/build-og-images.mjs
new file mode 100644
index 0000000..3fcabf7
--- /dev/null
+++ b/scripts/build-og-images.mjs
@@ -0,0 +1,7 @@
+import { buildOgImages } from "./og-lib.mjs"
+
+const files = await buildOgImages()
+console.log(`OG images written (${files.length})`)
+for (const { path } of files) {
+ console.log(` ${path}`)
+}
diff --git a/scripts/og-lib.mjs b/scripts/og-lib.mjs
new file mode 100644
index 0000000..4c25348
--- /dev/null
+++ b/scripts/og-lib.mjs
@@ -0,0 +1,207 @@
+import { createElement as h } from "react"
+import { ImageResponse } from "next/og.js"
+import { mkdir, readdir, readFile, writeFile } from "node:fs/promises"
+import { dirname, join, relative, sep } from "node:path"
+
+import {
+ docsOgImagePath,
+ docsOgPublicFile,
+ OG_IMAGE_SIZE,
+} from "../lib/og-paths.mjs"
+
+export const CONTENT_DOCS = "content/docs"
+
+const INDEX_TITLE = "23rd"
+const INDEX_TAGLINE = "Opinionated UI components for shippers"
+const SITE_NAME = "23rd"
+
+export function parseFrontmatter(source) {
+ const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---/)
+ if (!match) return {}
+
+ const data = {}
+ for (const line of match[1].split(/\r?\n/)) {
+ const idx = line.indexOf(":")
+ if (idx === -1) continue
+ const key = line.slice(0, idx).trim()
+ let value = line.slice(idx + 1).trim()
+ if (
+ (value.startsWith('"') && value.endsWith('"')) ||
+ (value.startsWith("'") && value.endsWith("'"))
+ ) {
+ value = value.slice(1, -1)
+ }
+ data[key] = value
+ }
+ return data
+}
+
+export function slugFromDocsFile(filePath, contentRoot) {
+ const rel = relative(contentRoot, filePath).split(sep).join("/")
+ const withoutExt = rel.replace(/\.mdx$/i, "")
+ if (withoutExt === "index") return []
+ return withoutExt.split("/").filter(Boolean)
+}
+
+async function walkMdx(dir, files = []) {
+ const entries = await readdir(dir, { withFileTypes: true })
+ for (const entry of entries) {
+ const path = join(dir, entry.name)
+ if (entry.isDirectory()) {
+ await walkMdx(path, files)
+ continue
+ }
+ if (entry.isFile() && entry.name.endsWith(".mdx")) files.push(path)
+ }
+ return files
+}
+
+export async function listDocsOgPages(root = process.cwd()) {
+ const contentRoot = join(root, CONTENT_DOCS)
+ const files = (await walkMdx(contentRoot)).sort()
+ const pages = []
+
+ for (const file of files) {
+ const source = await readFile(file, "utf8")
+ const data = parseFrontmatter(source)
+ const slug = slugFromDocsFile(file, contentRoot)
+ const isIndex = slug.length === 0
+ pages.push({
+ file,
+ slug,
+ title: isIndex ? INDEX_TITLE : (data.title ?? SITE_NAME),
+ description: isIndex
+ ? INDEX_TAGLINE
+ : (data.description ?? INDEX_TAGLINE),
+ })
+ }
+
+ return pages
+}
+
+export async function loadOgLogoSrc(root = process.cwd()) {
+ const logo = await readFile(join(root, "app/apple-icon.png"))
+ return `data:image/png;base64,${logo.toString("base64")}`
+}
+
+function flex(style, children) {
+ return h("div", { style: { display: "flex", ...style } }, children)
+}
+
+/** Satori drops space characters in flex text; gap words with margin instead. */
+export function splitOgWords(value) {
+ return String(value).split(/\s+/).filter(Boolean)
+}
+
+function text(style, value) {
+ const words = splitOgWords(value)
+ const fontSize = Number(style.fontSize) || 24
+ const gap = Math.round(fontSize * 0.3)
+ return flex(
+ { flexWrap: "wrap", ...style },
+ words.map((word, index) =>
+ h(
+ "div",
+ {
+ style: {
+ display: "flex",
+ marginRight: index === words.length - 1 ? 0 : gap,
+ },
+ },
+ word
+ )
+ )
+ )
+}
+
+export function renderDocsOgImage({ title, description, logoSrc }) {
+ return new ImageResponse(
+ h(
+ "div",
+ {
+ style: {
+ width: "100%",
+ height: "100%",
+ display: "flex",
+ flexDirection: "column",
+ justifyContent: "space-between",
+ background: "#0a0a0a",
+ color: "#fafafa",
+ padding: 72,
+ },
+ },
+ flex(
+ { alignItems: "center", justifyContent: "space-between" },
+ [
+ flex({ alignItems: "center" }, [
+ h("img", {
+ src: logoSrc,
+ width: 56,
+ height: 56,
+ alt: "",
+ style: { borderRadius: 8 },
+ }),
+ text(
+ {
+ marginLeft: 16,
+ fontSize: 28,
+ fontWeight: 600,
+ letterSpacing: "-0.04em",
+ },
+ SITE_NAME
+ ),
+ ]),
+ text({ fontSize: 22, color: "#737373" }, "23rd.dev"),
+ ]
+ ),
+ flex({ flexDirection: "column" }, [
+ text(
+ {
+ fontSize: title.length > 28 ? 56 : 64,
+ fontWeight: 600,
+ letterSpacing: "-0.04em",
+ lineHeight: 1.1,
+ },
+ title
+ ),
+ text(
+ {
+ marginTop: 20,
+ fontSize: 26,
+ color: "#a3a3a3",
+ lineHeight: 1.35,
+ maxWidth: 980,
+ },
+ description
+ ),
+ ]),
+ text({ fontSize: 20, color: "#737373" }, "shadcn registry")
+ ),
+ { ...OG_IMAGE_SIZE }
+ )
+}
+
+export async function renderDocsOgPng(page, logoSrc) {
+ const response = renderDocsOgImage({
+ title: page.title,
+ description: page.description,
+ logoSrc,
+ })
+ return Buffer.from(await response.arrayBuffer())
+}
+
+export async function buildOgImages(root = process.cwd()) {
+ const pages = await listDocsOgPages(root)
+ const logoSrc = await loadOgLogoSrc(root)
+ const written = []
+
+ for (const page of pages) {
+ const png = await renderDocsOgPng(page, logoSrc)
+ const out = docsOgPublicFile(page.slug, root)
+ await mkdir(dirname(out), { recursive: true })
+ await writeFile(out, png)
+ written.push({ file: out, path: docsOgImagePath(page.slug), page })
+ }
+
+ return written
+}
diff --git a/tests/og.test.mjs b/tests/og.test.mjs
new file mode 100644
index 0000000..9fc4d65
--- /dev/null
+++ b/tests/og.test.mjs
@@ -0,0 +1,129 @@
+import { test } from "node:test"
+import assert from "node:assert/strict"
+import { readFileSync, existsSync, readdirSync } from "node:fs"
+import { join } from "node:path"
+
+import { ROOT } from "../scripts/registry-lib.mjs"
+import {
+ docsOgImagePath,
+ docsOgImageSegments,
+ docsOgPublicFile,
+ OG_IMAGE_SIZE,
+} from "../lib/og-paths.mjs"
+import {
+ listDocsOgPages,
+ parseFrontmatter,
+ slugFromDocsFile,
+ splitOgWords,
+} from "../scripts/og-lib.mjs"
+
+const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
+
+function pngSize(buf) {
+ assert.ok(buf.subarray(0, 8).equals(PNG_MAGIC), "expected PNG magic")
+ return {
+ width: buf.readUInt32BE(16),
+ height: buf.readUInt32BE(20),
+ }
+}
+
+function walkFiles(dir, files = []) {
+ if (!existsSync(dir)) return files
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
+ const path = join(dir, entry.name)
+ if (entry.isDirectory()) walkFiles(path, files)
+ else files.push(path)
+ }
+ return files
+}
+
+test("docs OG paths put image.png after the page slug", () => {
+ assert.deepEqual(docsOgImageSegments(), ["image.png"])
+ assert.deepEqual(docsOgImageSegments(["getting-started"]), [
+ "getting-started",
+ "image.png",
+ ])
+ assert.equal(docsOgImagePath(), "/og/docs/image.png")
+ assert.equal(
+ docsOgImagePath(["components", "ascii-logo"]),
+ "/og/docs/components/ascii-logo/image.png"
+ )
+ assert.equal(
+ docsOgPublicFile(["components", "ascii-logo"], ROOT),
+ join(ROOT, "public/og/docs/components/ascii-logo/image.png")
+ )
+})
+
+test("lib/seo.ts keeps the same OG path convention as og-paths", () => {
+ const source = readFileSync(join(ROOT, "lib/seo.ts"), "utf8")
+ assert.match(source, /\/og\/docs\/\$\{docsOgImageSegments\(slug\)\.join\("\/"\)\}/)
+ assert.match(source, /width:\s*1200/)
+ assert.match(source, /height:\s*630/)
+ assert.equal(OG_IMAGE_SIZE.width, 1200)
+ assert.equal(OG_IMAGE_SIZE.height, 630)
+})
+
+test("slugFromDocsFile maps index.mdx to the docs root card", () => {
+ const contentRoot = join(ROOT, "content/docs")
+ assert.deepEqual(slugFromDocsFile(join(contentRoot, "index.mdx"), contentRoot), [])
+ assert.deepEqual(
+ slugFromDocsFile(join(contentRoot, "getting-started.mdx"), contentRoot),
+ ["getting-started"]
+ )
+ assert.deepEqual(
+ slugFromDocsFile(
+ join(contentRoot, "components/ascii-logo.mdx"),
+ contentRoot
+ ),
+ ["components", "ascii-logo"]
+ )
+})
+
+test("splitOgWords keeps every word so Satori can gap them", () => {
+ assert.deepEqual(splitOgWords("ASCII Logo"), ["ASCII", "Logo"])
+ assert.deepEqual(splitOgWords("shadcn registry"), ["shadcn", "registry"])
+ assert.deepEqual(splitOgWords(" one two "), ["one", "two"])
+})
+
+test("parseFrontmatter reads title and description", () => {
+ const data = parseFrontmatter(
+ "---\ntitle: ASCII Logo\ndescription: Glyphs scatter.\n---\n\n# Hello\n"
+ )
+ assert.deepEqual(data, {
+ title: "ASCII Logo",
+ description: "Glyphs scatter.",
+ })
+})
+
+test("every docs page has a static PNG OG card", async () => {
+ const pages = await listDocsOgPages(ROOT)
+ assert.ok(pages.length >= 10, "expected a docs page per MDX file")
+ assert.ok(
+ pages.some((page) => page.slug.length === 0 && page.title === "23rd")
+ )
+ assert.ok(
+ pages.some(
+ (page) =>
+ page.slug.join("/") === "components/ascii-logo" &&
+ page.title === "ASCII Logo"
+ )
+ )
+
+ for (const page of pages) {
+ const file = docsOgPublicFile(page.slug, ROOT)
+ assert.ok(existsSync(file), `missing OG image: ${file}`)
+ const buf = readFileSync(file)
+ assert.deepEqual(pngSize(buf), OG_IMAGE_SIZE)
+ assert.ok(buf.length > 8_000, `${file} looks too small to be a real card`)
+ }
+})
+
+test("the app no longer ships a next/og route that Cloudflare would crash", () => {
+ const appFiles = walkFiles(join(ROOT, "app"))
+ const hits = appFiles.filter((file) => {
+ if (!/\.(ts|tsx|js|jsx|mjs)$/.test(file)) return false
+ return readFileSync(file, "utf8").includes("next/og")
+ })
+ assert.deepEqual(hits, [])
+ assert.equal(existsSync(join(ROOT, "app/og/docs/[...slug]/route.tsx")), false)
+})