commit 509770004976fe4fd124e5a5e35d28ca2cb5ec00
parent 43cb0d43b242b693741e0ec1594e4050064088bf
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 9 Oct 2026 17:53:36 -0400
report: a timeline's feeds — feed.xml (RSS 2.0) and feed.json (JSON Feed 1.1)
lib/report/feeds.ts renders both, pure: one item per entry newest first, its
permalink the page at its anchor, its body as the HTML export renders it with
every link absolute (a citation's marker to its reference on the page).
Compose writes them beside the page for a report with entries on a site with
a public siteUrl, and names them in the view (absolute URLs); a site with
none publishes no feed. _headers serves them as application/rss+xml and
application/feed+json, readable cross-origin; the bundle audit knows the
files as report data.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
8 files changed, 460 insertions(+), 6 deletions(-)
diff --git a/common/bin/compose-site.ts b/common/bin/compose-site.ts
@@ -41,7 +41,7 @@ import type { PostsManifest } from "../lib/posts";
import type { DigestsManifest } from "../lib/digests";
import { buildSiteDescriptor, type PublicSiteDescriptor } from "../lib/siteDescriptor";
import { shipsPwa } from "../lib/archive/contract";
-import { SITE_CORS_PATHS, renderHeadersFile } from "../lib/archive/headers";
+import { SITE_CORS_PATHS, SITE_TYPED_PATHS, renderHeadersFile } from "../lib/archive/headers";
import { effectiveSiteAliases } from "../lib/aliasesStore";
import { effectiveSiteTags } from "../lib/curatedTagsStore";
import { TAGS_FILENAME } from "../lib/curatedTags";
@@ -108,7 +108,7 @@ export async function emitFederationFiles(
): Promise<void> {
await writePublicFile(
path.join(paths.exportPublicDir, "_headers"),
- renderHeadersFile("compose-site.ts", SITE_CORS_PATHS, { noStore: opts.noStore }),
+ renderHeadersFile("compose-site.ts", SITE_CORS_PATHS, { noStore: opts.noStore, types: SITE_TYPED_PATHS }),
);
// A cited site has no summaries: its descriptor names no channel, and it is
diff --git a/common/lib/archive/headers.test.ts b/common/lib/archive/headers.test.ts
@@ -1,8 +1,10 @@
import { test } from "node:test";
import assert from "node:assert/strict";
+import { REPORT_FEED_FILENAMES, REPORT_FEED_FORMATS, REPORT_FEED_MIME } from "../report/views";
import {
HUB_CORS_PATHS,
SITE_CORS_PATHS,
+ SITE_TYPED_PATHS,
contractCorsPaths,
renderHeadersFile,
} from "./headers";
@@ -170,3 +172,31 @@ test("every rendered entry is one path line and one indented header", () => {
assert.equal(body[i + 1], " Access-Control-Allow-Origin: *");
}
});
+
+// A report's timeline feeds (lib/report/feeds.ts) are served with their own
+// media type, and readable cross-origin (no site CORS rule covers reports/).
+const SITE_TYPES_BLOCK = `# Served with their own media type.
+/reports/:report/feed.xml
+ Content-Type: application/rss+xml; charset=utf-8
+ Access-Control-Allow-Origin: *
+/reports/:report/feed.json
+ Content-Type: application/feed+json; charset=utf-8
+ Access-Control-Allow-Origin: *
+`;
+
+test("renderHeadersFile: a site's typed paths follow its CORS lines, before the no-store block", () => {
+ assert.equal(renderHeadersFile("compose-site.ts", SITE_CORS_PATHS, { types: SITE_TYPED_PATHS }), SITE_HEADERS + SITE_TYPES_BLOCK);
+ assert.equal(
+ renderHeadersFile("compose-site.ts", SITE_CORS_PATHS, { types: SITE_TYPED_PATHS, noStore: ["/posts/manifest.json", "/posts/jer-x/*"] }),
+ SITE_HEADERS + SITE_TYPES_BLOCK + SITE_TOMBSTONE_BLOCK,
+ );
+ // No types, no block.
+ assert.equal(renderHeadersFile("compose-site.ts", SITE_CORS_PATHS, { types: [] }), SITE_HEADERS);
+});
+
+test("the typed paths are the report feeds' files and media types", () => {
+ assert.deepEqual(
+ SITE_TYPED_PATHS,
+ REPORT_FEED_FORMATS.map((f) => ({ path: `/reports/:report/${REPORT_FEED_FILENAMES[f]}`, type: `${REPORT_FEED_MIME[f]}; charset=utf-8` })),
+ );
+});
diff --git a/common/lib/archive/headers.ts b/common/lib/archive/headers.ts
@@ -78,6 +78,19 @@ export const HUB_CORS_PATHS: readonly string[] = [
// — are marked uncacheable. publish/tombstones.ts names the paths.
const NO_STORE_HEADER = "Cache-Control: no-store";
+// What a SITE serves with its own media type (a `_headers` rule for a path the
+// origin never serves costs nothing): a report's timeline feeds
+// (lib/report/feeds.ts), which Pages would otherwise serve as plain
+// `application/xml` and `application/json` — a feed reader and the page's
+// `<link rel="alternate">` want them named. Readable cross-origin, as every
+// other document a site serves: a web feed reader fetches from its own origin.
+// Kept in step with lib/report/views.ts (REPORT_FEED_FILENAMES, REPORT_FEED_MIME)
+// by headers.test.ts.
+export const SITE_TYPED_PATHS: readonly { path: string; type: string }[] = [
+ { path: "/reports/:report/feed.xml", type: "application/rss+xml; charset=utf-8" },
+ { path: "/reports/:report/feed.json", type: "application/feed+json; charset=utf-8" },
+];
+
// Whether a `_headers` path rule (a literal path, or one ending in a `*` splat)
// matches `p`, itself a literal path or a splat rule. A splat rule covers every
// path under its prefix.
@@ -90,7 +103,10 @@ function ruleCovers(rule: string, p: string): boolean {
// indented-header pair per served surface. `generator` is the script name so a
// reader of a deploy artifact knows what to edit instead of the file.
//
-// `noStore` adds, after the CORS lines, one rule per path marked
+// `types` adds, after the CORS lines, one rule per path served with its own
+// `Content-Type` (and CORS, where no rule above covers it — see below).
+//
+// `noStore` adds, after the CORS lines and the types, one rule per path marked
// `Cache-Control: no-store`. It carries the CORS header too — but only where no
// CORS rule above already covers the path: every matching rule applies, and a
// header a later rule sets again is APPENDED (wrangler's attachHeaders), so a
@@ -100,12 +116,20 @@ function ruleCovers(rule: string, p: string): boolean {
export function renderHeadersFile(
generator: string,
paths: readonly string[] = SITE_CORS_PATHS,
- opts: { noStore?: readonly string[] } = {},
+ opts: { noStore?: readonly string[]; types?: readonly { path: string; type: string }[] } = {},
): string {
const out = [`# Generated by ${generator} — do not edit by hand.`];
for (const p of paths) {
out.push(p, ` ${CORS_HEADER}`);
}
+ const types = opts.types ?? [];
+ if (types.length > 0) {
+ out.push("# Served with their own media type.");
+ for (const t of types) {
+ out.push(t.path, ` Content-Type: ${t.type}`);
+ if (!paths.some((rule) => ruleCovers(rule, t.path))) out.push(` ${CORS_HEADER}`);
+ }
+ }
const noStore = opts.noStore ?? [];
if (noStore.length > 0) {
out.push("# Withdrawn content (tombstones): never stored at the edge.");
diff --git a/common/lib/builtExport.ts b/common/lib/builtExport.ts
@@ -21,6 +21,7 @@ import {
MOMENTS_INDEX_PATH,
REPORTS_INDEX_PATH,
REPORT_EXPORT_FILENAMES,
+ REPORT_FEED_FILENAMES,
momentViewPath,
reportCitationsDownloadPath,
reportViewPath,
@@ -522,12 +523,14 @@ const HUB_FORBIDDEN_TREES = [
// The files only a site's reports stage writes (lib/report/views.ts names
// them; publish/composeReports.ts writes them), by name within reports/<id>/
// and m/…: the report index, each report's page view, its citations, its
-// exports and its revision history; the moment index and each moment view.
+// exports, its timeline's feeds and its revision history; the moment index
+// and each moment view.
const REPORT_DATA_FILES = new Set<string>([
path.posix.basename(reportViewPath("x")),
path.posix.basename(reportCitationsDownloadPath("x", "json")),
path.posix.basename(reportCitationsDownloadPath("x", "csv")),
...Object.values(REPORT_EXPORT_FILENAMES),
+ ...Object.values(REPORT_FEED_FILENAMES),
// reportHistory.ts: `history/history.json` beside a report's page.
"history.json",
]);
diff --git a/common/lib/report/feeds.test.ts b/common/lib/report/feeds.test.ts
@@ -0,0 +1,133 @@
+// A report's timeline as feeds (./feeds.ts): RSS 2.0 and JSON Feed 1.1 —
+// newest first, every link absolute, everything escaped.
+
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import {
+ JSON_FEED_VERSION,
+ escapeXml,
+ markdownPlainText,
+ renderReportJsonFeed,
+ renderReportRss,
+ reportFeedUrls,
+ rfc3339Date,
+ rfc822Date,
+} from "./feeds";
+import type { Report } from "./schema";
+import { validateReport } from "./validate";
+import { buildReportPageView, type ReportPageView } from "./views";
+
+const SITE = "https://site.example";
+
+function report(): Report {
+ return {
+ format: "archilyzer-report",
+ version: 1,
+ id: "living",
+ kind: "sweep",
+ series: "Watch",
+ title: "Bits & <pieces>",
+ summary: "It *starts* [here](cite:v1).",
+ published: "2026-10-01",
+ citations: {
+ v1: { kind: "video", channel: "demo-channel", id: "abc123", start: 10, end: 20, quote: "one" },
+ w1: { kind: "page", url: "https://example.org/p", title: "A page", quote: "two" },
+ },
+ entries: [
+ { id: "older", date: "2026-10-02", title: "First & \"quoted\"", body: "It [began](cite:w1) <b>here</b>." },
+ {
+ id: "newer",
+ date: "2026-10-05T09:30:00Z",
+ updated: "2026-10-06",
+ title: "Second",
+ body: "See [the section](#s1), [the index](/reports/) and [one](cite:v1).",
+ },
+ ],
+ sections: [{ id: "s1", title: "Section", body: "A body." }],
+ };
+}
+
+const view = (r: Report = report()): ReportPageView => {
+ assert.deepEqual(validateReport(r), []);
+ return buildReportPageView(r, {
+ record: (c) => ({ channel: c.channel, id: c.id }),
+ feeds: reportFeedUrls(SITE, r.id),
+ });
+};
+
+test("feed URLs beside the report's page", () => {
+ assert.deepEqual(reportFeedUrls(SITE, "living"), {
+ rss: "https://site.example/reports/living/feed.xml",
+ json: "https://site.example/reports/living/feed.json",
+ });
+});
+
+test("escaping: the five XML escapes, and the characters XML forbids dropped", () => {
+ assert.equal(escapeXml(`a & b < c > d "e" 'f'`), "a & b < c > d "e" 'f'");
+ assert.equal(escapeXml("bell\u0007 tab\t ok"), "bell tab\t ok");
+ assert.equal(markdownPlainText("It *starts* [here](cite:v1).\n\n> quoted `code`"), "It starts here. quoted code");
+});
+
+test("dates: a day is midnight UTC, RFC 822 for RSS and RFC 3339 for JSON Feed", () => {
+ assert.equal(rfc822Date("2026-10-02"), "Fri, 02 Oct 2026 00:00:00 GMT");
+ assert.equal(rfc822Date("2026-10-05T11:30:00+02:00"), "Mon, 05 Oct 2026 09:30:00 GMT");
+ assert.equal(rfc3339Date("2026-10-02"), "2026-10-02T00:00:00.000Z");
+ assert.equal(rfc822Date("nope"), undefined);
+ assert.equal(rfc3339Date(undefined), undefined);
+});
+
+test("RSS 2.0: the channel, then one item per entry newest first, absolute links, escaped", () => {
+ const xml = renderReportRss(view(), { siteUrl: SITE });
+ assert.match(xml, /^<\?xml version="1\.0" encoding="UTF-8"\?>\n<rss version="2\.0" xmlns:atom="http:\/\/www\.w3\.org\/2005\/Atom">\n<channel>\n/);
+ assert.match(xml, /<title>Watch: Bits & <pieces><\/title>/);
+ assert.match(xml, /<link>https:\/\/site\.example\/reports\/living\/<\/link>/);
+ // No subtitle: the summary as plain text.
+ assert.match(xml, /<description>It starts here\.<\/description>/);
+ assert.match(xml, /<atom:link href="https:\/\/site\.example\/reports\/living\/feed\.xml" rel="self" type="application\/rss\+xml"\/>/);
+ // The newest entry's update is the report's.
+ assert.match(xml, /<lastBuildDate>Tue, 06 Oct 2026 00:00:00 GMT<\/lastBuildDate>/);
+ const items = [...xml.matchAll(/<item>([\s\S]*?)<\/item>/g)].map((m) => m[1]);
+ assert.equal(items.length, 2);
+ assert.match(items[0], /<title>Second<\/title>/);
+ assert.match(items[0], /<link>https:\/\/site\.example\/reports\/living\/#newer<\/link>/);
+ assert.match(items[0], /<guid isPermaLink="true">https:\/\/site\.example\/reports\/living\/#newer<\/guid>/);
+ assert.match(items[0], /<pubDate>Mon, 05 Oct 2026 09:30:00 GMT<\/pubDate>/);
+ assert.match(items[1], /<title>First & "quoted"<\/title>/);
+ // The body is HTML, escaped as text: a fragment link to the page, a
+ // site-root link to the site, a citation's marker to its reference on the page.
+ assert.match(items[0], /<a href="https:\/\/site\.example\/reports\/living\/#s1">the section<\/a>/);
+ assert.match(items[0], /<a href="https:\/\/site\.example\/reports\/">the index<\/a>/);
+ assert.match(items[0], /one<sup class="cite"><a href="https:\/\/site\.example\/reports\/living\/#c-v1">\[1\]/);
+ // Raw HTML in an entry is text, escaped twice over in the feed.
+ assert.match(items[1], /&lt;b&gt;here&lt;\/b&gt;/);
+ assert.doesNotMatch(xml, /<b>/);
+ assert.match(xml, /<\/channel>\n<\/rss>\n$/);
+});
+
+test("JSON Feed 1.1: the same items newest first, ids their permalinks, dates RFC 3339", () => {
+ const feed = renderReportJsonFeed(view(), { siteUrl: SITE });
+ assert.equal(feed.version, JSON_FEED_VERSION);
+ assert.equal(feed.title, "Watch: Bits & <pieces>");
+ assert.equal(feed.home_page_url, "https://site.example/reports/living/");
+ assert.equal(feed.feed_url, "https://site.example/reports/living/feed.json");
+ assert.equal(feed.description, "It starts here.");
+ assert.deepEqual(
+ feed.items.map((i) => [i.id, i.url, i.title, i.date_published, i.date_modified]),
+ [
+ ["https://site.example/reports/living/#newer", "https://site.example/reports/living/#newer", "Second", "2026-10-05T09:30:00.000Z", "2026-10-06T00:00:00.000Z"],
+ ["https://site.example/reports/living/#older", "https://site.example/reports/living/#older", "First & \"quoted\"", "2026-10-02T00:00:00.000Z", undefined],
+ ],
+ );
+ assert.equal(
+ feed.items[1].content_html,
+ '<p>It began<sup class="cite"><a href="https://site.example/reports/living/#c-w1">[2]</a></sup> <b>here</b>.</p>',
+ );
+ assert.equal("date_modified" in feed.items[1], false);
+ // A round trip through JSON is the same feed.
+ assert.deepEqual(JSON.parse(JSON.stringify(feed)), feed);
+});
+
+test("the description: the subtitle when there is one, else the title when there is no summary", () => {
+ assert.equal(renderReportJsonFeed(view({ ...report(), subtitle: "A line" }), { siteUrl: SITE }).description, "A line");
+ assert.equal(renderReportJsonFeed(view({ ...report(), summary: undefined }), { siteUrl: SITE }).description, "Watch: Bits & <pieces>");
+});
diff --git a/common/lib/report/feeds.ts b/common/lib/report/feeds.ts
@@ -0,0 +1,194 @@
+// 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, """)
+ .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<ReportPageView, "id">, 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<ReportPageView, "id">, entry: Pick<EntryView, "id">, 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<ReportPageView, "id" | "citations">, 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[] = [
+ `<?xml version="1.0" encoding="UTF-8"?>`,
+ `<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">`,
+ `<channel>`,
+ `<title>${escapeXml(reportFullTitle(view))}</title>`,
+ `<link>${escapeXml(page)}</link>`,
+ `<description>${escapeXml(feedDescription(view))}</description>`,
+ `<atom:link href="${escapeXml(self)}" rel="self" type="application/rss+xml"/>`,
+ ...(built ? [`<lastBuildDate>${built}</lastBuildDate>`] : []),
+ `<generator>Archilyzer</generator>`,
+ ];
+ for (const e of view.entries ?? []) {
+ const url = entryUrl(view, e, opts.siteUrl);
+ const pub = rfc822Date(e.date);
+ out.push(
+ `<item>`,
+ `<title>${escapeXml(e.title)}</title>`,
+ `<link>${escapeXml(url)}</link>`,
+ `<guid isPermaLink="true">${escapeXml(url)}</guid>`,
+ ...(pub ? [`<pubDate>${pub}</pubDate>`] : []),
+ `<description>${escapeXml(entryContentHtml(view, e, opts.siteUrl))}</description>`,
+ `</item>`,
+ );
+ }
+ out.push(`</channel>`, `</rss>`);
+ 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 } : {}),
+ };
+ }),
+ };
+}
diff --git a/common/publish/composeReports.test.ts b/common/publish/composeReports.test.ts
@@ -589,6 +589,57 @@ test("a site with no reports ships none of the last site's", async () => {
assert.equal(corpus.reports, undefined);
});
+test("a report's timeline: its feeds beside the page on a site with a public URL, none without", async () => {
+ const withTimeline = {
+ ...report(),
+ entries: [
+ { id: "e-old", date: "2026-10-02", title: "First & <update>", body: "It [opened](cite:c01) after all." },
+ { id: "e-new", date: "2026-10-05T08:30:00Z", title: "Second update", body: "Nothing new." },
+ ],
+ };
+ for (const [siteId, extra] of [
+ ["timeline", {}],
+ ["timeline-private", { siteUrl: undefined, audience: "private" }],
+ ] as const) {
+ seedSite(siteId, { search: false, reports: [REPORT], ...extra });
+ writeJson(path.join(paths.sitesDir, siteId, "reports", REPORT, "report.json"), withTimeline);
+ }
+
+ await compose("timeline");
+ const page = readJson<{ entries: { id: string }[]; updated?: string; feeds?: Record<string, string> }>(pub("reports", REPORT, "page.json"));
+ assert.deepEqual(page.entries.map((e) => e.id), ["e-new", "e-old"]);
+ assert.equal(page.updated, "2026-10-05T08:30:00Z");
+ assert.deepEqual(page.feeds, {
+ rss: `https://timeline.example.test/reports/${REPORT}/feed.xml`,
+ json: `https://timeline.example.test/reports/${REPORT}/feed.json`,
+ });
+ const index = readJson<{ reports: { updated?: string }[] }>(pub("reports", "index.json"));
+ assert.equal(index.reports[0].updated, "2026-10-05T08:30:00Z");
+ const rss = readFileSync(pub("reports", REPORT, "feed.xml"), "utf8");
+ const items = [...rss.matchAll(/<link>([^<]+)<\/link>/g)].map((m) => m[1]);
+ assert.deepEqual(items, [
+ `https://timeline.example.test/reports/${REPORT}/`,
+ `https://timeline.example.test/reports/${REPORT}/#e-new`,
+ `https://timeline.example.test/reports/${REPORT}/#e-old`,
+ ]);
+ assert.match(rss, /<title>First & <update><\/title>/);
+ const feed = readJson<{ version: string; items: { id: string }[] }>(pub("reports", REPORT, "feed.json"));
+ assert.equal(feed.version, "https://jsonfeed.org/version/1.1");
+ assert.deepEqual(feed.items.map((i) => i.id.split("#")[1]), ["e-new", "e-old"]);
+ // Served as what they are, and readable cross-origin.
+ assert.match(readFileSync(pub("_headers"), "utf8"), /\/reports\/:report\/feed\.xml\n {2}Content-Type: application\/rss\+xml; charset=utf-8\n/);
+ // The cited audit allows them (reports/ is the stage's own).
+ assert.equal(citedBuildProblem(paths.exportPublicDir), null);
+
+ // No public URL: the page and its timeline, no feed — absolute links have nowhere to point.
+ await compose("timeline-private");
+ const priv = readJson<{ entries: unknown[]; feeds?: unknown }>(pub("reports", REPORT, "page.json"));
+ assert.equal(priv.entries.length, 2);
+ assert.equal(priv.feeds, undefined);
+ assert.ok(!existsSync(pub("reports", REPORT, "feed.xml")));
+ assert.ok(!existsSync(pub("reports", REPORT, "feed.json")));
+});
+
// ─── Exports (publish/reportExports.ts) ───
const { exportSiteReports } = await import("./reportExports");
diff --git a/common/publish/composeReports.ts b/common/publish/composeReports.ts
@@ -37,6 +37,11 @@
// history/repo/… dumb-HTTP clone of them, when
// `reports export` has committed
// one (./reportHistory.ts)
+// reports/<id>/feed.{xml,json} its timeline as RSS 2.0 and JSON
+// Feed 1.1 (lib/report/feeds.ts), for
+// a report with entries on a site
+// with a public `siteUrl` (a feed's
+// links are absolute: none without)
// reports/<id>/report.{html,pdf,md}, the report's exports, when
// evidence-pack.zip `archilyzer reports export` made
// them from the report as it is now
@@ -60,7 +65,7 @@ import path from "node:path";
import { publishFileSizeProblem } from "../lib/builtExport";
import type { Paths } from "../lib/paths";
import { siteChannelSlugs, type Site } from "../lib/site";
-import { isCitedSite } from "../lib/siteSchema";
+import { isCitedSite, parseSiteUrl } from "../lib/siteSchema";
import { getSettings } from "../lib/settings";
import { postsVisibleTo } from "../lib/postsVisibility";
import { assertChannelTextReadable } from "../lib/channelMedia";
@@ -89,6 +94,7 @@ import { momentKeyOf, momentPath, parseMomentKey, type SpanMoment } from "../lib
import { CITATIONS_VERSION, type Citation, type PostCitation, type SpanCitation } from "../lib/citations/schema";
import { cueWindowText, quoteDrifted, quoteVerification, QUOTE_DRIFT_THRESHOLD } from "../lib/citations/verify";
import { buildCitedIn } from "../lib/report/citedIn";
+import { renderReportJsonFeed, renderReportRss, reportFeedUrls } from "../lib/report/feeds";
import { reportHistoryPagePath } from "../lib/report/revisions";
import type { Report } from "../lib/report/schema";
import { reportCitationNumbers } from "../lib/report/uses";
@@ -106,6 +112,7 @@ import {
orderedCitations,
reportCitationsDownloadPath,
reportExportDownloadPath,
+ reportFeedPath,
reportIndexEntry,
reportViewPath,
REPORT_EXPORT_FORMATS,
@@ -117,6 +124,7 @@ import {
type ReportIndexEntry,
type ReportIndexView,
type ReportDownloads,
+ type ReportFeeds,
type ReportPageView,
} from "../lib/report/views";
import { citedEvidenceSpan, isAudioOnlyPlatform, type EvidenceSpan } from "../lib/evidenceClip-server";
@@ -442,6 +450,8 @@ export type ResolveSiteReportsOptions = Omit<ComposeReportsOptions, "publicDir">
downloads?: (reportId: string) => ReportDownloads | undefined;
// Each report's newest revision, as its view carries it.
history?: (reportId: string) => ReportHistoryRef | undefined;
+ // Each report's feeds, as its view carries them (only one with entries).
+ feeds?: (reportId: string) => ReportFeeds | undefined;
};
// The site's reports resolved against the corpus — verified, their views and
@@ -732,6 +742,7 @@ export async function resolveSiteReports(opts: ResolveSiteReportsOptions): Promi
},
downloads: opts.downloads?.(report.id),
history: opts.history?.(report.id),
+ feeds: opts.feeds?.(report.id),
}),
);
@@ -858,9 +869,12 @@ export async function composeReports(opts: ComposeReportsOptions): Promise<Compo
}
}
if (historyProblems.length > 0) throw new ComposeReportsError(historyProblems);
+ // A timeline's feeds link absolutely: a site with no public URL publishes none.
+ const siteUrl = parseSiteUrl(site.siteUrl);
const { reports, views, index, moments, mediaOf, cacheDir, allowed } = await resolveSiteReports({
...opts,
history: (id) => histories.get(id)?.ref,
+ feeds: siteUrl ? (id) => reportFeedUrls(siteUrl, id) : undefined,
downloads: (id) => ({
...Object.fromEntries(
REPORT_EXPORT_FORMATS.filter((f) => exportsOf.get(id)?.files[f]).map((f) => [f, reportExportDownloadPath(id, f)]),
@@ -879,6 +893,11 @@ export async function composeReports(opts: ComposeReportsOptions): Promise<Compo
await writeOut(publicDir, reportViewPath(report.id), json(view));
await writeOut(publicDir, reportCitationsDownloadPath(report.id, "json"), json(citationSet(report, view)));
await writeOut(publicDir, reportCitationsDownloadPath(report.id, "csv"), citationsCsv(view));
+ if (siteUrl && view.feeds) {
+ await writeOut(publicDir, reportFeedPath(report.id, "rss"), renderReportRss(view, { siteUrl }));
+ await writeOut(publicDir, reportFeedPath(report.id, "json"), json(renderReportJsonFeed(view, { siteUrl })));
+ log(`[reports] ${report.id}: feeds of ${view.entries?.length ?? 0} timeline entr${view.entries?.length === 1 ? "y" : "ies"}.`);
+ }
if (report.video && view.video) {
const dir = siteReportDir(paths, site.siteId, report.id);
await copyOut(publicDir, path.join(dir, report.video.src), view.video.src);