feat: implement dynamic Open Graph image generation for blog posts

This commit is contained in:
Mauricio Siu
2025-03-02 17:40:04 -06:00
parent b19d453682
commit 265af27a1c
9 changed files with 641 additions and 24 deletions

View File

@@ -0,0 +1,67 @@
import { getPost } from "@/lib/ghost";
import { generateOGImage } from "@/lib/og-image";
import type { NextRequest } from "next/server";
export async function GET(
request: NextRequest,
{ params }: { params: { locale: string } },
) {
try {
const { searchParams } = new URL(request.url);
const slug = searchParams.get("slug");
console.log(
"Generating OG image for slug:",
slug,
"locale:",
params.locale,
);
if (!slug) {
console.error("Missing slug parameter");
return new Response("Missing slug parameter", { status: 400 });
}
const post = await getPost(slug);
if (!post) {
console.error("Post not found for slug:", slug);
return new Response("Post not found", { status: 404 });
}
console.log("Found post:", post.title);
const formattedDate = new Date(post.published_at).toLocaleDateString(
params.locale,
{
year: "numeric",
month: "long",
day: "numeric",
},
);
const ogImage = await generateOGImage({
title: post.title,
author: post.primary_author
? {
name: post.primary_author.name,
image: post.primary_author.profile_image || undefined,
}
: undefined,
date: formattedDate,
readingTime: post.reading_time,
});
console.log("Successfully generated OG image");
return new Response(ogImage, {
headers: {
"Content-Type": "image/png",
"Cache-Control": "public, max-age=31536000, immutable",
},
});
} catch (error) {
console.error("Error generating OG image:", error);
return new Response(`Error generating image: ${error}`, { status: 500 });
}
}

View File

@@ -34,21 +34,35 @@ export async function generateMetadata(
};
}
const ogUrl = new URL(
`/${params.locale}/api/og`,
process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000",
);
ogUrl.searchParams.set("slug", params.slug);
return {
title: post.title,
description: post.custom_excerpt || post.excerpt,
openGraph: post.feature_image
? {
images: [
{
url: post.feature_image,
width: 1200,
height: 630,
alt: post.title,
},
],
}
: undefined,
openGraph: {
title: post.title,
description: post.custom_excerpt || post.excerpt,
type: "article",
url: `${process.env.NEXT_PUBLIC_APP_URL}/blog/${post.slug}`,
images: [
{
url: ogUrl.toString(),
width: 1200,
height: 630,
alt: post.title,
},
],
},
twitter: {
card: "summary_large_image",
title: post.title,
description: post.custom_excerpt || post.excerpt,
images: [ogUrl.toString()],
},
};
}

View File

@@ -0,0 +1,59 @@
import { getPost } from "@/lib/ghost";
import { generateOGImage } from "@/lib/og-image";
import type { NextRequest } from "next/server";
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const slug = searchParams.get("slug");
console.log("Generating OG image for slug:", slug);
if (!slug) {
console.error("Missing slug parameter");
return new Response("Missing slug parameter", { status: 400 });
}
const post = await getPost(slug);
if (!post) {
console.error("Post not found for slug:", slug);
return new Response("Post not found", { status: 404 });
}
console.log("Found post:", post.title);
const formattedDate = new Date(post.published_at).toLocaleDateString(
"en-US",
{
year: "numeric",
month: "long",
day: "numeric",
},
);
const ogImage = await generateOGImage({
title: post.title,
author: post.primary_author
? {
name: post.primary_author.name,
image: post.primary_author.profile_image || undefined,
}
: undefined,
date: formattedDate,
readingTime: post.reading_time,
});
console.log("Successfully generated OG image");
return new Response(ogImage, {
headers: {
"Content-Type": "image/png",
"Cache-Control": "public, max-age=31536000, immutable",
},
});
} catch (error) {
console.error("Error generating OG image:", error);
return new Response(`Error generating image: ${error}`, { status: 500 });
}
}

View File

