import { query } from "@/lib/db"; import { loadMenuEntries } from "@/lib/db/menu-entries"; import { listClosedDays } from "@/lib/db/closed-days"; import { addDays, entriesFor, formatPrice, mondayOf, todayISO } from "@/lib/menu"; import { localized, SITE_TITLE } from "@/lib/types"; import type { Section } from "@/lib/types"; // RSS-Feed mit dem Speiseplan – ein Eintrag pro Tag mit Gerichten, // damit man ihn in einem Feedreader abonnieren kann und täglich sieht, // was es gibt. Immer auf Deutsch: Feedreader senden kein Sprach-Cookie, // eine feste Sprache ist hier verständlicher als ein zufälliger Locale-Default. export const dynamic = "force-dynamic"; function escapeXml(text: string): string { return text.replace(/[&<>"']/g, (c) => { switch (c) { case "&": return "&"; case "<": return "<"; case ">": return ">"; case '"': return """; default: return "'"; } }); } function formatFeedDate(iso: string): string { const d = new Date(`${iso}T00:00:00Z`); return d.toLocaleDateString("de-DE", { weekday: "long", day: "numeric", month: "long", year: "numeric", timeZone: "UTC", }); } export async function GET() { const appUrl = (process.env.APP_URL ?? "http://localhost:3000").replace(/\/$/, ""); const monday = mondayOf(todayISO()); // Aktuelle + nächste Woche, wie auch die öffentliche Speisekarte per // Wochennavigation anzeigt. const rangeEnd = addDays(monday, 11); const [sections, entries, closedDays] = await Promise.all([ query
("select * from sections order by sort"), loadMenuEntries(monday, rangeEnd), listClosedDays(monday, rangeEnd), ]); const days = Array.from( new Set([...entries.map((e) => e.menu_date), ...closedDays.map((c) => c.closed_date)]) ).sort(); const items = days .map((iso) => { const closed = closedDays.find((c) => c.closed_date === iso); const dayContent = closed ? `

Geschlossen${ closed.reason ? ` – ${escapeXml(closed.reason)}` : "" }

` : sections .map((s) => { const dishes = entriesFor(entries, iso, s.key); if (dishes.length === 0) return ""; const list = dishes .map( (e) => `
  • ${escapeXml(localized(e.dish, "de"))} – ${formatPrice(e.dish.price)}
  • ` ) .join(""); return `

    ${escapeXml(localized(s, "de"))} (${escapeXml( s.time_range )})

    `; }) .join(""); if (!dayContent) return ""; const [y, m, d] = iso.split("-").map(Number); const pubDate = new Date(Date.UTC(y, m - 1, d, 6, 0, 0)).toUTCString(); return ` ${escapeXml(formatFeedDate(iso))} ${appUrl}/?woche=${mondayOf(iso)} ${appUrl}/feed.xml#${iso} ${pubDate} `; }) .join("\n"); // "description" gilt bei vielen Feed-Readern (Thunderbird, Outlook, // etliche Apps) als reine Kurzfassung/Vorschau und wird eigenmächtig // gekürzt – dadurch war z. B. bei mehreren Essenszeiten pro Tag oft nur // die erste sichtbar. "content:encoded" (RSS-Content-Modul) gilt als // vollständiger Artikeltext und wird von den meisten Readern // ungekürzt angezeigt, wenn vorhanden – redundant zu description für // Reader ohne Unterstützung des Moduls. const xml = ` ${escapeXml(SITE_TITLE)} – Speiseplan ${appUrl} Täglicher Speiseplan der Kantine de ${new Date().toUTCString()} ${items} `; return new Response(xml, { headers: { "Content-Type": "application/rss+xml; charset=utf-8", "Cache-Control": "public, max-age=1800", }, }); }