/* RFC-0165/RFC-0317: pure RSS 2.0 + JSON Feed v1.1 formatters. Framework-agnostic — the site-OS feed.generate command supplies the channel + dated items and writes the result. Do not read files or fetch content — the caller supplies resolved items. RFC-0165: initial implementation. RFC-0317: add JSON Feed v1.1 output from the same item set. */ export interface FeedItem { title: string; /** Absolute URL. */ url: string; summary: string; /** ISO date. */ publishedAt: string; /** ISO date; defaults to publishedAt. */ updatedAt?: string; } export interface FeedChannel { title: string; /** Absolute site URL. */ url: string; description: string; language: string; /** Absolute URL of the feed itself (Atom self-link). */ selfUrl: string; } function escapeXml(value: string): string { return value .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function toRfc822(iso: string): string { const date = new Date(iso); return Number.isNaN(date.getTime()) ? iso : date.toUTCString(); } export function buildRssFeed(channel: FeedChannel, items: FeedItem[]): string { const sorted = [...items].sort( (a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime(), ); // Deterministic: derive lastBuildDate from item dates (never `now`), and omit it for an // empty feed so a no-article app produces a stable, churn-free file. const lastBuild = sorted[0]?.updatedAt ?? sorted[0]?.publishedAt; const itemXml = sorted .map((item) => [ " ", ` ${escapeXml(item.title)}`, ` ${escapeXml(item.url)}`, ` ${escapeXml(item.url)}`, ` ${escapeXml(item.summary)}`, ` ${escapeXml(toRfc822(item.publishedAt))}`, " ", ].join("\n"), ) .join("\n"); return [ '', '', " ", ` ${escapeXml(channel.title)}`, ` ${escapeXml(channel.url)}`, ` ${escapeXml(channel.description)}`, ` ${escapeXml(channel.language)}`, ...(lastBuild ? [` ${escapeXml(toRfc822(lastBuild))}`] : []), ` `, ...(itemXml ? [itemXml] : []), " ", "", "", ].join("\n"); } export interface JsonFeedInput { title: string; home_page_url: string; feed_url: string; description: string; language: string; items: FeedItem[]; } /** RFC-0317: JSON Feed v1.1 from the same item set as the RSS feed. */ export function buildJsonFeed(input: JsonFeedInput): string { const sorted = [...input.items].sort( (a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime(), ); const feed: Record = { version: "https://jsonfeed.org/version/1.1", title: input.title, description: input.description || undefined, home_page_url: input.home_page_url, feed_url: input.feed_url, language: input.language, }; if (sorted.length > 0) { feed.items = sorted.map((item) => ({ id: item.url, url: item.url, title: item.title, content_text: item.summary || undefined, date_published: item.publishedAt, ...(item.updatedAt ? { date_modified: item.updatedAt } : {}), })); } return `${JSON.stringify(feed, null, 2)}\n`; }