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 `
${alt}
` }) .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", }, }) }