Files

512 lines
14 KiB
TypeScript

interface Env {
BLOG_BUCKET: R2Bucket
BLOG_VIEWS: KVNamespace
BLOG_EDIT_KEY?: string
}
interface BlogPost {
id: string
title: string
date: string
views: number
description?: string
tags?: string[]
draft?: boolean
}
interface BlogPostDetail extends BlogPost {
content: string
author?: string
image?: string
outline?: number | [number, number] | "deep" | false
draft?: boolean
}
// VitePress-compatible frontmatter interface
interface Frontmatter {
title?: string
date?: string
description?: string
tags?: string[]
author?: string
image?: string
outline?: number | [number, number] | "deep" | false
draft?: boolean
}
// Parse frontmatter from markdown (VitePress compatible)
function parseFrontmatter(content: string): {
data: Frontmatter
content: string
} {
// Normalize line endings to LF
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 = {}
let currentKey = ""
let inArray = false
let arrayValues: string[] = []
for (const line of frontmatter.split("\n")) {
// Handle array continuation
if (inArray) {
const arrayItemMatch = line.match(/^\s*-\s*(.+)$/)
if (arrayItemMatch) {
arrayValues.push(arrayItemMatch[1].replace(/^["']|["']$/g, "").trim())
continue
} else {
// End of array
if (currentKey === "tags") data.tags = arrayValues
inArray = false
arrayValues = []
}
}
const [key, ...valueParts] = line.split(":")
if (key && valueParts.length > 0) {
const rawValue = valueParts.join(":").trim()
const value = rawValue.replace(/^["']|["']$/g, "")
const trimmedKey = key.trim()
// Check if this starts an array (empty value or inline array)
if (rawValue === "" || rawValue === "[]") {
currentKey = trimmedKey
inArray = true
arrayValues = []
continue
}
// Handle inline array like tags: [tag1, tag2]
if (rawValue.startsWith("[") && rawValue.endsWith("]")) {
const arrayContent = rawValue.slice(1, -1)
const items = arrayContent
.split(",")
.map((item) => item.trim().replace(/^["']|["']$/g, ""))
.filter(Boolean)
if (trimmedKey === "tags") data.tags = items
continue
}
switch (trimmedKey) {
case "title":
data.title = value
break
case "date":
data.date = value
break
case "description":
data.description = value
break
case "author":
data.author = value
break
case "image":
data.image = value
break
case "outline":
if (value === "deep") data.outline = "deep"
else if (value === "false") data.outline = false
else if (value.startsWith("[")) {
const nums = value
.slice(1, -1)
.split(",")
.map((n) => parseInt(n.trim(), 10))
if (nums.length === 2) data.outline = [nums[0], nums[1]]
} else {
data.outline = parseInt(value, 10)
}
break
case "draft":
data.draft = value === "true"
break
}
}
}
// Handle trailing array
if (inArray && currentKey === "tags") {
data.tags = arrayValues
}
return { data, content: body.trim() }
}
// Verify edit key for authentication
function verifyEditKey(request: Request, env: Env): boolean {
const key = request.headers.get("X-Edit-Key")
const envKey = env.BLOG_EDIT_KEY
return !!key && !!envKey && key === envKey
}
export const onRequestGet: PagesFunction<Env> = async (context) => {
const url = new URL(context.request.url)
const id = url.searchParams.get("id")
const raw = url.searchParams.get("raw")
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, X-Edit-Key",
}
// Verify key check endpoint
if (id === "_verify") {
if (!verifyEditKey(context.request, context.env)) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json", ...corsHeaders },
})
}
return new Response(JSON.stringify({ ok: true }), {
headers: { "Content-Type": "application/json", ...corsHeaders },
})
}
// Get single post with raw content for editing
if (id && raw === "true") {
if (!verifyEditKey(context.request, context.env)) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json", ...corsHeaders },
})
}
try {
const object = await context.env.BLOG_BUCKET.get(`${id}.md`)
if (!object) {
return new Response(JSON.stringify({ error: "Post not found" }), {
status: 404,
headers: { "Content-Type": "application/json", ...corsHeaders },
})
}
const text = await object.text()
return new Response(JSON.stringify({ id, raw: text }), {
headers: { "Content-Type": "application/json", ...corsHeaders },
})
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Unknown error"
return new Response(
JSON.stringify({
error: "Failed to fetch post",
details: errorMessage,
}),
{
status: 500,
headers: { "Content-Type": "application/json", ...corsHeaders },
},
)
}
}
// Get single post
if (id) {
const maxRetries = 2
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const object = await context.env.BLOG_BUCKET.get(`${id}.md`)
if (!object) {
return new Response(JSON.stringify({ error: "Post not found" }), {
status: 404,
headers: { "Content-Type": "application/json", ...corsHeaders },
})
}
let views = 0
try {
const currentViews = await context.env.BLOG_VIEWS.get(`views:${id}`)
views = (currentViews ? parseInt(currentViews, 10) : 0) + 1
context.waitUntil(
context.env.BLOG_VIEWS.put(`views:${id}`, views.toString()),
)
} catch {
views = 0
}
const text = await object.text()
const { data, content } = parseFrontmatter(text)
const post: BlogPostDetail = {
id,
title: data.title || id,
date: data.date || "",
description: data.description,
tags: data.tags,
author: data.author,
image: data.image,
outline: data.outline,
draft: data.draft,
content,
views,
}
return new Response(JSON.stringify(post), {
headers: { "Content-Type": "application/json", ...corsHeaders },
})
} catch (error) {
if (attempt === maxRetries) {
const errorMessage =
error instanceof Error ? error.message : "Unknown error"
console.error(`Blog fetch failed for ${id}:`, errorMessage)
return new Response(
JSON.stringify({
error: "Failed to fetch post",
details: errorMessage,
}),
{
status: 500,
headers: { "Content-Type": "application/json", ...corsHeaders },
},
)
}
await new Promise((resolve) =>
setTimeout(resolve, 100 * Math.pow(2, attempt)),
)
}
}
}
// List all posts with pagination
const page = parseInt(url.searchParams.get("page") || "1", 10)
const limit = parseInt(url.searchParams.get("limit") || "8", 10)
// Include drafts only for editors
const includeDrafts =
url.searchParams.get("includeDrafts") === "true" &&
verifyEditKey(context.request, context.env)
try {
const listed = await context.env.BLOG_BUCKET.list()
const validObjects = listed.objects.filter(
(obj) => !obj.key.startsWith("_") && obj.key.endsWith(".md"),
)
validObjects.sort((a, b) => b.key.localeCompare(a.key))
// First, fetch all posts to determine which are drafts
const allPosts: BlogPost[] = []
for (const object of validObjects) {
const postId = object.key.replace(/\.md$/, "")
try {
const file = await context.env.BLOG_BUCKET.get(object.key)
if (!file) continue
let views = 0
try {
const viewCount = await context.env.BLOG_VIEWS.get(`views:${postId}`)
views = viewCount ? parseInt(viewCount, 10) : 0
} catch {
// Ignore view count errors
}
const text = await file.text()
const { data } = parseFrontmatter(text)
allPosts.push({
id: postId,
title: data.title || postId,
date: data.date || "",
description: data.description,
tags: data.tags,
draft: data.draft,
views,
})
} catch (e) {
console.error(`Failed to fetch post ${postId}:`, e)
}
}
// Filter out drafts unless includeDrafts is true
const filteredPosts = includeDrafts
? allPosts
: allPosts.filter((post) => !post.draft)
filteredPosts.sort((a, b) => {
const dateA = new Date(a.date)
const dateB = new Date(b.date)
return dateB.getTime() - dateA.getTime()
})
const totalPosts = filteredPosts.length
const totalPages = Math.ceil(totalPosts / limit)
const startIndex = (page - 1) * limit
const endIndex = startIndex + limit
const posts = filteredPosts.slice(startIndex, endIndex)
return new Response(
JSON.stringify({
posts,
pagination: {
page,
limit,
totalPosts,
totalPages,
hasNext: page < totalPages,
hasPrev: page > 1,
},
}),
{
headers: { "Content-Type": "application/json", ...corsHeaders },
},
)
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Unknown error"
console.error("Blog list error:", errorMessage)
return new Response(
JSON.stringify({ error: "Failed to list posts", details: errorMessage }),
{
status: 500,
headers: { "Content-Type": "application/json", ...corsHeaders },
},
)
}
}
// Update or create post
export const onRequestPut: PagesFunction<Env> = async (context) => {
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, X-Edit-Key",
}
if (!verifyEditKey(context.request, context.env)) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json", ...corsHeaders },
})
}
try {
const body = (await context.request.json()) as {
id: string
content: string
}
const { id, content } = body
if (!id || !content) {
return new Response(JSON.stringify({ error: "Missing id or content" }), {
status: 400,
headers: { "Content-Type": "application/json", ...corsHeaders },
})
}
// Validate id format
if (!/^[a-zA-Z0-9-_]+$/.test(id)) {
return new Response(JSON.stringify({ error: "Invalid id format" }), {
status: 400,
headers: { "Content-Type": "application/json", ...corsHeaders },
})
}
// Save to R2
await context.env.BLOG_BUCKET.put(`${id}.md`, content, {
httpMetadata: {
contentType: "text/markdown",
},
})
return new Response(JSON.stringify({ success: true, id }), {
headers: { "Content-Type": "application/json", ...corsHeaders },
})
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Unknown error"
console.error("Blog update error:", errorMessage)
return new Response(
JSON.stringify({ error: "Failed to update post", details: errorMessage }),
{
status: 500,
headers: { "Content-Type": "application/json", ...corsHeaders },
},
)
}
}
// Delete post
export const onRequestDelete: PagesFunction<Env> = async (context) => {
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, X-Edit-Key",
}
if (!verifyEditKey(context.request, context.env)) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json", ...corsHeaders },
})
}
const url = new URL(context.request.url)
const id = url.searchParams.get("id")
if (!id) {
return new Response(JSON.stringify({ error: "Missing id" }), {
status: 400,
headers: { "Content-Type": "application/json", ...corsHeaders },
})
}
try {
// Check if post exists
const object = await context.env.BLOG_BUCKET.get(`${id}.md`)
if (!object) {
return new Response(JSON.stringify({ error: "Post not found" }), {
status: 404,
headers: { "Content-Type": "application/json", ...corsHeaders },
})
}
// Delete from R2
await context.env.BLOG_BUCKET.delete(`${id}.md`)
// Optionally delete view count
try {
await context.env.BLOG_VIEWS.delete(`views:${id}`)
} catch {
// Ignore view count deletion errors
}
return new Response(JSON.stringify({ success: true }), {
headers: { "Content-Type": "application/json", ...corsHeaders },
})
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Unknown error"
console.error("Blog delete error:", errorMessage)
return new Response(
JSON.stringify({ error: "Failed to delete post", details: errorMessage }),
{
status: 500,
headers: { "Content-Type": "application/json", ...corsHeaders },
},
)
}
}
export const onRequestOptions: PagesFunction = async () => {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, X-Edit-Key",
},
})
}