Skip to content

Commit 1605360

Browse files
committed
feat: add blog post metadata
1 parent 30d6270 commit 1605360

File tree

5 files changed

+172
-3
lines changed

5 files changed

+172
-3
lines changed

src/app/__components/blog/share-modal.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,19 +57,18 @@ import { Input } from "@/app/__components/ui/input";
5757
import { Label } from "@/app/__components/ui/label";
5858
import { Separator } from "@/app/__components/ui/separator";
5959
import { useToast } from "@/app/__components/ui/use-toast";
60+
import config from "@/lib/config";
6061

6162
interface ShareModalProps {
6263
title: string;
6364
slug: string;
6465
}
6566

66-
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000";
67-
6867
const ShareModal = ({ title, slug }: ShareModalProps) => {
6968
const [open, setOpen] = useState(false);
7069
const [copied, setCopied] = useState(false);
7170
const { toast } = useToast();
72-
const shareUrl = `${siteUrl}/blog/${slug}`;
71+
const shareUrl = `${config.siteUrl}/blog/${slug}`;
7372

7473
const handleCopyLink = () => {
7574
navigator.clipboard

src/app/blog/[slug]/page.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@ import { notFound } from "next/navigation";
2727
import { BlogPost } from "@/app/__components/blog/blog-post";
2828
import { Footer } from "@/app/__components/common/footer";
2929
import { Header } from "@/app/__components/common/header";
30+
import { generateMetadata } from "@/lib/metadata";
3031
import { getAllPosts, getPostBySlug } from "@/lib/posts";
32+
export { generateMetadata };
3133

3234
export default async function BlogPostPage({
3335
params,

src/lib/config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,13 @@
2222
* SOFTWARE.
2323
*/
2424

25+
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000";
2526
const postsConfig = {
2627
postsManifestFile: "src/lib/data/posts-manifest.json",
2728
};
2829

2930
const config = {
31+
siteUrl,
3032
posts: postsConfig,
3133
};
3234

src/lib/metadata.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/**
2+
* MIT License
3+
*
4+
* Copyright (c) 2025 Josimar Silva
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy
7+
* of this software and associated documentation files (the "Software"), to deal
8+
* in the Software without restriction, including without limitation the rights
9+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
* copies of the Software, and to permit persons to whom the Software is
11+
* furnished to do so, subject to the following conditions:
12+
*
13+
* The above copyright notice and this permission notice shall be included in all
14+
* copies or substantial portions of the Software.
15+
*
16+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22+
* SOFTWARE.
23+
*/
24+
25+
import config from "./config";
26+
import { generateMetadata } from "./metadata";
27+
import { getPostBySlug } from "./posts";
28+
29+
jest.mock("./posts", () => ({
30+
getPostBySlug: jest.fn(),
31+
}));
32+
33+
jest.mock("./config", () => ({
34+
__esModule: true,
35+
default: {
36+
siteUrl: "https://example.com",
37+
},
38+
}));
39+
40+
describe("generateMetadata", () => {
41+
const mockPost = {
42+
title: "Test Post Title",
43+
excerpt: "Test Post Excerpt",
44+
image: "/assets/test-image.jpg",
45+
date: "2025-01-01T00:00:00Z",
46+
author: "Test Author",
47+
slug: "test-post-slug",
48+
};
49+
50+
it("should generate correct metadata for a post", async () => {
51+
(getPostBySlug as jest.Mock).mockResolvedValue(mockPost);
52+
53+
const metadata = await generateMetadata({
54+
params: { slug: "test-post-slug" },
55+
});
56+
57+
expect(metadata.title).toBe(mockPost.title);
58+
expect(metadata.description).toBe(mockPost.excerpt);
59+
expect(metadata.openGraph?.title).toBe(mockPost.title);
60+
expect(metadata.openGraph?.description).toBe(mockPost.excerpt);
61+
expect(metadata.openGraph?.url).toBe(
62+
`${config.siteUrl}/blog/${mockPost.slug}`,
63+
);
64+
expect(metadata.openGraph?.type).toBe("article");
65+
expect(metadata.openGraph?.publishedTime).toBe(mockPost.date);
66+
expect(metadata.openGraph?.authors).toEqual([mockPost.author]);
67+
expect(metadata.openGraph?.images?.[0].url).toBe(
68+
new URL(mockPost.image, config.siteUrl).toString(),
69+
);
70+
expect(metadata.openGraph?.images?.[0].width).toBe(1200);
71+
expect(metadata.openGraph?.images?.[0].height).toBe(630);
72+
expect(metadata.openGraph?.images?.[0].alt).toBe(mockPost.title);
73+
74+
expect(metadata.twitter?.card).toBe("summary_large_image");
75+
expect(metadata.twitter?.title).toBe(mockPost.title);
76+
expect(metadata.twitter?.description).toBe(mockPost.excerpt);
77+
expect(metadata.twitter?.images?.[0]).toBe(
78+
new URL(mockPost.image, config.siteUrl).toString(),
79+
);
80+
});
81+
82+
it("should return empty metadata if post is not found", async () => {
83+
(getPostBySlug as jest.Mock).mockResolvedValue(null);
84+
85+
const metadata = await generateMetadata({
86+
params: { slug: "non-existent-post" },
87+
});
88+
89+
expect(metadata).toEqual({});
90+
});
91+
});

src/lib/metadata.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/**
2+
* MIT License
3+
*
4+
* Copyright (c) 2025 Josimar Silva
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy
7+
* of this software and associated documentation files (the "Software"), to deal
8+
* in the Software without restriction, including without limitation the rights
9+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
* copies of the Software, and to permit persons to whom the Software is
11+
* furnished to do so, subject to the following conditions:
12+
*
13+
* The above copyright notice and this permission notice shall be included in all
14+
* copies or substantial portions of the Software.
15+
*
16+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22+
* SOFTWARE.
23+
*/
24+
25+
import type { Metadata } from "next";
26+
27+
import config from "./config";
28+
import { getPostBySlug } from "./posts";
29+
30+
export async function generateMetadata({
31+
params,
32+
}: {
33+
params: { slug: string };
34+
}): Promise<Metadata> {
35+
const post = await getPostBySlug(params.slug, [
36+
"title",
37+
"excerpt",
38+
"image",
39+
"date",
40+
"author",
41+
]);
42+
43+
if (!post || Object.keys(post).length === 0) {
44+
return {};
45+
}
46+
47+
const ogImageUrl = new URL(post.image, config.siteUrl).toString();
48+
49+
return {
50+
title: post.title,
51+
description: post.excerpt,
52+
openGraph: {
53+
title: post.title,
54+
description: post.excerpt,
55+
url: `${config.siteUrl}/blog/${params.slug}`,
56+
type: "article",
57+
publishedTime: post.date,
58+
authors: [post.author],
59+
images: [
60+
{
61+
url: ogImageUrl,
62+
width: 1200,
63+
height: 630,
64+
alt: post.title,
65+
},
66+
],
67+
},
68+
twitter: {
69+
card: "summary_large_image",
70+
title: post.title,
71+
description: post.excerpt,
72+
images: [ogImageUrl],
73+
},
74+
};
75+
}

0 commit comments

Comments
 (0)