import { NextResponse } from "next/server" import fs from "fs" import path from "path" import { routing } from "@/i18n/routing" export const dynamic = "force-static" export function generateStaticParams() { return routing.locales.map((locale) => ({ locale })) } interface ChangelogEntry { version: string date: string content: string url: string title: string image?: string } // Per-locale RSS feed. Mirrors /app/rss.xml/route.ts (which stays the // canonical English feed at the root for backwards compatibility with // existing subscribers) but reads the localized CHANGELOG at // /lang//CHANGELOG.md. Falls back to the English source // when the localized file doesn't exist yet so partial translations // still produce a valid feed. const DEFAULT_CHANNEL_IMAGE = "https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/main.png" type LocaleStrings = { lang: string channelTitle: string channelDescription: string itemTitlePrefix: string // "ProxMenux" — used as `${prefix} ${version}` for versioned releases itemUpdatePrefix: string // "ProxMenux Update" — used as `${prefix} ${date}` for dated releases category: string } const STRINGS: Record = { en: { lang: "en-US", channelTitle: "ProxMenux Changelog", channelDescription: "Release notes and changes in ProxMenux — an open-source interactive menu and web dashboard for Proxmox VE management.", itemTitlePrefix: "ProxMenux", itemUpdatePrefix: "ProxMenux Update", category: "Changelog", }, es: { lang: "es-ES", channelTitle: "Changelog de ProxMenux", channelDescription: "Notas de release y cambios en ProxMenux — un menú interactivo y panel web open-source para gestionar Proxmox VE.", itemTitlePrefix: "ProxMenux", itemUpdatePrefix: "Actualización ProxMenux", category: "Changelog", }, } function resolveChangelogPath(locale: string): string { const repoRoot = path.join(process.cwd(), "..") if (locale && locale !== "en") { const localized = path.join(repoRoot, "lang", locale, "CHANGELOG.md") if (fs.existsSync(localized)) return localized } return path.join(repoRoot, "CHANGELOG.md") } 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) => `
      ${match}
    `) .replace(/^---$/gm, '
    ') .replace(/\n/g, "
    ") .replace(/\s+/g, " ") .trim() } async function parseChangelog(locale: string, strings: LocaleStrings): Promise { try { const changelogPath = resolveChangelogPath(locale) 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/${locale}/changelog#${version}`, title: `${strings.itemTitlePrefix} ${version}`, } } else if (dateMatch) { const date = dateMatch[1] currentEntry = { version: date, date, url: `https://proxmenux.com/${locale}/changelog#${date}`, title: `${strings.itemUpdatePrefix} ${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( _req: Request, { params }: { params: Promise<{ locale: string }> }, ) { const { locale } = await params const strings = STRINGS[locale] ?? STRINGS.en const entries = await parseChangelog(locale, strings) const siteUrl = "https://proxmenux.com" const channelImage = entries.find((e) => e.image)?.image ?? DEFAULT_CHANNEL_IMAGE const feedUrl = `${siteUrl}/${locale}/rss.xml` const changelogUrl = `${siteUrl}/${locale}/changelog` const rssXml = ` ${escapeXml(strings.channelTitle)} ${escapeXml(strings.channelDescription)} ${changelogUrl} ${strings.lang} ${new Date().toUTCString()} ProxMenux RSS Generator 60 ${escapeXml(channelImage)} ${escapeXml(strings.channelTitle)} ${changelogUrl} ${entries .map( (entry) => ` ${escapeXml(entry.title)} ${escapeXml(entry.content.replace(/<[^>]*>/g, "").substring(0, 200))}... ${entry.url} ${entry.url} ${new Date(entry.date).toUTCString()} ${escapeXml(strings.category)}${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", }, }) }