import { NextResponse } from "next/server"
import fs from "fs"
import path from "path"
export const dynamic = "force-static"
interface ChangelogEntry {
version: string
date: string
content: string
url: string
title: string
image?: string
}
// Default channel image when no entry image is available.
const DEFAULT_CHANNEL_IMAGE =
"https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/main.png"
// Pull the first markdown image URL out of a raw entry block. Returns an
// absolute URL or null if the entry has no image.
function extractFirstImage(rawContent: string): string | null {
const match = rawContent.match(/!\[[^\]]*\]\(([^)]+)\)/)
if (!match) return null
const url = match[1]
if (url.startsWith("http://") || url.startsWith("https://")) {
return url.replace(
"https://macrimi.github.io/ProxMenux",
"https://proxmenux.com",
)
}
if (url.startsWith("/")) return `https://proxmenux.com${url}`
return `https://proxmenux.com/${url}`
}
function escapeXml(text: string): string {
return text
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
}
function formatContentForRSS(content: string): string {
return (
content
.replace(/https:\/\/macrimi\.github\.io\/ProxMenux/g, "https://proxmenux.com")
.replace(/`([^`]+)`/g, "$1")
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, url) => {
let absoluteUrl = url
if (url.startsWith("/")) {
absoluteUrl = `https://proxmenux.com${url}`
} else if (!url.startsWith("http://") && !url.startsWith("https://")) {
absoluteUrl = `https://proxmenux.com/${url}`
}
return `
`
})
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1')
.replace(/^### (.+)$/gm, "$1
")
.replace(/\*\*(.*?)\*\*/g, "$1")
.replace(/```[\s\S]*?```/g, (match) => {
const code = match.replace(/```/g, "").trim()
return `${code}
`
})
.replace(/^- (.+)$/gm, "$1")
.replace(/(.*?<\/li>\s*)+/g, (match) => ``)
.replace(/^---$/gm, '
')
.replace(/\n/g, "
")
.replace(/\s+/g, " ")
.trim()
)
}
async function parseChangelog(): Promise {
try {
const changelogPath = path.join(process.cwd(), "..", "CHANGELOG.md")
if (!fs.existsSync(changelogPath)) {
return []
}
const fileContents = fs.readFileSync(changelogPath, "utf8")
const entries: ChangelogEntry[] = []
const lines = fileContents.split("\n")
let currentEntry: Partial | null = null
let contentLines: string[] = []
for (const line of lines) {
const versionMatch = line.match(/^##\s+\[([^\]]+)\]\s*-\s*(\d{4}-\d{2}-\d{2})/)
const dateMatch = line.match(/^##\s+(\d{4}-\d{2}-\d{2})$/)
if (versionMatch || dateMatch) {
if (currentEntry && contentLines.length > 0) {
const rawContent = contentLines.join("\n").trim()
const firstImage = extractFirstImage(rawContent)
if (firstImage) currentEntry.image = firstImage
currentEntry.content = formatContentForRSS(rawContent)
if (currentEntry.version && currentEntry.date && currentEntry.title) {
entries.push(currentEntry as ChangelogEntry)
}
}
if (versionMatch) {
const version = versionMatch[1]
const date = versionMatch[2]
currentEntry = {
version,
date,
url: `https://proxmenux.com/changelog#${version}`,
title: `ProxMenux ${version}`,
}
} else if (dateMatch) {
const date = dateMatch[1]
currentEntry = {
version: date,
date,
url: `https://proxmenux.com/changelog#${date}`,
title: `ProxMenux Update ${date}`,
}
}
contentLines = []
} else if (currentEntry && line.trim()) {
if (contentLines.length > 0 || line.trim() !== "") {
contentLines.push(line)
}
}
}
if (currentEntry && contentLines.length > 0) {
const rawContent = contentLines.join("\n").trim()
const firstImage = extractFirstImage(rawContent)
if (firstImage) currentEntry.image = firstImage
currentEntry.content = formatContentForRSS(rawContent)
if (currentEntry.version && currentEntry.date && currentEntry.title) {
entries.push(currentEntry as ChangelogEntry)
}
}
return entries.slice(0, 20)
} catch (error) {
console.error("Error parsing changelog:", error)
return []
}
}
export async function GET() {
const entries = await parseChangelog()
const siteUrl = "https://proxmenux.com"
// Use the latest entry image as the channel image when available, otherwise
// fall back to the static ProxMenux brand image. Channel-level is the
// RSS 2.0 standard way to express a feed icon; per item is
// what most modern readers (Feedly, NetNewsWire, Inoreader, etc.) render.
const channelImage = entries.find((e) => e.image)?.image ?? DEFAULT_CHANNEL_IMAGE
const rssXml = `
ProxMenux Changelog
Release notes and changes in ProxMenux — an open-source interactive menu and web dashboard for Proxmox VE management.
${siteUrl}/changelog
en-US
${new Date().toUTCString()}
ProxMenux RSS Generator
60
${escapeXml(channelImage)}
ProxMenux Changelog
${siteUrl}/changelog
${entries
.map(
(entry) => `
-
${escapeXml(entry.title)}
${escapeXml(entry.content.replace(/<[^>]*>/g, '').substring(0, 200))}...
${entry.url}
${entry.url}
${new Date(entry.date).toUTCString()}
Changelog${entry.image ? `
` : ""}
`,
)
.join("")}
`
return new NextResponse(rssXml, {
headers: {
"Content-Type": "application/rss+xml; charset=utf-8",
"Cache-Control": "public, max-age=3600, s-maxage=3600",
},
})
}