@@ -0,0 +1,209 @@
import * as fs from "node:fs/promises";
import { join } from "node:path";
import satori from "satori";
import sharp from "sharp";
interface GenerateOGImageOptions {
title: string;
author?: {
name: string;
image?: string;
};
date?: string;
readingTime?: number;
}
// Logo de Dokploy como SVG string (versión simplificada)
const DOKPLOY_LOGO = {
type: "svg",
props: {
width: "100%",
height: "100%",
viewBox: "0 0 6323 5778",
fill: "currentColor",
children: [
{
type: "path",
props: {
d: "M4638.51 44.5295C4616.52 81.8286 4611.45 115.575 4619.9 213.263C4636.82 433.505 4772.12 710.584 4924.33 842.019C5002.12 909.512 5196.61 1012.53 5245.66 1012.53C5284.56 1012.53 5282.87 1019.63 5213.53 1129.75C5140.8 1243.43 5024.11 1339.34 4890.5 1389.07C4743.36 1445.91 4455.85 1453.01 4234.3 1405.06C4016.13 1357.1 3931.57 1323.35 3211.11 977.006C2265.71 522.312 2253.87 516.984 2125.34 481.461C2017.1 451.267 1917.32 445.938 1316.93 435.281C853.533 428.177 601.539 429.953 538.964 444.162C334.325 485.013 156.745 632.434 70.4925 829.586C12.9907 961.021 -7.30411 1191.92 2.84328 1589.78C7.91697 1841.99 16.3731 1911.26 46.8153 2005.39C114.465 2213.2 226.086 2342.86 422.269 2445.88C1594.29 3055.1 1969.74 3206.07 2529.54 3294.88C2732.49 3326.85 3258.46 3330.4 3459.72 3303.76C3755.69 3261.13 4107.46 3161.66 4403.43 3033.78C4540.42 2975.17 4904.03 2776.24 5220.29 2587.97C5910.31 2177.68 6006.71 2111.96 6037.16 2030.26C6070.98 1934.35 5988.11 1811.79 5888.33 1811.79C5851.12 1811.79 5862.96 1806.47 5426.62 2069.34C4352.69 2715.85 4026.28 2865.05 3485.09 2957.41C3162.06 3014.24 2587.04 2987.6 2274.17 2902.35C1924.08 2806.44 1839.52 2770.91 1051.41 2383.71C552.493 2140.38 444.255 2079.99 395.209 2023.16C363.076 1984.08 336.016 1945.01 336.016 1934.35C336.016 1920.14 467.932 1916.59 787.575 1921.92L1240.82 1929.02L1435.32 2001.84C1541.86 2040.92 1744.81 2126.17 1883.49 2190.11C2296.15 2381.94 2610.72 2451.21 3058.9 2451.21C3490.16 2451.21 3872.38 2374.83 4305.33 2198.99C4910.8 1955.66 5342.06 1596.88 5545.01 1172.38C5565.3 1127.98 5585.6 1090.68 5587.29 1087.13C5590.67 1083.57 5660.01 1074.69 5742.88 1065.81C5940.76 1046.28 6084.51 978.782 6221.5 842.019L6322.97 740.779V520.536V302.071L6253.63 353.579C6177.53 412.192 6062.52 444.162 5920.46 444.162C5795.31 444.162 5661.7 508.104 5568.69 614.672L5497.65 692.823L5487.51 646.643C5451.99 500.999 5304.85 364.236 5115.44 300.294C4956.46 248.786 4893.88 206.159 4831.31 108.471C4800.87 64.0671 4770.42 21.4395 4760.28 14.335C4721.38 -14.0833 4665.57 1.90186 4638.51 44.5295ZM2057.69 806.496C2162.55 834.914 2250.49 873.99 2517.7 1007.2C2605.65 1051.6 2796.76 1142.19 2940.51 1211.46C3084.27 1280.73 3332.88 1397.95 3490.16 1472.55C3948.49 1691.02 4049.96 1726.54 4301.95 1754.96L4437.25 1770.94L4310.41 1833.11C4153.12 1911.26 4016.13 1960.99 3804.73 2016.05C3512.15 2090.65 3402.22 2104.86 3050.44 2104.86C2590.43 2103.08 2370.57 2056.9 1974.82 1872.18C1413.33 1611.09 1386.27 1603.99 801.104 1589.78C457.784 1580.9 356.311 1572.01 336.016 1552.48C278.514 1492.09 303.882 1019.63 373.223 914.841C412.121 854.452 474.697 806.496 552.493 779.854C577.862 770.973 904.27 767.421 1278.03 772.749C1814.15 778.078 1978.2 785.182 2057.69 806.496Z",
},
},
{
type: "path",
props: {
d: "M1266.2 1060.49C1173.18 1097.79 1129.21 1207.91 1171.49 1294.94C1222.22 1394.4 1332.15 1417.49 1413.33 1342.89C1477.6 1286.06 1479.29 1174.16 1418.41 1112C1374.44 1065.82 1308.48 1042.73 1266.2 1060.49Z",
},
},
{
type: "path",
props: {
d: "M87.4063 2513.37C7.91846 2548.89 -8.99385 2616.39 4.536 2836.63C19.7571 3072.86 46.8168 3222.05 124.613 3488.48C427.344 4532.85 1129.2 5287.71 2106.74 5623.4C2641.17 5806.35 3236.48 5827.66 3752.3 5682.01C4596.23 5445.79 5315 4836.57 5692.15 4040.86C5886.64 3630.57 6018.55 3111.93 6018.55 2753.15C6018.55 2582.64 5991.49 2518.7 5910.31 2497.39C5820.68 2474.3 5575.45 2609.28 5164.48 2911.23C4484.61 3410.32 4229.23 3563.07 3890.98 3676.75C3635.61 3763.78 3466.49 3797.52 3194.2 3818.84C2651.31 3863.24 2057.69 3731.81 1570.62 3458.28C1394.73 3358.82 846.769 2980.5 581.246 2772.69C285.28 2540.01 270.059 2529.36 199.028 2508.04C155.056 2495.61 124.613 2497.39 87.4063 2513.37ZM5678.62 3076.41C5661.7 3138.57 5646.48 3202.52 5646.48 3218.5C5646.48 3236.26 5626.19 3262.9 5600.82 3280.67C5573.76 3296.65 5482.43 3371.25 5396.18 3445.85C5308.24 3518.67 5198.31 3611.03 5150.95 3650.1C5101.91 3689.18 4990.28 3781.54 4902.34 3856.14C4699.39 4026.65 4406.81 4236.23 4242.76 4330.37C4085.48 4420.95 3767.52 4532.85 3532.44 4582.58C2847.5 4724.67 2054.31 4570.15 1516.5 4190.05C1173.18 3946.72 412.123 3314.41 388.445 3254.02C363.077 3182.98 330.944 3042.66 337.708 3021.35C341.091 3012.47 417.196 3060.42 505.14 3129.69C1056.48 3559.52 1563.85 3863.24 1942.69 3992.9C2328.29 4124.34 2565.06 4163.41 2991.25 4163.41C3380.23 4163.41 3628.84 4126.11 3963.71 4012.44C4345.93 3884.56 4531.96 3781.54 5052.86 3405C5391.11 3161.66 5676.92 2968.06 5700.6 2966.29C5705.68 2966.29 5697.22 3016.02 5678.62 3076.41ZM5426.62 3881C5426.62 3886.33 5409.71 3925.41 5391.11 3966.26C5318.38 4115.45 5144.19 4364.11 5003.81 4518.64C4587.77 4973.33 4090.55 5271.73 3540.9 5392.5C3309.2 5444.01 2708.81 5440.46 2483.88 5387.17C1716.06 5204.23 1105.53 4754.87 696.249 4071.05C647.204 3987.57 609.997 3916.53 613.379 3912.97C616.762 3909.42 774.046 4028.42 965.155 4177.62C1154.57 4326.82 1371.05 4486.67 1443.77 4532.85C1974.82 4863.21 2463.59 4991.09 3118.09 4968C3461.41 4955.57 3691.42 4912.94 3997.53 4806.38C4357.76 4680.27 4623.29 4513.31 5130.66 4095.92C5382.65 3888.11 5426.62 3856.14 5426.62 3881Z",
},
},
],
},
};
export async function generateOGImage({
title,
author,
date,
readingTime,
}: GenerateOGImageOptions): Promise<Buffer> {
// Cargar la fuente
const interRegular = await fs.readFile(
join(process.cwd(), "public/fonts/Inter-Regular.ttf"),
);
const interBold = await fs.readFile(
join(process.cwd(), "public/fonts/Inter-Bold.ttf"),
);
// Crear el markup para la imagen OG
const markup = {
type: "div",
props: {
style: {
height: "100%",
width: "100%",
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
justifyContent: "center",
backgroundColor: "#000000",
padding: "80px",
position: "relative",
overflow: "hidden",
},
children: [
{
type: "div",
props: {
style: {
position: "absolute",
left: "80px",
top: "40px",
fontSize: "32px",
fontWeight: 700,
color: "#fff",
zIndex: 1,
},
children: "Dokploy",
},
},
{
type: "div",
props: {
style: {
position: "absolute",
right: "-50px",
bottom: "-50px",
width: "500px",
height: "500px",
opacity: 0.1,
display: "flex",
alignItems: "center",
justifyContent: "center",
transform: "rotate(-10deg)",
color: "#ffffff",
},
children: DOKPLOY_LOGO,
},
},
{
type: "div",
props: {
style: {
display: "flex",
flexDirection: "column",
gap: "24px",
position: "relative",
zIndex: 1,
},
children: [
{
type: "div",
props: {
style: {
fontSize: "64px",
fontWeight: 700,
color: "#fff",
lineHeight: 1.2,
maxWidth: "900px",
},
children: title,
},
},
{
type: "div",
props: {
style: {
display: "flex",
alignItems: "center",
gap: "16px",
},
children: [
author?.name && {
type: "div",
props: {
style: {
color: "#9CA3AF",
fontSize: "24px",
},
children: author.name,
},
},
date && {
type: "div",
props: {
style: {
color: "#9CA3AF",
fontSize: "24px",
},
children: `${date}`,
},
},
readingTime && {
type: "div",
props: {
style: {
color: "#9CA3AF",
fontSize: "24px",
},
children: `${readingTime} min read`,
},
},
].filter(Boolean) as any,
},
},
],
},
},
],
},
};
// Generar SVG con Satori
const svg = await satori(markup as any, {
width: 1200,
height: 630,
fonts: [
{
name: "Inter",
data: interRegular,
weight: 400,
style: "normal",
},
{
name: "Inter",
data: interBold,
weight: 700,
style: "normal",
},
],
});
// Convertir SVG a PNG
const png = await sharp(Buffer.from(svg)).png().toBuffer();
return png;
}

View File

@@ -1,9 +1,14 @@
import createMiddleware from "next-intl/middleware";
import { routing } from "./i18n/routing";
export default createMiddleware(routing);
export default createMiddleware({
locales: ["en", "fr", "zh-Hans"],
defaultLocale: "en",
localePrefix: "always",
// Excluir la ruta de la API de OG images
localeDetection: true,
});
export const config = {
// Match only internationalized pathnames
matcher: ["/((?!_next|.*\\..*).*)"],
matcher: ["/((?!api|_next|.*\\..*).*)"],
};

View File

@@ -47,7 +47,10 @@
"typescript": "5.1.6",
"react-markdown": "^10.0.0",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1"
"remark-gfm": "^4.0.1",
"@resvg/resvg-js": "^2.6.2",
"satori": "^0.12.1",
"sharp": "^0.33.5"
},
"devDependencies": {
"@babel/core": "^7.26.9",

Binary file not shown.

Binary file not shown.