// A REPORT'S TIMELINE AS FEEDS — `feed.xml` (RSS 2.0) and `feed.json` (JSON // Feed 1.1) beside its page, so a reader can subscribe to a living report and // hear of each new entry (lib/report/entries.ts). Compose writes them // (publish/composeReports.ts) for a report with entries on a site with a // public URL — a feed is read off the site, so every link in it is absolute, // and a site with none (a private one) publishes no feed, as it publishes no // sitemap. // // One item per entry, newest first (the view's order): its title, its // permalink (the page at the entry's anchor), its dates, and its body as HTML // — the HTML export's renderer (./exportHtml.ts markdownToHtml), each citation // marker `[n]` linking to its reference on the report's page, every link // absolute. // // PURE: no I/O, deterministic for its input (no clock: the channel's date is // the report's own). import { citationAnchor } from "../citations/inline"; import { reportDateMs } from "./entries"; import { markdownToHtml, siteLink } from "./exportHtml"; import { REPORT_FEED_FORMATS, reportFeedPath, reportFullTitle, reportPagePath, type EntryView, type ReportFeeds, type ReportPageView, } from "./views"; export const JSON_FEED_VERSION = "https://jsonfeed.org/version/1.1"; export type ReportFeedOptions = { // The site's public URL: absolute http(s) (lib/siteSchema.ts parseSiteUrl). siteUrl: string; }; // A report's feed URLs on the site. export function reportFeedUrls(siteUrl: string, reportId: string): ReportFeeds { return Object.fromEntries( REPORT_FEED_FORMATS.map((f) => [f, siteLink(siteUrl, reportFeedPath(reportId, f))!]), ) as ReportFeeds; } // Text safe in XML character data or an attribute value: the five escapes, // and every character XML 1.0 forbids dropped. export function escapeXml(s: string): string { return s // eslint-disable-next-line no-control-regex -- the characters XML forbids .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\uFFFE\uFFFF]/g, "") .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } // Markdown as one line of plain text: a link (a citation's included) as its // label, the marks of emphasis, code, headings and quotes dropped. export function markdownPlainText(md: string): string { return md .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") .replace(/^\s{0,3}(?:#{1,6}\s+|>\s?|[-*+]\s+)/gm, "") .replace(/[*_`]+/g, "") .replace(/\s+/g, " ") .trim(); } // A report date as RFC 822 (RSS), or undefined when it does not parse. export function rfc822Date(v: string | undefined): string | undefined { const ms = reportDateMs(v); return Number.isNaN(ms) ? undefined : new Date(ms).toUTCString(); } // A report date as RFC 3339 (JSON Feed), or undefined when it does not parse. export function rfc3339Date(v: string | undefined): string | undefined { const ms = reportDateMs(v); return Number.isNaN(ms) ? undefined : new Date(ms).toISOString(); } // The report's page on the site. function pageUrlOf(view: Pick, siteUrl: string): string { return siteLink(siteUrl, reportPagePath(view.id))!; } // An entry's permalink: the page at its anchor (a reference id is safe in a // fragment as it is). export function entryUrl(view: Pick, entry: Pick, siteUrl: string): string { return `${pageUrlOf(view, siteUrl)}#${entry.id}`; } // An entry's body as HTML for a feed reader: citation markers link to their // references on the report's page; a fragment link goes to the page; a // site-root link to the site. export function entryContentHtml(view: Pick, entry: EntryView, siteUrl: string): string { const page = pageUrlOf(view, siteUrl); return markdownToHtml( entry.body, (id) => { const c = view.citations[id]; return c ? { number: c.number, anchor: citationAnchor(id), href: `${page}#${citationAnchor(id)}` } : undefined; }, { siteUrl, fragmentBase: page, headingBase: 2 }, ); } // The feed's description: the subtitle, else the summary as plain text, else // the title. function feedDescription(view: ReportPageView): string { if (view.subtitle) return view.subtitle; const summary = view.summary ? markdownPlainText(view.summary) : ""; return summary || reportFullTitle(view); } // When the feed last changed: the report's (its view's `updated` already // counts its newest entry), else its newest entry's, else its publication. function feedDate(view: ReportPageView): string | undefined { return view.updated ?? view.entries?.[0]?.date ?? view.published; } // RSS 2.0 (with an Atom self link, as validators ask). export function renderReportRss(view: ReportPageView, opts: ReportFeedOptions): string { const page = pageUrlOf(view, opts.siteUrl); const self = reportFeedUrls(opts.siteUrl, view.id).rss; const built = rfc822Date(feedDate(view)); const out: string[] = [ ``, ``, ``, `${escapeXml(reportFullTitle(view))}`, `${escapeXml(page)}`, `${escapeXml(feedDescription(view))}`, ``, ...(built ? [`${built}`] : []), `Archilyzer`, ]; for (const e of view.entries ?? []) { const url = entryUrl(view, e, opts.siteUrl); const pub = rfc822Date(e.date); out.push( ``, `${escapeXml(e.title)}`, `${escapeXml(url)}`, `${escapeXml(url)}`, ...(pub ? [`${pub}`] : []), `${escapeXml(entryContentHtml(view, e, opts.siteUrl))}`, ``, ); } out.push(``, ``); return `${out.join("\n")}\n`; } export type JsonFeedItem = { id: string; url: string; title: string; content_html: string; date_published?: string; date_modified?: string; }; export type JsonFeed = { version: typeof JSON_FEED_VERSION; title: string; home_page_url: string; feed_url: string; description: string; items: JsonFeedItem[]; }; // JSON Feed 1.1. An item's id is its permalink, which never changes. export function renderReportJsonFeed(view: ReportPageView, opts: ReportFeedOptions): JsonFeed { return { version: JSON_FEED_VERSION, title: reportFullTitle(view), home_page_url: pageUrlOf(view, opts.siteUrl), feed_url: reportFeedUrls(opts.siteUrl, view.id).json, description: feedDescription(view), items: (view.entries ?? []).map((e) => { const url = entryUrl(view, e, opts.siteUrl); const published = rfc3339Date(e.date); const modified = rfc3339Date(e.updated); return { id: url, url, title: e.title, content_html: entryContentHtml(view, e, opts.siteUrl), ...(published ? { date_published: published } : {}), ...(modified ? { date_modified: modified } : {}), }; }), }; }