// Feed generation from a route's enumerated items. All three formats come from // one item list, because a site that ships RSS but not JSON Feed has simply // picked a winner for its readers. import { type Clock, systemClock } from '@ultimat3/core'; import { epochOf, isoOf, newestEpoch, nowEpoch, rfc822Of } from './feed-dates'; import { absoluteUrl, cdata, escapeXml, xmlElement } from './xml'; export interface FeedAuthor { name: string; email?: string; url?: string; } export interface FeedItem { /** Stable, permanent identifier. The canonical URL is a fine choice. */ id: string; url: string; title: string; /** ISO 8601. */ published: string; updated?: string; summary?: string; /** Full HTML body. Emitted as CDATA in RSS, escaped in Atom. */ contentHtml?: string; author?: FeedAuthor; tags?: readonly string[]; image?: string; } export interface FeedChannel { title: string; description: string; /** Absolute site root. */ siteUrl: string; /** Path or absolute URL of the feed itself. */ feedUrl: string; /** BCP-47. */ language: string; /** Defaults to the newest item's timestamp. */ updated?: string; author?: FeedAuthor; copyright?: string; icon?: string; } export interface Feed { readonly rss: string; readonly atom: string; readonly json: string; } export interface BuildFeedOptions { /** * Where "now" comes from when no item carries a usable timestamp. Defaults to `systemClock`; * pass `frozenClock(at)` and the three formats are byte-for-byte reproducible. */ clock?: Clock; } /** * An item with its timestamps already resolved, so no builder below ever parses a date. A date the * app supplied that will not parse is `undefined` here, and every format omits the element rather * than emitting `Invalid Date` — a feed reader is entitled to reject a document over one, and the * other twenty entries are fine. */ interface DatedItem { readonly item: FeedItem; readonly published: number | undefined; readonly updated: number | undefined; } function dated(item: FeedItem): DatedItem { return { item, published: epochOf(item.published), updated: epochOf(item.updated) }; } /** * Newest first. An item with no usable `published` sorts last, rather than making the comparator * answer `NaN` — which hands the order of the whole feed to the engine's sort implementation. */ function byNewest(a: DatedItem, b: DatedItem): number { if (a.published === undefined) return b.published === undefined ? 0 : 1; if (b.published === undefined) return -1; return b.published - a.published; } /** Atom's person construct (RFC 4287 §3.2): a name, and whichever of email and uri is known. */ function atomAuthor(author: FeedAuthor): string { const email = author.email === undefined ? '' : xmlElement('email', author.email); const uri = author.url === undefined ? '' : xmlElement('uri', author.url); return `${xmlElement('name', author.name)}${email}${uri}`; } /** RSS 2.0's `` IS an e-mail address; a name with none is Dublin Core's `dc:creator`. */ const needsCreator = (author: FeedAuthor | undefined): author is FeedAuthor => author !== undefined && author.email === undefined; function rssAuthor(author: FeedAuthor): string { return author.email === undefined ? xmlElement('dc:creator', author.name) : xmlElement('author', `${author.email} (${author.name})`); } function buildRss(channel: FeedChannel, items: readonly DatedItem[], updated: number): string { const self = absoluteUrl(channel.siteUrl, channel.feedUrl); // Declared only when used: a namespace on every feed would change bytes no reader asked for. const dublinCore = items.some(({ item }) => needsCreator(item.author)) ? ' xmlns:dc="http://purl.org/dc/elements/1.1/"' : ''; // Media RSS, not ``: RSS 2.0 requires a byte `length` there and nothing here has one. const media = items.some(({ item }) => item.image !== undefined) ? ' xmlns:media="http://search.yahoo.com/mrss/"' : ''; const entries = items .map(({ item, published }) => { const parts = [ xmlElement('title', item.title), xmlElement('link', item.url), ` ${escapeXml(item.id)}`, ]; if (published !== undefined) parts.push(xmlElement('pubDate', rfc822Of(published))); if (item.author !== undefined) parts.push(rssAuthor(item.author)); if (item.summary !== undefined) parts.push(xmlElement('description', item.summary)); if (item.contentHtml !== undefined) { parts.push(` ${cdata(item.contentHtml)}`); } for (const tag of item.tags ?? []) parts.push(xmlElement('category', tag)); if (item.image !== undefined) { const url = escapeXml(absoluteUrl(channel.siteUrl, item.image)); parts.push(` `); } return ` \n${parts.map((line) => (line.startsWith(' ') ? line : ` ${line}`)).join('\n')}\n `; }) .join('\n'); return [ '', ``, ' ', ` ${xmlElement('title', channel.title)}`, ` ${xmlElement('link', channel.siteUrl)}`, ` ${xmlElement('description', channel.description)}`, ` ${xmlElement('language', channel.language)}`, ` ${xmlElement('lastBuildDate', rfc822Of(updated))}`, ` `, channel.copyright === undefined ? '' : ` ${xmlElement('copyright', channel.copyright)}`, entries, ' ', '', '', ] .filter((line) => line !== '') .join('\n'); } function buildAtom(channel: FeedChannel, items: readonly DatedItem[], updated: number): string { const self = absoluteUrl(channel.siteUrl, channel.feedUrl); const entries = items .map(({ item, published, updated: itemUpdated }) => { const parts = [ ` ${xmlElement('title', item.title)}`, ` `, ` ${xmlElement('id', item.id)}`, // Atom requires on every entry, so an entry with no usable date of its own // carries the feed's — the one instant in the document that is always real. ` ${xmlElement('updated', isoOf(itemUpdated ?? published ?? updated))}`, ]; if (published !== undefined) { parts.push(` ${xmlElement('published', isoOf(published))}`); } if (item.summary !== undefined) parts.push(` ${xmlElement('summary', item.summary)}`); if (item.contentHtml !== undefined) { parts.push(` ${escapeXml(item.contentHtml)}`); } if (item.author !== undefined) parts.push(` ${atomAuthor(item.author)}`); for (const tag of item.tags ?? []) { parts.push(` `); } if (item.image !== undefined) { const href = escapeXml(absoluteUrl(channel.siteUrl, item.image)); parts.push(` `); } return ` \n${parts.join('\n')}\n `; }) .join('\n'); return ( [ '', ``, ` ${xmlElement('title', channel.title)}`, ` ${xmlElement('subtitle', channel.description)}`, ` ${xmlElement('id', channel.siteUrl)}`, ` ${xmlElement('updated', isoOf(updated))}`, ` `, ` `, // RFC 4287 §4.1.1: a feed carries an author unless every entry does. The channel's was read // by the JSON builder alone, so an Atom feed with authorless entries was not a valid one. channel.author === undefined ? '' : ` ${atomAuthor(channel.author)}`, channel.copyright === undefined ? '' : ` ${xmlElement('rights', channel.copyright)}`, channel.icon === undefined ? '' : ` ${xmlElement('icon', absoluteUrl(channel.siteUrl, channel.icon))}`, entries, '', '', ] // The trailing '' is the document's final newline; only an absent optional line is dropped. .filter((line, index, lines) => line !== '' || index === lines.length - 1) .join('\n') ); } function buildJsonFeed(channel: FeedChannel, items: readonly DatedItem[]): string { return `${JSON.stringify( { version: 'https://jsonfeed.org/version/1.1', title: channel.title, description: channel.description, home_page_url: channel.siteUrl, feed_url: absoluteUrl(channel.siteUrl, channel.feedUrl), language: channel.language, icon: channel.icon, authors: channel.author === undefined ? undefined : [channel.author], items: items.map(({ item, published, updated }) => ({ id: item.id, url: item.url, title: item.title, summary: item.summary, content_html: item.contentHtml, image: item.image, date_published: published === undefined ? undefined : isoOf(published), date_modified: updated === undefined ? undefined : isoOf(updated), tags: item.tags, authors: item.author === undefined ? undefined : [item.author], })), }, null, 2, )}\n`; } /** * RSS 2.0, Atom, and JSON Feed 1.1 from one item list. Never throws over a date: an unparseable * one is treated as absent, and the feed still renders for the items that have a real one. */ export function buildFeed( channel: FeedChannel, items: readonly FeedItem[], options: BuildFeedOptions = {}, ): Feed { const ordered = items.map(dated).sort(byNewest); const updated = epochOf(channel.updated) ?? newestEpoch(ordered.map((entry) => entry.updated ?? entry.published)) ?? nowEpoch(options.clock ?? systemClock); return { rss: buildRss(channel, ordered, updated), atom: buildAtom(channel, ordered, updated), json: buildJsonFeed(channel, ordered), }; }