/*
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`;
}