Skip to content

Commit 89f061f

Browse files
committed
feat(blog): add rss feed
1 parent 2e8d026 commit 89f061f

3 files changed

Lines changed: 125 additions & 1 deletion

File tree

apps/blog/app/layout.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,11 @@ export const metadata: Metadata = {
5757
icons: {
5858
icon: "/icons/favicon.png",
5959
},
60+
alternates: {
61+
types: {
62+
"application/rss+xml": "/rss.xml",
63+
},
64+
},
6065
};
6166

6267
export default function RootLayout({

apps/blog/app/rss.xml/route.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { getAllBlogPosts } from "@/lib/blog";
2+
3+
export const dynamic = "force-static";
4+
5+
function escapeXml(text: string): string {
6+
return text
7+
.replaceAll("&", "&")
8+
.replaceAll("<", "&lt;")
9+
.replaceAll(">", "&gt;")
10+
.replaceAll('"', "&quot;")
11+
.replaceAll("'", "&apos;");
12+
}
13+
14+
function toRfc2822(dateInput: string): string {
15+
const date = new Date(dateInput);
16+
if (Number.isNaN(date.getTime())) {
17+
return new Date().toUTCString();
18+
}
19+
return date.toUTCString();
20+
}
21+
22+
function getBaseUrl(): string {
23+
const envUrl = process.env.NEXT_PUBLIC_SITE_URL || "";
24+
if (envUrl) return envUrl.replace(/\/$/, "");
25+
if (process.env.NODE_ENV === "development") {
26+
return "http://localhost:3000";
27+
}
28+
return "";
29+
}
30+
31+
function normalizeCover(cover: string, baseUrl: string): string | null {
32+
if (!cover) return null;
33+
const normalizedPath = cover.replace("/api/blog-assets", "/blog-assets");
34+
const isAbsolute =
35+
normalizedPath.startsWith("http://") ||
36+
normalizedPath.startsWith("https://");
37+
if (isAbsolute) return normalizedPath;
38+
if (!baseUrl) return null;
39+
return `${baseUrl}${normalizedPath.startsWith("/") ? "" : "/"}${normalizedPath}`;
40+
}
41+
42+
function inferImageMimeType(url: string): string | null {
43+
const lower = url.toLowerCase();
44+
if (lower.endsWith(".png")) return "image/png";
45+
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
46+
if (lower.endsWith(".webp")) return "image/webp";
47+
if (lower.endsWith(".gif")) return "image/gif";
48+
return null;
49+
}
50+
51+
export async function GET() {
52+
const baseUrl = getBaseUrl();
53+
if (!baseUrl) {
54+
return new Response("Missing NEXT_PUBLIC_SITE_URL", { status: 500 });
55+
}
56+
57+
const posts = getAllBlogPosts().sort((a, b) => (a.date < b.date ? 1 : -1));
58+
59+
const itemsXml = posts
60+
.map((post) => {
61+
const link = `${baseUrl}/blog/${post.slug}`;
62+
const guid = link;
63+
const title = escapeXml(post.title || "Untitled");
64+
const description = escapeXml(post.description || "");
65+
const pubDate = toRfc2822(post.date);
66+
const coverUrl = normalizeCover(post.cover, baseUrl);
67+
68+
const categoriesXml = (post.tags || [])
69+
.map((tag) => ` <category>${escapeXml(tag)}</category>`)
70+
.join("\n");
71+
72+
const coverType = coverUrl ? inferImageMimeType(coverUrl) : null;
73+
const enclosureXml =
74+
coverUrl && coverType
75+
? ` <enclosure url="${escapeXml(coverUrl)}" type="${coverType}" />`
76+
: "";
77+
78+
return [
79+
" <item>",
80+
` <title>${title}</title>`,
81+
` <link>${link}</link>`,
82+
` <guid isPermaLink=\"true\">${guid}</guid>`,
83+
` <pubDate>${pubDate}</pubDate>`,
84+
` <description>${description}</description>`,
85+
categoriesXml,
86+
enclosureXml,
87+
" </item>",
88+
]
89+
.filter(Boolean)
90+
.join("\n");
91+
})
92+
.join("\n");
93+
94+
const siteTitle = "BLOGIT";
95+
const siteDescription = "BLOGIT RSS Feed";
96+
const rssXml = `<?xml version="1.0" encoding="UTF-8"?>
97+
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
98+
<channel>
99+
<title>${escapeXml(siteTitle)}</title>
100+
<link>${baseUrl}</link>
101+
<description>${escapeXml(siteDescription)}</description>
102+
<atom:link href="${baseUrl}/rss.xml" rel="self" type="application/rss+xml" />
103+
${itemsXml}
104+
</channel>
105+
</rss>`;
106+
107+
return new Response(rssXml, {
108+
headers: {
109+
"Content-Type": "application/rss+xml; charset=utf-8",
110+
"Cache-Control": "public, max-age=600",
111+
},
112+
});
113+
}

apps/blog/components/layout/blog-header.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,13 @@ export function BlogHeader() {
1717
<span className="bg-black text-lg text-white font-anta">BLOGIT</span>
1818
</div>
1919
</a>
20-
<nav>
20+
<nav className="flex items-center gap-4">
21+
<a
22+
href="/rss.xml"
23+
className="text-sm font-medium text-neutral-600 transition-colors hover:text-neutral-900"
24+
>
25+
RSS
26+
</a>
2127
<a
2228
href="https://github.com/Hexi1997/Blogit"
2329
target="_blank"

0 commit comments

Comments
 (0)