mirror of
https://github.com/better-auth/better-auth.git
synced 2026-07-19 04:08:43 -05:00
docs: generate blog cover images from post titles (#10032)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
co-authored by
Copilot Autofix powered by AI
parent
f8f44e3d49
commit
2c8f925c48
@@ -0,0 +1,152 @@
|
||||
import { ImageResponse } from "@vercel/og";
|
||||
import * as z from "zod";
|
||||
|
||||
export const runtime = "edge";
|
||||
|
||||
const ogSchema = z.object({
|
||||
title: z.string().trim().min(1).max(200),
|
||||
date: z.string().trim().max(40).optional(),
|
||||
theme: z.enum(["light", "dark"]).default("dark"),
|
||||
});
|
||||
|
||||
const themes = {
|
||||
dark: {
|
||||
background: "radial-gradient(120% 120% at 80% 0%, #111111 0%, #000000 55%)",
|
||||
titleGradient:
|
||||
"linear-gradient(100deg, #fafafa 0%, #d4d4d4 55%, #8a8a8a 100%)",
|
||||
date: "#8f8f8f",
|
||||
outline: "rgba(255, 255, 255, 0.09)",
|
||||
speck: "rgba(255, 255, 255, 0.35)",
|
||||
},
|
||||
light: {
|
||||
background: "radial-gradient(120% 120% at 80% 0%, #ffffff 0%, #f1f1f0 55%)",
|
||||
titleGradient:
|
||||
"linear-gradient(100deg, #0a0a0a 0%, #2e2e2e 55%, #6f6f6f 100%)",
|
||||
date: "#7a7a7a",
|
||||
outline: "rgba(0, 0, 0, 0.09)",
|
||||
speck: "rgba(0, 0, 0, 0.3)",
|
||||
},
|
||||
} as const;
|
||||
|
||||
const specks: Array<{ top: string; left: string; size: number }> = [
|
||||
{ top: "6%", left: "43%", size: 3 },
|
||||
{ top: "13%", left: "61%", size: 2 },
|
||||
{ top: "4%", left: "76%", size: 2 },
|
||||
{ top: "27%", left: "52%", size: 2 },
|
||||
{ top: "9%", left: "30%", size: 2 },
|
||||
{ top: "31%", left: "88%", size: 3 },
|
||||
];
|
||||
|
||||
function titleFontSize(title: string) {
|
||||
if (title.length <= 40) return 72;
|
||||
if (title.length <= 80) return 62;
|
||||
if (title.length <= 120) return 54;
|
||||
return 46;
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const url = new URL(req.url);
|
||||
const { title, date, theme } = ogSchema.parse(
|
||||
Object.fromEntries(url.searchParams),
|
||||
);
|
||||
|
||||
const geist = await fetch(
|
||||
new URL("../../../assets/Geist.ttf", import.meta.url),
|
||||
).then((res) => res.arrayBuffer());
|
||||
|
||||
const colors = themes[theme];
|
||||
const trimmedTitle =
|
||||
title.length > 140 ? `${title.substring(0, 137)}...` : title;
|
||||
|
||||
return new ImageResponse(
|
||||
<div
|
||||
tw="flex w-full h-full relative"
|
||||
style={{
|
||||
background: colors.background,
|
||||
fontFamily: "Geist",
|
||||
}}
|
||||
>
|
||||
{specks.map((speck, i) => (
|
||||
<div
|
||||
key={i}
|
||||
tw="absolute"
|
||||
style={{
|
||||
top: speck.top,
|
||||
left: speck.left,
|
||||
width: `${speck.size}px`,
|
||||
height: `${speck.size}px`,
|
||||
borderRadius: "9999px",
|
||||
background: colors.speck,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Rotated rounded-rect outline, bleeding off the top-right corner */}
|
||||
<div
|
||||
tw="absolute"
|
||||
style={{
|
||||
width: "560px",
|
||||
height: "640px",
|
||||
top: "-180px",
|
||||
right: "-200px",
|
||||
border: `1.5px solid ${colors.outline}`,
|
||||
borderRadius: "56px",
|
||||
transform: "rotate(14deg)",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
tw="flex flex-col justify-center w-full h-full"
|
||||
style={{ padding: "0 240px 40px 96px" }}
|
||||
>
|
||||
<div
|
||||
tw="flex"
|
||||
style={{
|
||||
fontSize: `${titleFontSize(trimmedTitle)}px`,
|
||||
fontWeight: 500,
|
||||
letterSpacing: "-0.02em",
|
||||
lineHeight: 1.16,
|
||||
backgroundImage: colors.titleGradient,
|
||||
backgroundClip: "text",
|
||||
color: "transparent",
|
||||
}}
|
||||
>
|
||||
{trimmedTitle}
|
||||
</div>
|
||||
{date ? (
|
||||
<div
|
||||
tw="flex"
|
||||
style={{
|
||||
marginTop: "36px",
|
||||
fontSize: "30px",
|
||||
color: colors.date,
|
||||
}}
|
||||
>
|
||||
{date}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>,
|
||||
{
|
||||
width: 1200,
|
||||
height: 630,
|
||||
fonts: [
|
||||
{
|
||||
name: "Geist",
|
||||
data: geist,
|
||||
weight: 400,
|
||||
style: "normal",
|
||||
},
|
||||
],
|
||||
headers: {
|
||||
"Cache-Control":
|
||||
"public, max-age=86400, s-maxage=31536000, immutable",
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
console.log({ err });
|
||||
return new Response("Failed to generate the OG image", { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,69 @@ function formatDate(date: Date) {
|
||||
});
|
||||
}
|
||||
|
||||
function generatedCoverUrl(
|
||||
title: string,
|
||||
date: Date | undefined,
|
||||
theme: "light" | "dark",
|
||||
) {
|
||||
const params = new URLSearchParams();
|
||||
params.set("title", title);
|
||||
if (date) {
|
||||
params.set(
|
||||
"date",
|
||||
new Date(date).toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
}),
|
||||
);
|
||||
}
|
||||
params.set("theme", theme);
|
||||
return `/api/og-blog?${params.toString()}`;
|
||||
}
|
||||
|
||||
function BlogCover({
|
||||
title,
|
||||
date,
|
||||
image,
|
||||
}: {
|
||||
title: string;
|
||||
date: Date;
|
||||
image?: string;
|
||||
}) {
|
||||
if (image) {
|
||||
return (
|
||||
<Image
|
||||
src={image}
|
||||
alt={title}
|
||||
width={320}
|
||||
height={168}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{/* Generated cover, one per theme */}
|
||||
<img
|
||||
src={generatedCoverUrl(title, date, "light")}
|
||||
alt={title}
|
||||
width={320}
|
||||
height={168}
|
||||
className="w-full h-full object-cover dark:hidden"
|
||||
loading="lazy"
|
||||
/>
|
||||
<img
|
||||
src={generatedCoverUrl(title, date, "dark")}
|
||||
alt={title}
|
||||
width={320}
|
||||
height={168}
|
||||
className="w-full h-full object-cover hidden dark:block"
|
||||
loading="lazy"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function BlogList() {
|
||||
const posts = blogs
|
||||
.getPages()
|
||||
@@ -49,17 +112,13 @@ function BlogList() {
|
||||
className="group block border-b border-dashed border-foreground/[0.06] px-5 sm:px-6 lg:px-8 py-5 transition-colors hover:bg-foreground/[0.02]"
|
||||
>
|
||||
<div className="flex gap-5 items-center">
|
||||
{post.data?.image && (
|
||||
<div className="shrink-0 w-56 aspect-[1200/630] overflow-hidden border border-foreground/[0.06] hidden sm:block">
|
||||
<Image
|
||||
src={post.data.image}
|
||||
alt={post.data.title}
|
||||
width={320}
|
||||
height={192}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="shrink-0 w-56 aspect-[1200/630] overflow-hidden border border-foreground/[0.06] hidden sm:block">
|
||||
<BlogCover
|
||||
title={post.data.title}
|
||||
date={post.data.date}
|
||||
image={post.data.image}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-lg font-medium tracking-tight text-neutral-800 dark:text-neutral-200 group-hover:text-neutral-950 dark:group-hover:text-white transition-colors">
|
||||
{post.data.title}
|
||||
@@ -253,22 +312,8 @@ export async function generateMetadata({
|
||||
if (!page || page.data.draft) return notFound();
|
||||
const { title, description, image, date } = page.data;
|
||||
|
||||
const ogSearchParams = new URLSearchParams();
|
||||
ogSearchParams.set("heading", title);
|
||||
if (description) ogSearchParams.set("description", description);
|
||||
if (date) {
|
||||
ogSearchParams.set(
|
||||
"date",
|
||||
new Date(date).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
}),
|
||||
);
|
||||
}
|
||||
const ogUrl = `/api/og-release?${ogSearchParams.toString()}`;
|
||||
|
||||
const ogImage = image || ogUrl;
|
||||
// Social cards have no theme preference; always use the dark variant.
|
||||
const ogImage = image || generatedCoverUrl(title, date, "dark");
|
||||
|
||||
return createMetadata({
|
||||
title,
|
||||
|
||||
@@ -5,7 +5,6 @@ date: 2025-08-25
|
||||
author:
|
||||
name: "Dagmawi Esayas"
|
||||
avatar: ""
|
||||
image: "https://docs.better-auth.com/blogs/supabase-ps.png"
|
||||
tags: ["migration", "guides", "supabase", "planetscale"]
|
||||
---
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ author:
|
||||
name: "Bereket Engida"
|
||||
avatar: "/avatars/beka.jpg"
|
||||
twitter: "bekacru"
|
||||
image: "https://docs.better-auth.com/release-og/1-3.png"
|
||||
tags: ["1.3", "authentication", "oidc", "mcp", "sso", "organization"]
|
||||
---
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ author:
|
||||
name: "Bereket Engida"
|
||||
avatar: "/avatars/beka.jpg"
|
||||
twitter: "bekacru"
|
||||
image: "https://docs.better-auth.com/release-og/1-4.png"
|
||||
tags: ["1.4", "stateless sessions", "SCIM", "database indexing", "database join", "SSO domain verification", "uuid", "device authorization"]
|
||||
---
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ author:
|
||||
name: "Alex Yang"
|
||||
avatar: "/avatars/alex.png"
|
||||
twitter: "himseIf_65"
|
||||
image: "https://docs.better-auth.com/release-og/v1.5-release.png"
|
||||
tags: ["1.5", "CLI", "MCP", "oauth-provider", "electron", "i18n", "adapters", "cloudflare", "d1", "stripe", "SSO", "SCIM", "test-utils"]
|
||||
---
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ date: 2026-04-07
|
||||
author:
|
||||
name: "Taesu Kim"
|
||||
avatar: ""
|
||||
image: "/release-og/1-6.png"
|
||||
tags: ["1.6", "OpenTelemetry", "passkey", "performance", "release workflow"]
|
||||
---
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ author:
|
||||
name: "Bereket Engida"
|
||||
avatar: "/avatars/beka.jpg"
|
||||
twitter: "bekacru"
|
||||
image: "https://docs.better-auth.com/blogs/authjs-joins.png"
|
||||
tags: ["seed round", "authentication", "funding"]
|
||||
---
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ date: 2026-06-02
|
||||
author:
|
||||
name: "Better Auth Maintainers"
|
||||
avatar: ""
|
||||
image: "/blogs/security-june-2026.png"
|
||||
tags: ["security", "advisories", "release"]
|
||||
---
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ author:
|
||||
name: "Bereket Engida"
|
||||
avatar: "/avatars/beka.jpg"
|
||||
twitter: "bekacru"
|
||||
image: "https://docs.better-auth.com/blogs/seed-round.png"
|
||||
tags: ["seed round", "authentication", "funding"]
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user