interface Env {
BLOG_BUCKET: R2Bucket
}
interface BlogPost {
id: string
title: string
date: string
description?: string
content: string
}
interface Frontmatter {
title?: string
date?: string
description?: string
}
// Parse frontmatter from markdown
function parseFrontmatter(content: string): {
data: Frontmatter
content: string
} {
const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
const frontmatterRegex = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/
const match = normalized.match(frontmatterRegex)
if (!match) {
return { data: {}, content: normalized }
}
const frontmatter = match[1]
const body = match[2]
const data: Frontmatter = {}
for (const line of frontmatter.split("\n")) {
const [key, ...valueParts] = line.split(":")
if (key && valueParts.length > 0) {
const value = valueParts
.join(":")
.trim()
.replace(/^["']|["']$/g, "")
const trimmedKey = key.trim()
switch (trimmedKey) {
case "title":
data.title = value
break
case "date":
data.date = value
break
case "description":
data.description = value
break
}
}
}
return { data, content: body }
}
// Escape XML special characters
function escapeXml(text: string): string {
return text
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """)
.replace(/'/g, "'")
}
// Strip markdown to plain text for description
function stripMarkdown(markdown: string): string {
return (
markdown
// Remove code blocks
.replace(/```[\s\S]*?```/g, "")
// Remove inline code
.replace(/`[^`]+`/g, "")
// Remove images
.replace(/!\[[^\]]*\]\([^)]*\)/g, "")
// Remove links but keep text
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
// Remove headers
.replace(/^#{1,6}\s+/gm, "")
// Remove bold/italic
.replace(/\*\*([^*]+)\*\*/g, "$1")
.replace(/\*([^*]+)\*/g, "$1")
.replace(/__([^_]+)__/g, "$1")
.replace(/_([^_]+)_/g, "$1")
// Remove custom containers
.replace(/:::\s*\w+[\s\S]*?:::/g, "")
// Remove horizontal rules
.replace(/^---+$/gm, "")
// Remove blockquotes
.replace(/^>\s+/gm, "")
// Remove list markers
.replace(/^[\s]*[-*+]\s+/gm, "")
.replace(/^[\s]*\d+\.\s+/gm, "")
// Collapse whitespace
.replace(/\n{3,}/g, "\n\n")
.trim()
)
}
// Generate RSS 2.0 feed
function generateRss(posts: BlogPost[], baseUrl: string): string {
const now = new Date().toUTCString()
const items = posts
.map((post) => {
const pubDate = post.date ? new Date(post.date).toUTCString() : now
const description =
post.description || stripMarkdown(post.content).slice(0, 300) + "..."
const link = `${baseUrl}/blog/${post.id}`
return ` -
${escapeXml(post.title)}
${link}
${link}
${pubDate}
${escapeXml(description)}
`
})
.join("\n")
return `
c30.life Blog
${baseUrl}/blog
ced's blog - プログラミング、技術、日常など
ja
${now}
${items}
`
}
export const onRequestGet: PagesFunction = async (context) => {
const { env, request } = context
const url = new URL(request.url)
const baseUrl = `${url.protocol}//${url.host}`
try {
// List all blog posts
const listed = await env.BLOG_BUCKET.list()
const posts: BlogPost[] = []
for (const object of listed.objects) {
if (!object.key.endsWith(".md")) continue
const file = await env.BLOG_BUCKET.get(object.key)
if (!file) continue
const rawContent = await file.text()
const { data, content } = parseFrontmatter(rawContent)
const id = object.key.replace(/\.md$/, "")
const title = data.title || id
const date = data.date || object.uploaded.toISOString().split("T")[0]
posts.push({
id,
title,
date,
description: data.description,
content,
})
}
// Sort by date (newest first)
posts.sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(),
)
// Limit to 20 most recent posts
const recentPosts = posts.slice(0, 20)
// Generate RSS feed
const rss = generateRss(recentPosts, baseUrl)
return new Response(rss, {
headers: {
"Content-Type": "application/rss+xml; charset=utf-8",
"Cache-Control": "public, max-age=3600",
},
})
} catch (error) {
console.error("RSS generation error:", error)
return new Response("Internal Server Error", { status: 500 })
}
}