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) +})