commit b56921eda36d3ea632d9c2c446c9677745492d1f
parent 137a7ae7f8c5b843e3fb0eb601e764d9f3047042
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 9 Oct 2026 18:41:17 -0400
Merge reports/timeline-feeds (a living report's dated entries: the Timeline region, a slide per entry, RSS + JSON feeds) into r19/integration
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
43 files changed, 1628 insertions(+), 114 deletions(-)
diff --git a/REPORT.md b/REPORT.md
@@ -4,9 +4,9 @@
One cited report, format `"archilyzer-report"`, version 1, persisted to `transcripts/sites/<siteId>/reports/<reportId>/report.json` beside its `stills/` and `sources/<sourceId>/`; a relative path in it is relative to that directory. A site's `reports` list in `site.json` is the published, ordered list — see [SITE.md](SITE.md); a report directory it does not name is a draft. The schema is `common/lib/report/schema.ts`; its citations and sources are the citation model's — see [CITATIONS.md](CITATIONS.md). A `notes.json` beside it holds the operator's notes on the article (umtool, `umtool notes`; [umtool/docs/notes.md](umtool/docs/notes.md)) and is never published.
-A **fact-check** (`"kind": "factcheck"`) is sections (chapters) of claims, each with a verdict and its findings. A **sweep** (`"kind": "sweep"`) is sections with no verdicts, or bodies that cite inline. Markdown fields (`summary`, a section's `body`, a claim's `findings`) cite with `[label](cite:<id>)`.
+A **fact-check** (`"kind": "factcheck"`) is sections (chapters) of claims, each with a verdict and its findings. A **sweep** (`"kind": "sweep"`) is sections with no verdicts, or bodies that cite inline. Either may keep a **timeline** (`entries`): dated entries appended over time. Markdown fields (`summary`, an entry's `body`, a section's `body`, a claim's `findings`) cite with `[label](cite:<id>)`.
-`common/lib/report/validate.ts` reports every problem with its JSON path: an unknown key, a reference that names nothing (a listed citation, a claim's source sentence, a `cite:` link, the subject), a section or claim id used twice (they share one namespace: the report page's anchors) or named for one of the page's own anchors, a sweep's claim with a verdict, `updated` before `published`, a slide field out of bounds (below), and every citation problem CITATIONS.md lists. Whether a still or the report's video exists, whether the video fits the publish limit, and whether a quote matches its cues are checked when the site is composed.
+`common/lib/report/validate.ts` reports every problem with its JSON path: an unknown key, a reference that names nothing (a listed citation, a claim's source sentence, a `cite:` link, the subject), a section, claim or entry id used twice (they share one namespace: the report page's anchors) or named for one of the page's own anchors, a sweep's claim with a verdict, `updated` before `published` (or an entry's before its `date`), a date that is not one, a slide field out of bounds (below), and every citation problem CITATIONS.md lists. Whether a still or the report's video exists, whether the video fits the publish limit, and whether a quote matches its cues are checked when the site is composed.
Regenerate this file with `pnpm --filter yt-dlp-transcript-common exec tsx bin/file-schemas-docs.ts`.
@@ -24,20 +24,21 @@ Regenerate this file with `pnpm --filter yt-dlp-transcript-common exec tsx bin/f
| `summary` | no | The report's summary, in markdown, shown before the sections. May cite inline: `[label](cite:<id>)`. |
| `method` | no | How the report was checked, in markdown (short; it cites nothing): shown under "How it was checked" at the start of the claims in full. |
| `published` | no | When the report was published: `YYYY-MM-DD` or an ISO 8601 date-time with a zone. |
-| `updated` | no | When it was last changed, in the same form; not before `published`. |
+| `updated` | no | When it was last changed, in the same form; not before `published`. A report with a timeline is shown as updated at its newest entry's `date` or `updated` when that is later. |
| `subject` | no | The document under review, when the report reviews one: `{ "source": "<id>" }`, an id in `sources`. |
| `video` | no | A video of the report, shown at the head of its page under the title: `{ "src": "video.mp4", "poster": "poster.jpg", "caption": "…" }`. `src` is an mp4 and `poster` an image (png, jpg or webp), both relative to the report's directory; the caption is one line. Absent = none. |
| `verdicts` | no | Overrides of the shared verdict vocabulary's labels and colours, by verdict (`CORROBORATED`, `PARTLY`, `CONTRADICTED`, `NOT_FOUND`, `UNTESTABLE`): `{ "label": "…", "color": "#rrggbb" }`, each key optional. Absent = the shared defaults. |
| `sources` | no | The documents the report's `source` citations quote, by id — see [CITATIONS.md](CITATIONS.md). Absent = none. |
| `citations` | no | The report's citations, by id — see [CITATIONS.md](CITATIONS.md). A citation is cited from markdown with `[label](cite:<id>)` and listed under the claims that rest on it. Absent = none. |
| `sections` | yes | The report's sections, in order. |
+| `entries` | no | The report's timeline: dated entries, appended over time and shown newest first under "Timeline", before the claims — see `entries[]` below. With entries and a public `siteUrl`, the report publishes feeds of them (`feed.xml`, RSS 2.0; `feed.json`, JSON Feed 1.1). Absent = none. |
| `slides` | no | How the report reads as slides (its page's Slides and Overview views, `slides.html`, `slides.pdf`) — see `slides` below. Absent = slides derived from its text. |
#### `sections[]`
| Key | Required | Description |
|---|---|---|
-| `id` | yes | The section's id (letters, digits, `_ . : -`; at most 64): its anchor on the report page. Unique among the report's section and claim ids, and none of the page's own anchors (`report-head`, `in-brief`, `found`, `claims`, `references`, `downloads`, `report-end`). |
+| `id` | yes | The section's id (letters, digits, `_ . : -`; at most 64): its anchor on the report page. Unique among the report's section, claim and entry ids, and none of the page's own anchors (`report-head`, `in-brief`, `found`, `claims`, `references`, `downloads`, `report-end`). |
| `title` | yes | The section's heading. |
| `body` | no | Markdown under the heading. May cite inline. |
| `claims` | no | The section's claims, in order. Absent = none. |
@@ -47,7 +48,7 @@ Regenerate this file with `pnpm --filter yt-dlp-transcript-common exec tsx bin/f
| Key | Required | Description |
|---|---|---|
-| `id` | yes | The claim's id (letters, digits, `_ . : -`; at most 64): its anchor on the report page. Unique among the report's section and claim ids, and none of the page's own anchors (`report-head`, `in-brief`, `found`, `claims`, `references`, `downloads`, `report-end`). |
+| `id` | yes | The claim's id (letters, digits, `_ . : -`; at most 64): its anchor on the report page. Unique among the report's section, claim and entry ids, and none of the page's own anchors (`report-head`, `in-brief`, `found`, `claims`, `references`, `downloads`, `report-end`). |
| `title` | no | A short headline for the claim (plain text, e.g. a phrase it turns on), shown above its text. Absent = the text alone. |
| `text` | yes | The claim, as stated by the document under review (plain text). |
| `verdict` | no | The ruling on the claim: `CORROBORATED`, `PARTLY`, `CONTRADICTED`, `NOT_FOUND`, `UNTESTABLE`. A fact-check's claim may leave it out (not yet ruled); a sweep's carries none. |
@@ -58,9 +59,23 @@ Regenerate this file with `pnpm --filter yt-dlp-transcript-common exec tsx bin/f
| `citations` | no | The citations the claim rests on, in the order they are listed under it. Each must exist; none twice. |
| `slide` | no | The claim's slide — see `slide` below. Absent = derived from the claim. |
+## The timeline
+
+A report may keep a timeline: `entries`, dated blocks a reader sees newest first under **Timeline**, after the summary and before what the check found and the claims — on its page, in its exports and as one slide each. Entries of the same instant keep their order in the file. Each entry's id is its anchor (`#<id>`). Its citations are numbered with the rest, in the page's order. A report with a newer entry than its own `updated` is shown as updated then (the index, the header, the feeds). On a site with a public `siteUrl`, compose publishes the timeline as feeds beside the page — `/reports/<id>/feed.xml` (RSS 2.0) and `/reports/<id>/feed.json` (JSON Feed 1.1), one item per entry, its permalink the page at its anchor — and the page links them ("Subscribe", and `<link rel="alternate">`). A site with no `siteUrl` publishes no feed.
+
+#### `entries[]`
+
+| Key | Required | Description |
+|---|---|---|
+| `id` | yes | The entry's id (letters, digits, `_ . : -`; at most 64): its anchor on the report page and its permalink in the feeds. Unique among the report's section, claim and entry ids, and none of the page's own anchors (`report-head`, `in-brief`, `found`, `claims`, `references`, `downloads`, `report-end`). |
+| `date` | yes | When the entry was added: `YYYY-MM-DD` or an ISO 8601 date-time with a zone. The timeline is newest first by this date; entries of the same instant keep their order here. |
+| `title` | yes | The entry's heading (plain text, one line). |
+| `body` | yes | The entry, in markdown. May cite inline, `[label](cite:<id>)`, as a section's body does; its citations are numbered with the rest, in the page's order (the timeline comes after the summary). |
+| `updated` | no | When the entry was last changed, in the same form; not before its `date`. Absent = never. |
+
## Slides
-A report is also read as slides — its page's **Slides** view (`?rv=slides`), the **Overview** that pairs each part of the article with its slide (`?rv=iso`), `slides.html` and `slides.pdf` — built by `common/lib/report/slides.ts` from the same view as the article: a title slide, "In brief", "What the check found" (a fact-check), one slide per section and per claim, then a sources slide. With no slide fields a report still has slides, derived from its text: a section shows the first two sentences of its body (else its claims), a claim its verdict, gist and the evidence of its first listed citation. The fields below make them good. A slide's points cite only what the article cites elsewhere, and its `cite` names a citation of its own section or claim. Every slide links back to its place in the article (the section's or the claim's anchor).
+A report is also read as slides — its page's **Slides** view (`?rv=slides`), the **Overview** that pairs each part of the article with its slide (`?rv=iso`), `slides.html` and `slides.pdf` — built by `common/lib/report/slides.ts` from the same view as the article: a title slide, "In brief", one per timeline entry (newest first), "What the check found" (a fact-check), one slide per section and per claim, then a sources slide. With no slide fields a report still has slides, derived from its text: a section shows the first two sentences of its body (else its claims), a claim its verdict, gist and the evidence of its first listed citation. The fields below make them good. A slide's points cite only what the article cites elsewhere, and its `cite` names a citation of its own section or claim. Every slide links back to its place in the article (the section's or the claim's anchor).
#### `slides`
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/components/report/slides/ReportOverview.tsx b/common/components/report/slides/ReportOverview.tsx
@@ -61,6 +61,8 @@ function kindName(s: SlideView): string {
return "The head";
case "summary":
return "In brief";
+ case "entry":
+ return `Timeline · ${s.date}`;
case "found":
return "What the check found";
case "section":
@@ -84,6 +86,9 @@ function BlockFace({ s, view }: { s: SlideView; view: ReportPageView }) {
case "summary":
line = s.text ? slidePointText(s.text) : s.points?.map(slidePointText).join(" · ");
break;
+ case "entry":
+ line = s.text ? slidePointText(s.text) : undefined;
+ break;
case "found":
line = s.groups.map((g) => `${g.claims.length} ${verdictStyleOf(view, g.verdict).label}`).join(" · ");
break;
diff --git a/common/components/report/slides/ReportSlides.tsx b/common/components/report/slides/ReportSlides.tsx
@@ -31,6 +31,8 @@ function kindLabel(s: SlideView): string {
return "Title";
case "summary":
return "In brief";
+ case "entry":
+ return "Timeline";
case "found":
return "Findings";
case "section":
diff --git a/common/components/report/slides/Slide.tsx b/common/components/report/slides/Slide.tsx
@@ -5,6 +5,7 @@ import { reportFullTitle, CITATION_KIND_LABELS, type CitationView, type ReportPa
import { VERDICT_DEFAULTS, type Verdict, type VerdictStyle } from "../../../lib/report/verdicts";
import type {
ClaimSlide,
+ EntrySlide,
FoundSlide,
SectionSlide,
SlideView,
@@ -44,6 +45,10 @@ function Eyebrow({ slide, total }: { slide: SlideView; total: number }) {
case "summary":
kind = "In brief";
break;
+ case "entry":
+ kind = "Timeline";
+ place = `${slide.index} of ${slide.count}`;
+ break;
case "found":
kind = "Findings";
place = `${slide.claimCount} claim${slide.claimCount === 1 ? "" : "s"} ruled`;
@@ -156,6 +161,21 @@ function SummaryBody({ slide, view }: { slide: SummarySlide; view: ReportPageVie
);
}
+// A timeline entry: its date first (what a timeline is read by), its title,
+// the first sentences of its body.
+function EntryBody({ slide }: { slide: EntrySlide }) {
+ const host = useSlidesHost();
+ return (
+ <>
+ <p className="rs-dates">
+ <time dateTime={slide.datetime}>{slide.date}</time>
+ </p>
+ <h2 className="rs-h">{slide.title}</h2>
+ {slide.text && <div className="rs-statement">{host.md(slide.text)}</div>}
+ </>
+ );
+}
+
function FoundBody({ slide, view }: { slide: FoundSlide; view: ReportPageView }) {
return (
<>
@@ -347,6 +367,9 @@ export function Slide({
case "summary":
body = <SummaryBody slide={slide} view={view} />;
break;
+ case "entry":
+ body = <EntryBody slide={slide} />;
+ break;
case "found":
body = <FoundBody slide={slide} view={view} />;
break;
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/citedIn.ts b/common/lib/report/citedIn.ts
@@ -1,7 +1,7 @@
// "CITED IN" — the back-link index a moment page reads: for every moment the
// given reports cite, each place that cites it.
//
-// moment key → [{ reportId, sectionId, claimId, citationId }]
+// moment key → [{ reportId, sectionId, claimId, entryId?, citationId }]
//
// Built at compose from the site's published reports, in the order given, each
// report in reading order (./uses.ts). A place is listed once however many
@@ -18,10 +18,12 @@ import { reportCitationUses } from "./uses";
export type CitedIn = {
reportId: string;
- // null when cited in the report's summary.
+ // null when cited in the report's summary or a timeline entry.
sectionId: string | null;
- // null when cited in the summary or a section's body.
+ // null when cited in the summary, an entry or a section's body.
claimId: string | null;
+ // The timeline entry, when cited in one; absent elsewhere.
+ entryId?: string;
citationId: string;
};
@@ -38,9 +40,10 @@ export function buildCitedIn(reports: readonly Report[]): Record<string, CitedIn
reportId: report.id,
sectionId: use.sectionId,
claimId: use.claimId,
+ ...(use.entryId !== undefined ? { entryId: use.entryId } : {}),
citationId: use.citationId,
};
- const dedup = JSON.stringify([key, entry.reportId, entry.sectionId, entry.claimId, entry.citationId]);
+ const dedup = JSON.stringify([key, entry.reportId, entry.sectionId, entry.claimId, entry.entryId ?? null, entry.citationId]);
if (seen.has(dedup)) continue;
seen.add(dedup);
(out[key] ??= []).push(entry);
diff --git a/common/lib/report/docs.ts b/common/lib/report/docs.ts
@@ -9,6 +9,7 @@ import { cell } from "../settingsDocs";
import { GENERATED_SCHEMA_DOC, REGENERATE_SCHEMA_DOC, renderKeyTable } from "../citations/docs";
import {
CLAIM_FIELD_DOCS,
+ ENTRY_FIELD_DOCS,
REPORT_FIELD_DOCS,
REPORT_FORMAT,
REPORT_SLIDES_FIELD_DOCS,
@@ -16,6 +17,7 @@ import {
SECTION_FIELD_DOCS,
SLIDE_FIELD_DOCS,
claimSchema,
+ entrySchema,
reportSchema,
reportSlidesSchema,
sectionSchema,
@@ -44,17 +46,20 @@ export function renderReportMarkdown(): string {
out.push(
'A **fact-check** (`"kind": "factcheck"`) is sections (chapters) of claims, each ' +
'with a verdict and its findings. A **sweep** (`"kind": "sweep"`) is sections ' +
- "with no verdicts, or bodies that cite inline. Markdown fields (`summary`, a " +
- "section's `body`, a claim's `findings`) cite with `[label](cite:<id>)`.",
+ "with no verdicts, or bodies that cite inline. Either may keep a **timeline** " +
+ "(`entries`): dated entries appended over time. Markdown fields (`summary`, an " +
+ "entry's `body`, a section's `body`, a claim's `findings`) cite with " +
+ "`[label](cite:<id>)`.",
);
out.push("");
out.push(
"`common/lib/report/validate.ts` reports every problem with its JSON path: an " +
"unknown key, a reference that names nothing (a listed citation, a claim's " +
- "source sentence, a `cite:` link, the subject), a section or claim id used " +
+ "source sentence, a `cite:` link, the subject), a section, claim or entry id used " +
"twice (they share one namespace: the report page's anchors) or named for one of " +
"the page's own anchors, a sweep's claim with a verdict, `updated` before " +
- "`published`, a slide field out of bounds (below), and every citation problem " +
+ "`published` (or an entry's before its `date`), a date that is not one, a slide " +
+ "field out of bounds (below), and every citation problem " +
"CITATIONS.md lists. Whether a still or the report's video exists, whether the " +
"video fits the publish limit, and whether a quote matches its cues are checked " +
"when the site is composed.",
@@ -66,13 +71,31 @@ export function renderReportMarkdown(): string {
renderKeyTable(out, "#### `sections[]`", sectionSchema.shape, SECTION_FIELD_DOCS);
renderKeyTable(out, "#### `sections[].claims[]`", claimSchema.shape, CLAIM_FIELD_DOCS);
+ out.push("## The timeline");
+ out.push("");
+ out.push(
+ "A report may keep a timeline: `entries`, dated blocks a reader sees newest first under " +
+ "**Timeline**, after the summary and before what the check found and the claims — on its " +
+ "page, in its exports and as one slide each. Entries of the same instant keep their order " +
+ "in the file. Each entry's id is its anchor (`#<id>`). Its citations are numbered with " +
+ "the rest, in the page's order. A report with a newer entry than its own `updated` is " +
+ "shown as updated then (the index, the header, the feeds). On a site with a public " +
+ "`siteUrl`, compose publishes the timeline as feeds beside the page — " +
+ "`/reports/<id>/feed.xml` (RSS 2.0) and `/reports/<id>/feed.json` (JSON Feed 1.1), one " +
+ "item per entry, its permalink the page at its anchor — and the page links them " +
+ "(\"Subscribe\", and `<link rel=\"alternate\">`). A site with no `siteUrl` publishes no feed.",
+ );
+ out.push("");
+ renderKeyTable(out, "#### `entries[]`", entrySchema.shape, ENTRY_FIELD_DOCS);
+
out.push("## Slides");
out.push("");
out.push(
"A report is also read as slides — its page's **Slides** view (`?rv=slides`), the **Overview** " +
"that pairs each part of the article with its slide (`?rv=iso`), `slides.html` and `slides.pdf` " +
"— built by `common/lib/report/slides.ts` from the same view as the article: a title slide, " +
- "\"In brief\", \"What the check found\" (a fact-check), one slide per section and per claim, " +
+ "\"In brief\", one per timeline entry (newest first), \"What the check found\" (a fact-check), " +
+ "one slide per section and per claim, " +
"then a sources slide. With no slide fields a report still has slides, derived from its text: " +
"a section shows the first two sentences of its body (else its claims), a claim its verdict, " +
"gist and the evidence of its first listed citation. The fields below make them good. A " +
diff --git a/common/lib/report/entries.ts b/common/lib/report/entries.ts
@@ -0,0 +1,57 @@
+// THE TIMELINE — a report's dated entries (report.json `entries`): small
+// cited blocks appended over time, read newest first, each its own anchor on
+// the page and its own item in the report's feeds (./feeds.ts).
+//
+// The one order every reader shares — the page, the slides, the citation
+// numbering (./uses.ts), the exports, the feeds, MCP: newest first by date;
+// entries of the same instant keep their order in the document.
+//
+// Pure, no imports: the browser and the validator read the one copy.
+
+const DAY_RE = /^\d{4}-\d{2}-\d{2}$/;
+
+// A report date (`YYYY-MM-DD`, midnight UTC, or an ISO 8601 date-time with a
+// zone) as epoch milliseconds; NaN for anything else.
+export function reportDateMs(v: string | undefined): number {
+ if (!v) return Number.NaN;
+ return Date.parse(DAY_RE.test(v) ? `${v}T00:00:00Z` : v);
+}
+
+// The source indexes of `entries`, newest first. A date that does not parse
+// sorts last (a published report has none: the validator refuses it).
+export function entryOrder(entries: readonly { date: string }[]): number[] {
+ const at = entries.map((e) => {
+ const ms = reportDateMs(e.date);
+ return Number.isNaN(ms) ? Number.NEGATIVE_INFINITY : ms;
+ });
+ return entries.map((_e, i) => i).sort((a, b) => at[b] - at[a] || a - b);
+}
+
+export function entriesNewestFirst<T extends { date: string }>(entries: readonly T[]): T[] {
+ return entryOrder(entries).map((i) => entries[i]);
+}
+
+// When the report last changed, its timeline counted: the latest of its own
+// `updated` and every entry's `date` and `updated` — when that is after
+// `published`. Else `updated` as given (an entry dated before the report was
+// published does not make it "updated").
+export function effectiveUpdated(report: {
+ published?: string;
+ updated?: string;
+ entries?: readonly { date: string; updated?: string }[];
+}): string | undefined {
+ let best = report.updated;
+ let bestMs = reportDateMs(best);
+ for (const e of report.entries ?? []) {
+ for (const d of [e.date, e.updated]) {
+ const ms = reportDateMs(d);
+ if (!Number.isNaN(ms) && (Number.isNaN(bestMs) || ms > bestMs)) {
+ best = d;
+ bestMs = ms;
+ }
+ }
+ }
+ if (best === report.updated) return report.updated;
+ const published = reportDateMs(report.published);
+ return !Number.isNaN(published) && bestMs <= published ? report.updated : best;
+}
diff --git a/common/lib/report/exportHtml.ts b/common/lib/report/exportHtml.ts
@@ -15,7 +15,8 @@
// dates and the revision; the document under review as a cited line on its
// colour's rail; the subtitle), then the three tiers, each opened by a
// marker (a hairline and depth dots): the quick take (the
-// tally, the summary, jump links); what the check found (a fact-check's
+// tally, the summary, jump links); the timeline when the report keeps one
+// (its dated entries, newest first, each with its anchor); what the check found (a fact-check's
// claims by verdict, a line each with its gist and flag); and every claim
// with its evidence, opening with how it was checked — each claim its
// verdict and flag pill, the document's sentence on its rail, the findings
@@ -34,6 +35,7 @@ import type { SourceArchive } from "../citations/schema";
import { ICON_PALETTES, markSvg } from "../brand";
import {
CITATION_KIND_LABELS,
+ entryDateLabel,
foundGroups,
orderedCitations,
reportDateParts,
@@ -126,10 +128,12 @@ export function siteLink(siteUrl: string | undefined, sitePath: string): string
}
// A link a reader may follow out of a saved file: http(s) or mailto. A
-// fragment stays in the file; a site-root path goes to the site, when known.
-function safeHref(href: string, siteUrl: string | undefined): string | undefined {
+// fragment stays in the file (or goes to `fragmentBase`, the page it belongs
+// to, when the HTML is read elsewhere — a feed); a site-root path goes to the
+// site, when known.
+function safeHref(href: string, siteUrl: string | undefined, fragmentBase?: string): string | undefined {
if (/^(https?:|mailto:)/i.test(href)) return href;
- if (href.startsWith("#")) return href;
+ if (href.startsWith("#")) return fragmentBase ? `${fragmentBase}${href}` : href;
if (href.startsWith("/")) return siteLink(siteUrl, href);
return undefined;
}
@@ -138,12 +142,16 @@ const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? o
// ─── Markdown, the small subset a report writes ───
-export type CiteRef = { number?: number; anchor: string };
+// A citation's number and its reference's anchor in the file; `href`, when
+// given, is where the marker links instead (a feed links the reference on
+// the report's page).
+export type CiteRef = { number?: number; anchor: string; href?: string };
// A citation marker: `[n]` linking to its reference.
function citeMarker(ref: CiteRef | undefined, id: string): string {
const n = ref?.number !== undefined ? String(ref.number) : "?";
- return `<sup class="cite"><a href="#${escapeHtml(ref?.anchor ?? citationAnchor(id))}">[${n}]</a></sup>`;
+ const href = ref?.href ?? `#${ref?.anchor ?? citationAnchor(id)}`;
+ return `<sup class="cite"><a href="${escapeHtml(href)}">[${n}]</a></sup>`;
}
const PH = "\u0000";
@@ -159,7 +167,12 @@ function emphasis(escaped: string): string {
// One paragraph's inline markdown: code spans, links (a `cite:` link is the
// label and its number), autolinks, emphasis. Raw HTML is escaped.
-function inlineMd(text: string, cite: (id: string) => CiteRef | undefined, siteUrl: string | undefined): string {
+function inlineMd(
+ text: string,
+ cite: (id: string) => CiteRef | undefined,
+ siteUrl: string | undefined,
+ fragmentBase?: string,
+): string {
const held: string[] = [];
const hold = (html: string) => `${PH}${held.push(html) - 1}${PH}`;
let s = text.replace(/(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/g, (_m, _t, code: string) =>
@@ -171,7 +184,7 @@ function inlineMd(text: string, cite: (id: string) => CiteRef | undefined, siteU
const id = href.slice(CITE_SCHEME.length).trim();
return hold(`${shown}${citeMarker(cite(id), id)}`);
}
- const safe = safeHref(href, siteUrl);
+ const safe = safeHref(href, siteUrl, fragmentBase);
return hold(safe ? `<a href="${escapeHtml(safe)}">${shown}</a>` : shown);
});
s = s.replace(/<(https?:\/\/[^\s<>]+)>/g, (_m, url: string) => hold(`<a href="${escapeHtml(url)}">${escapeHtml(url)}</a>`));
@@ -192,15 +205,16 @@ const startsBlock = (l: string) => FENCE_RE.test(l) || HEADING_RE.test(l) || HR_
// A report's markdown (a summary, a section's body, a claim's findings) as
// HTML: paragraphs, headings (shifted under the page's own), lists, quotes,
-// code, rules. `headingBase` is the level a `#` becomes.
+// code, rules. `headingBase` is the level a `#` becomes; `fragmentBase`, the
+// page a `#fragment` link is on when the HTML is read elsewhere.
export function markdownToHtml(
md: string,
cite: (id: string) => CiteRef | undefined,
- opts: { siteUrl?: string; headingBase?: number } = {},
+ opts: { siteUrl?: string; headingBase?: number; fragmentBase?: string } = {},
): string {
const lines = md.replace(/\r\n?/g, "\n").split("\n");
const base = opts.headingBase ?? 3;
- const inline = (t: string) => inlineMd(t, cite, opts.siteUrl);
+ const inline = (t: string) => inlineMd(t, cite, opts.siteUrl, opts.fragmentBase);
const out: string[] = [];
let i = 0;
while (i < lines.length) {
@@ -325,6 +339,12 @@ details.given summary{cursor:pointer;font:.8rem/1.4 ui-monospace,SFMono-Regular,
.verdict::before{content:"";width:.5rem;height:.5rem;border-radius:50%;background:var(--v)}
.verdict .n{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--muted);font-weight:400}
nav.toc ol{margin:.5rem 0;padding-left:1.4rem}
+ol.timeline{list-style:none;margin:1rem 0 0;padding:0 0 0 1rem;border-left:2px solid var(--border)}
+ol.timeline>li{margin:0 0 1.5rem}
+.entry-date{margin:0;font:600 .95rem/1.3 ui-monospace,SFMono-Regular,Menlo,monospace}
+.entry-date a{text-decoration:none}
+.entry-date .meta{font-weight:400}
+ol.timeline h3{margin:.2rem 0 0}
.claim{margin:1.25rem 0;padding:1rem 1.1rem;border:1px solid var(--border);border-radius:8px}
.claim-head{display:flex;flex-wrap:wrap;align-items:baseline;gap:.4rem .7rem}
.claim-head h3{flex:1 1 15rem}
@@ -633,6 +653,22 @@ export function reportExportHtml(view: ReportPageView, opts: ReportExportHtmlOpt
quick.push(`<p class="jumps" data-report-jumps="">${jumps.map(([h, l]) => `<a href="${h}">${l}</a>`).join(" · ")}</p>`);
body.push(`<section class="tier-1" data-report-tier="1" aria-label="In brief">${quick.join("\n")}</section>`);
+ // The timeline: its dated entries, newest first, each its own anchor.
+ const entries = view.entries ?? [];
+ if (entries.length > 0) {
+ body.push(
+ `<section data-report-timeline=""><h2>Timeline</h2><ol class="timeline">${entries
+ .map(
+ (e) =>
+ `<li id="${escapeHtml(e.id)}" data-entry="${escapeHtml(e.id)}"><p class="entry-date">` +
+ `<a href="#${escapeHtml(e.id)}"><time datetime="${escapeHtml(e.date)}">${escapeHtml(entryDateLabel(e.date))}</time></a>` +
+ `${e.updated ? ` <span class="meta">updated ${escapeHtml(entryDateLabel(e.updated))}</span>` : ""}</p>` +
+ `<h3>${escapeHtml(e.title)}</h3>${md(e.body, 4)}</li>`,
+ )
+ .join("\n")}</ol></section>`,
+ );
+ }
+
// Tier 2, what the check found: every ruled claim by verdict, one line each.
if (groups.length > 0) {
body.push(
diff --git a/common/lib/report/exportMarkdown.ts b/common/lib/report/exportMarkdown.ts
@@ -3,9 +3,10 @@
// view (./views.ts), like the HTML export (./exportHtml.ts), with the same
// footer.
//
-// The report's own markdown (summary, section bodies, findings) passes through
-// as written, each inline citation `[label](cite:<id>)` becoming `label [n]` —
-// the number of its entry in the numbered reference list at the end. Plain
+// The report's own markdown (summary, timeline entries, section bodies,
+// findings) passes through as written, each inline citation
+// `[label](cite:<id>)` becoming `label [n]` — the number of its entry in the
+// numbered reference list at the end. Plain
// text fields (titles, quotes) are escaped so a `*` or `[` in a quote stays a
// character. No images: a still or a screenshot is in the HTML export and the
// evidence pack; here the quote is the text.
@@ -17,6 +18,7 @@ import type { SourceArchive } from "../citations/schema";
import { dateLabel, reportExportFooterLine, siteLink, type ReportExportOptions } from "./exportHtml";
import {
CITATION_KIND_LABELS,
+ entryDateLabel,
foundGroups,
orderedCitations,
reportDateParts,
@@ -205,6 +207,17 @@ export function reportExportMarkdown(view: ReportPageView, opts: ReportExportOpt
}
if (view.summary) out.push(citedMarkdownToPlain(view.summary, view, opts.siteUrl).trim(), "");
+ // The timeline: its dated entries, newest first.
+ const entries = view.entries ?? [];
+ if (entries.length > 0) {
+ out.push("## Timeline", "");
+ for (const e of entries) {
+ out.push(`### ${entryDateLabel(e.date)} — ${mdText(e.title)}`, "");
+ if (e.updated) out.push(`*updated ${entryDateLabel(e.updated)}*`, "");
+ out.push(citedMarkdownToPlain(e.body, view, opts.siteUrl).trim(), "");
+ }
+ }
+
// Tier 2: what the check found (a fact-check with verdicts).
const groups = isFactcheck ? foundGroups(view) : [];
if (groups.length > 0) {
diff --git a/common/lib/report/exportSlides.test.ts b/common/lib/report/exportSlides.test.ts
@@ -35,6 +35,7 @@ function report(): Report {
a1: { kind: "source", source: "s0", quote: "the claim", image: "stills/a1.png" },
},
slides: { closing: "The end." },
+ entries: [{ id: "e1", date: "2026-10-03T08:00:00Z", title: "An update", body: "It [moved](cite:v1). Then more. And more." }],
sections: [
{
id: "ch1",
@@ -107,6 +108,15 @@ test("the way back: each slide links to its place in the article on the site; wi
assert.match(out, /href="https:\/\/site\.example\/m\/demo-channel\/abc123\/10\.00-20\.00\/"/);
});
+test("a timeline entry is a slide: its date, its title, its first sentences", () => {
+ const out = html();
+ assert.match(
+ out,
+ /data-slide="entry:e1" data-slide-kind="entry" data-layout="statement"[^>]*>.*<b>Timeline<\/b> · 1 of 1.*<p class="rs-dates"><time datetime="2026-10-03T08:00:00Z">2026-10-03<\/time><\/p><h2 class="rs-h">An update<\/h2><div class="rs-statement"><div class="rs-md"><p>It moved<sup[^>]*><a href="#c-v1">\[1\]<\/a><\/sup>\. Then more\.<\/p>/s,
+ );
+ assert.match(out, /href="https:\/\/site\.example\/reports\/demo\/#e1" data-slide-read="e1"/);
+});
+
test("the sources slide names which document this is", () => {
assert.match(html(), /data-export-footer="">Revision 2 · 2026-10-04 · report sha256 abababababab</);
assert.match(html(), /The end\./);
diff --git a/common/lib/report/exportSlidesHtml.ts b/common/lib/report/exportSlidesHtml.ts
@@ -27,6 +27,7 @@ import {
buildReportSlides,
slidePointText,
type ClaimSlide,
+ type EntrySlide,
type FoundSlide,
type SectionSlide,
type SlideView,
@@ -201,6 +202,14 @@ function summaryBody(s: SummarySlide, x: Ctx): string {
);
}
+function entryBody(s: EntrySlide, x: Ctx): string {
+ return (
+ `<p class="rs-dates"><time datetime="${escapeHtml(s.datetime)}">${escapeHtml(s.date)}</time></p>` +
+ `<h2 class="rs-h">${escapeHtml(s.title)}</h2>` +
+ (s.text ? `<div class="rs-statement">${md(s.text, x)}</div>` : "")
+ );
+}
+
function foundBody(s: FoundSlide, x: Ctx): string {
return (
`<h2 class="rs-h rs-h-sm">${escapeHtml(s.title)}</h2>` +
@@ -286,6 +295,10 @@ function eyebrow(s: SlideView, total: number): string {
case "summary":
kind = "In brief";
break;
+ case "entry":
+ kind = "Timeline";
+ place = `${s.index} of ${s.count}`;
+ break;
case "found":
kind = "Findings";
place = `${s.claimCount} claim${s.claimCount === 1 ? "" : "s"} ruled`;
@@ -317,6 +330,9 @@ function slideHtml(s: SlideView, x: Ctx): string {
case "summary":
body = summaryBody(s, x);
break;
+ case "entry":
+ body = entryBody(s, x);
+ break;
case "found":
body = foundBody(s, x);
break;
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/lib/report/revisions.ts b/common/lib/report/revisions.ts
@@ -11,7 +11,7 @@
//
// Pure, no imports but types: the export site's pages can use it.
-import type { Claim, Report } from "./schema";
+import type { Claim, Report, ReportEntry } from "./schema";
export const REPORT_HISTORY_FORMAT = "archilyzer-report-history";
export const REPORT_HISTORY_VERSION = 1;
@@ -266,16 +266,19 @@ function headerChange(name: string, a: string | undefined, b: string | undefined
}
// What changed from `prev` to `next`, one line each, in a fixed order: the
-// title, series and subtitle; claims added and removed; verdicts changed;
+// title, series and subtitle; timeline entries added, removed and edited;
+// claims added and removed; verdicts changed;
// claims whose title, text or findings were edited; citations added and
// removed; quotes edited; the summary and method. The first revision
// (`prev` null) is one line counting what it holds. A change outside all of
// these (a citation's span, a source, formatting) is one line saying so.
export function reportChangeSummary(prev: Report | null, next: Report): string[] {
if (!prev) {
+ const entries = next.entries?.length ?? 0;
return [
`First revision: ${plural(next.sections.length, "section")}, ${plural(countClaims(next), "claim")}, ` +
- `${plural(Object.keys(next.citations ?? {}).length, "citation")}.`,
+ `${plural(Object.keys(next.citations ?? {}).length, "citation")}` +
+ `${entries > 0 ? `, ${plural(entries, "timeline entry", "timeline entries")}` : ""}.`,
];
}
const out: string[] = [];
@@ -287,6 +290,22 @@ export function reportChangeSummary(prev: Report | null, next: Report): string[]
if (line) out.push(line);
}
+ const eBefore = new Map((prev.entries ?? []).map((e) => [e.id, e]));
+ const eAfter = new Map((next.entries ?? []).map((e) => [e.id, e]));
+ const entryLabel = (e: ReportEntry) => `${e.id} “${short(e.title)}”`;
+ const eAdded = [...eAfter.values()].filter((e) => !eBefore.has(e.id)).map(entryLabel);
+ const eRemoved = [...eBefore.values()].filter((e) => !eAfter.has(e.id)).map(entryLabel);
+ const eEdited: string[] = [];
+ for (const [id, e] of eAfter) {
+ const old = eBefore.get(id);
+ if (!old) continue;
+ const fields = (["date", "title", "body"] as const).filter((f) => old[f] !== e[f]);
+ if (fields.length) eEdited.push(`${id} (${fields.join(", ")})`);
+ }
+ if (eAdded.length) out.push(`${eAdded.length === 1 ? "Entry" : `${eAdded.length} entries`} added: ${list(eAdded)}`);
+ if (eRemoved.length) out.push(`${eRemoved.length === 1 ? "Entry" : `${eRemoved.length} entries`} removed: ${list(eRemoved)}`);
+ if (eEdited.length) out.push(`${eEdited.length === 1 ? "Entry" : "Entries"} edited: ${list(eEdited)}`);
+
const before = claimsById(prev);
const after = claimsById(next);
const added = [...after.values()].filter((c) => !before.has(c.claim.id)).map((c) => claimLabel(c.claim));
diff --git a/common/lib/report/schema.ts b/common/lib/report/schema.ts
@@ -6,7 +6,8 @@
// optional markdown body and claims. A FACT-CHECK (`kind: "factcheck"`) is
// sections (chapters) of claims, each with a verdict from the shared
// vocabulary (./verdicts.mjs) and its findings; a SWEEP (`kind: "sweep"`) is
-// sections with no verdicts, or bodies with inline citations.
+// sections with no verdicts, or bodies with inline citations. Either may keep
+// a TIMELINE (`entries`): dated, cited blocks appended over time.
//
// ITS CITATIONS ARE THE CITATION MODEL'S (lib/citations/): the `sources` and
// `citations` maps are that model's schemas, cited inline with
@@ -85,6 +86,17 @@ export const sectionSchema = z.strictObject({
slide: slideSchema.optional(),
});
+// THE TIMELINE. A report may carry dated entries — small cited blocks a reader
+// sees newest first, appended over time (lib/report/entries.ts orders them),
+// each its own anchor on the page and an item in the report's feeds.
+export const entrySchema = z.strictObject({
+ id: text,
+ date: text,
+ title: text,
+ body: text,
+ updated: text.optional(),
+});
+
const verdictOverride = z.strictObject({ label: text.optional(), color: text.optional() });
export const reportSchema = z.strictObject({
@@ -105,12 +117,14 @@ export const reportSchema = z.strictObject({
sources: z.record(text, sourceSchema).optional(),
citations: z.record(text, citationSchema).optional(),
sections: z.array(sectionSchema),
+ entries: z.array(entrySchema).optional(),
slides: reportSlidesSchema.optional(),
});
export type Claim = z.infer<typeof claimSchema>;
export type Section = z.infer<typeof sectionSchema>;
export type Report = z.infer<typeof reportSchema>;
+export type ReportEntry = z.infer<typeof entrySchema>;
export type ReportSubject = NonNullable<Report["subject"]>;
export type ClaimSourceQuote = NonNullable<Claim["sourceQuote"]>;
export type SlideSpec = z.infer<typeof slideSchema>;
@@ -136,7 +150,8 @@ export const REPORT_FIELD_DOCS: FieldDocs<Report> = {
method:
"How the report was checked, in markdown (short; it cites nothing): shown under \"How it was checked\" at the start of the claims in full.",
published: "When the report was published: `YYYY-MM-DD` or an ISO 8601 date-time with a zone.",
- updated: "When it was last changed, in the same form; not before `published`.",
+ updated:
+ "When it was last changed, in the same form; not before `published`. A report with a timeline is shown as updated at its newest entry's `date` or `updated` when that is later.",
subject:
"The document under review, when the report reviews one: `{ \"source\": \"<id>\" }`, an id in `sources`.",
video:
@@ -146,6 +161,8 @@ export const REPORT_FIELD_DOCS: FieldDocs<Report> = {
citations:
"The report's citations, by id — see [CITATIONS.md](CITATIONS.md). A citation is cited from markdown with `[label](cite:<id>)` and listed under the claims that rest on it. Absent = none.",
sections: "The report's sections, in order.",
+ entries:
+ "The report's timeline: dated entries, appended over time and shown newest first under \"Timeline\", before the claims — see `entries[]` below. With entries and a public `siteUrl`, the report publishes feeds of them (`feed.xml`, RSS 2.0; `feed.json`, JSON Feed 1.1). Absent = none.",
slides:
"How the report reads as slides (its page's Slides and Overview views, `slides.html`, `slides.pdf`) — see `slides` below. Absent = slides derived from its text.",
};
@@ -166,7 +183,7 @@ export const SLIDE_FIELD_DOCS: FieldDocs<SlideSpec> = {
};
export const SECTION_FIELD_DOCS: FieldDocs<Section> = {
- id: `The section's id (letters, digits, \`_ . : -\`; at most 64): its anchor on the report page. Unique among the report's section and claim ids, and none of the page's own anchors (${RESERVED_ANCHOR_IDS.map((a) => `\`${a}\``).join(", ")}).`,
+ id: `The section's id (letters, digits, \`_ . : -\`; at most 64): its anchor on the report page. Unique among the report's section, claim and entry ids, and none of the page's own anchors (${RESERVED_ANCHOR_IDS.map((a) => `\`${a}\``).join(", ")}).`,
title: "The section's heading.",
body: "Markdown under the heading. May cite inline.",
claims: "The section's claims, in order. Absent = none.",
@@ -174,7 +191,7 @@ export const SECTION_FIELD_DOCS: FieldDocs<Section> = {
};
export const CLAIM_FIELD_DOCS: FieldDocs<Claim> = {
- id: `The claim's id (letters, digits, \`_ . : -\`; at most 64): its anchor on the report page. Unique among the report's section and claim ids, and none of the page's own anchors (${RESERVED_ANCHOR_IDS.map((a) => `\`${a}\``).join(", ")}).`,
+ id: `The claim's id (letters, digits, \`_ . : -\`; at most 64): its anchor on the report page. Unique among the report's section, claim and entry ids, and none of the page's own anchors (${RESERVED_ANCHOR_IDS.map((a) => `\`${a}\``).join(", ")}).`,
title: "A short headline for the claim (plain text, e.g. a phrase it turns on), shown above its text. Absent = the text alone.",
text: "The claim, as stated by the document under review (plain text).",
verdict: `The ruling on the claim: ${VERDICTS.map((v) => `\`${v}\``).join(", ")}. A fact-check's claim may leave it out (not yet ruled); a sweep's carries none.`,
@@ -186,3 +203,11 @@ export const CLAIM_FIELD_DOCS: FieldDocs<Claim> = {
citations: "The citations the claim rests on, in the order they are listed under it. Each must exist; none twice.",
slide: "The claim's slide — see `slide` below. Absent = derived from the claim.",
};
+
+export const ENTRY_FIELD_DOCS: FieldDocs<ReportEntry> = {
+ id: `The entry's id (letters, digits, \`_ . : -\`; at most 64): its anchor on the report page and its permalink in the feeds. Unique among the report's section, claim and entry ids, and none of the page's own anchors (${RESERVED_ANCHOR_IDS.map((a) => `\`${a}\``).join(", ")}).`,
+ date: "When the entry was added: `YYYY-MM-DD` or an ISO 8601 date-time with a zone. The timeline is newest first by this date; entries of the same instant keep their order here.",
+ title: "The entry's heading (plain text, one line).",
+ body: "The entry, in markdown. May cite inline, `[label](cite:<id>)`, as a section's body does; its citations are numbered with the rest, in the page's order (the timeline comes after the summary).",
+ updated: "When the entry was last changed, in the same form; not before its `date`. Absent = never.",
+};
diff --git a/common/lib/report/slides.ts b/common/lib/report/slides.ts
@@ -8,6 +8,8 @@
// else the subtitle), the dates, the document under review
// summary "In brief": `slides.points`, else the summary's first paragraph
// (and a fact-check's tally)
+// entry one per timeline entry, newest first: its date, its title and
+// the first two sentences of its body
// found "What the check found": the claims by verdict — a fact-check only
// section one per section: its `slide.points`, else the first two sentences
// of its body, else its claims
@@ -39,27 +41,29 @@ import {
type SlideLayoutName,
} from "./slideRules";
import {
+ entryDateLabel,
foundGroups,
orderedCitations,
reportDateParts,
subjectMetaParts,
verdictTally,
type ClaimView,
+ type EntryView,
type ReportPageView,
type SectionView,
type VerdictCount,
} from "./views";
-export type SlideAnchorKind = "head" | "summary" | "found" | "section" | "claim" | "sources";
+export type SlideAnchorKind = "head" | "summary" | "entry" | "found" | "section" | "claim" | "sources";
-// A place in the article: its kind, and the section's or claim's id.
+// A place in the article: its kind, and the entry's, section's or claim's id.
export type SlideAnchor = { kind: SlideAnchorKind; id?: string };
type SlideBase = {
// The slide's place in the deck, from 1: its hash is `#s-<n>`.
n: number;
- // Unique in the deck: `title`, `summary`, `found`, `section:<id>`,
- // `claim:<id>`, `sources`.
+ // Unique in the deck: `title`, `summary`, `entry:<id>`, `found`,
+ // `section:<id>`, `claim:<id>`, `sources`.
key: string;
anchor: SlideAnchor;
// The anchor's element id on the report page (`#<anchorId>`).
@@ -88,6 +92,19 @@ export type SummarySlide = SlideBase & {
tally?: VerdictCount[];
};
+export type EntrySlide = SlideBase & {
+ kind: "entry";
+ layout: "statement";
+ // Its place in the timeline, newest first ("1 of 3").
+ index: number;
+ count: number;
+ // The entry's day (entryDateLabel), and as written.
+ date: string;
+ datetime: string;
+ // The body's first two sentences (markdown, citing inline).
+ text?: string;
+};
+
export type FoundSlide = SlideBase & {
kind: "found";
layout: "found";
@@ -135,7 +152,7 @@ export type SourcesSlide = SlideBase & {
closing?: string;
};
-export type SlideView = TitleSlide | SummarySlide | FoundSlide | SectionSlide | ClaimSlide | SourcesSlide;
+export type SlideView = TitleSlide | SummarySlide | EntrySlide | FoundSlide | SectionSlide | ClaimSlide | SourcesSlide;
// A slide before the deck numbers it.
type Unnumbered = SlideView extends infer S ? (S extends SlideView ? Omit<S, "n"> : never) : never;
@@ -193,6 +210,7 @@ export function slideAnchorId(anchor: SlideAnchor): string {
return REPORT_PAGE_ANCHOR_IDS.found;
case "sources":
return REPORT_PAGE_ANCHOR_IDS.end;
+ case "entry":
case "section":
case "claim":
return anchor.id ?? "";
@@ -200,10 +218,11 @@ export function slideAnchorId(anchor: SlideAnchor): string {
}
// Every element id the report page gives a place (export ReportArticle): its
-// own parts, each section and each claim — those a page of this view
-// carries, in document order.
+// own parts, each timeline entry, each section and each claim — those a page
+// of this view carries, in document order.
export function reportPageAnchorIds(view: ReportPageView): string[] {
const ids: string[] = [REPORT_PAGE_ANCHOR_IDS.head, REPORT_PAGE_ANCHOR_IDS.summary];
+ for (const e of view.entries ?? []) ids.push(e.id);
if (view.kind === "factcheck" && foundGroups(view).length > 0) ids.push(REPORT_PAGE_ANCHOR_IDS.found);
ids.push(REPORT_PAGE_ANCHOR_IDS.claims);
for (const s of view.sections) {
@@ -231,6 +250,23 @@ function claimCite(c: ClaimView): string | undefined {
return c.slide?.cite ?? c.citations.find((id) => id !== c.sourceQuote);
}
+function entrySlide(e: EntryView, index: number, count: number): Omit<EntrySlide, "n"> {
+ const text = firstSentences(e.body, 2);
+ return {
+ kind: "entry",
+ key: `entry:${e.id}`,
+ anchor: { kind: "entry", id: e.id },
+ anchorId: e.id,
+ title: e.title,
+ layout: "statement",
+ index,
+ count,
+ date: entryDateLabel(e.date),
+ datetime: e.date,
+ ...(text ? { text } : {}),
+ };
+}
+
function sectionSlide(s: SectionView, index: number, count: number): Omit<SectionSlide, "n"> {
const spec = s.slide;
const points = spec?.points && spec.points.length > 0 ? spec.points : undefined;
@@ -318,6 +354,9 @@ export function buildReportSlides(view: ReportPageView): SlideView[] {
} satisfies Omit<SummarySlide, "n">);
}
+ const entries = view.entries ?? [];
+ entries.forEach((e, i) => out.push(entrySlide(e, i + 1, entries.length)));
+
const groups = isFactcheck ? foundGroups(view) : [];
if (groups.length > 0) {
out.push({
diff --git a/common/lib/report/timeline.test.ts b/common/lib/report/timeline.test.ts
@@ -0,0 +1,233 @@
+// The timeline (report.json `entries`): the schema and its refusals, the one
+// newest-first order and what reads it — the citation numbering, cited-in,
+// the page view and the index, the slides, the exports, the change summary.
+
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { buildCitedIn } from "./citedIn";
+import { effectiveUpdated, entriesNewestFirst, entryOrder, reportDateMs } from "./entries";
+import { reportExportHtml } from "./exportHtml";
+import { reportExportMarkdown } from "./exportMarkdown";
+import { reportChangeSummary } from "./revisions";
+import type { Report } from "./schema";
+import { buildReportSlides, reportPageAnchorIds, slideForAnchor } from "./slides";
+import { reportCitationUses } from "./uses";
+import { validateReport } from "./validate";
+import { buildReportPageView, citedInHref, citedInViews, reportIndexEntry, type ReportPageView } from "./views";
+
+function report(): Report {
+ return {
+ format: "archilyzer-report",
+ version: 1,
+ id: "living",
+ kind: "sweep",
+ title: "A living report",
+ 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" },
+ w2: { kind: "page", url: "https://example.org/q", title: "Another", quote: "three" },
+ },
+ // In the file oldest first, as an author appends; one undated-by-time
+ // pair on one day keeps its file order.
+ entries: [
+ { id: "e1", date: "2026-10-02", title: "The first update", body: "It [began](cite:w2)." },
+ { id: "e2", date: "2026-10-05T09:30:00Z", title: "The third update", body: "Then [this](cite:w1)." },
+ { id: "e3", date: "2026-10-03", title: "The second update, a", body: "More." },
+ { id: "e4", date: "2026-10-03", title: "The second update, b", body: "And more.", updated: "2026-10-04" },
+ ],
+ sections: [{ id: "s1", title: "Section", body: "A body citing [the page](cite:w1) and [one](cite:v1)." }],
+ };
+}
+
+const view = (r: Report = report()): ReportPageView =>
+ buildReportPageView(r, {
+ record: (c) => ({ channel: c.channel, id: c.id, title: `Record ${c.id}` }),
+ feeds: { rss: "https://site.example/reports/living/feed.xml", json: "https://site.example/reports/living/feed.json" },
+ });
+
+test("a report with a timeline is sound", () => {
+ assert.deepEqual(validateReport(report()), []);
+});
+
+test("the order: newest first by date (a day is midnight UTC), the same instant in file order", () => {
+ assert.deepEqual(entryOrder(report().entries!), [1, 2, 3, 0]);
+ assert.deepEqual(entriesNewestFirst(report().entries!).map((e) => e.id), ["e2", "e3", "e4", "e1"]);
+ assert.equal(reportDateMs("2026-10-03"), Date.parse("2026-10-03T00:00:00Z"));
+ assert.ok(Number.isNaN(reportDateMs("yesterday")));
+ // A date that does not parse sorts last (the validator refuses it anyway).
+ assert.deepEqual(entryOrder([{ date: "nope" }, { date: "2026-01-01" }]), [1, 0]);
+});
+
+test("refused: a duplicate id, an id shared with a section or claim, a page anchor, a bad id", () => {
+ const msgs = (mutate: (r: Report) => void) => {
+ const r = report();
+ mutate(r);
+ return validateReport(r).map((p) => `${p.path}: ${p.message}`);
+ };
+ assert.deepEqual(msgs((r) => (r.entries![1].id = "e1")), ['entries[1].id: "e1" is already the id of entries[0]']);
+ assert.deepEqual(msgs((r) => (r.entries![0].id = "s1")), ['entries[0].id: "s1" is already the id of sections[0]']);
+ assert.match(msgs((r) => (r.entries![0].id = "found"))[0], /^entries\[0\]\.id: "found" is the report page's own anchor/);
+ assert.match(msgs((r) => (r.entries![0].id = "has space"))[0], /^entries\[0\]\.id: is not a reference id/);
+});
+
+test("refused: a blank or two-line title, a blank body, a bad date, an updated before its date", () => {
+ const msgs = (mutate: (r: Report) => void) => {
+ const r = report();
+ mutate(r);
+ return validateReport(r).map((p) => `${p.path}: ${p.message}`);
+ };
+ assert.deepEqual(msgs((r) => (r.entries![0].title = " ")), ["entries[0].title: must not be blank"]);
+ assert.deepEqual(msgs((r) => (r.entries![0].title = "a\nb")), ["entries[0].title: must be one line"]);
+ assert.deepEqual(msgs((r) => (r.entries![0].body = "\n")), ["entries[0].body: must not be blank"]);
+ assert.deepEqual(msgs((r) => (r.entries![0].date = "2026-13-40")), [
+ "entries[0].date: must be YYYY-MM-DD or an ISO 8601 date-time with a zone",
+ ]);
+ assert.deepEqual(msgs((r) => (r.entries![0].date = "2026-10-02T10:00:00")), [
+ "entries[0].date: must be YYYY-MM-DD or an ISO 8601 date-time with a zone",
+ ]);
+ assert.deepEqual(msgs((r) => (r.entries![3].updated = "soon")), [
+ "entries[3].updated: must be YYYY-MM-DD or an ISO 8601 date-time with a zone",
+ ]);
+ assert.deepEqual(msgs((r) => (r.entries![3].updated = "2026-10-02")), ["entries[3].updated: is before the entry's date"]);
+});
+
+test("refused: an entry citing what the report does not define; an unknown key", () => {
+ const r = report();
+ r.entries![0].body = "See [that](cite:nope) and [this](cite:).";
+ assert.deepEqual(
+ validateReport(r).map((p) => `${p.path}: ${p.message}`),
+ [
+ 'entries[0].body: the link [that](cite:nope) names no citation ("nope" is not in citations)',
+ "entries[0].body: the link [this](cite:) names no citation",
+ ],
+ );
+ const raw = { ...report(), entries: [{ id: "x", date: "2026-10-02", title: "t", body: "b", extra: 1 }] };
+ assert.ok(validateReport(raw).some((p) => p.path.startsWith("entries[0]")));
+});
+
+test("uses and numbers: the summary, then the entries newest first, then the sections", () => {
+ const uses = reportCitationUses(report()).map((u) => `${u.field}:${u.entryId ?? u.sectionId ?? "-"}:${u.citationId}`);
+ assert.deepEqual(uses, ["summary:-:v1", "entry:e2:w1", "entry:e1:w2", "body:s1:w1", "body:s1:v1"]);
+ const v = view();
+ assert.deepEqual(Object.fromEntries(Object.values(v.citations).map((c) => [c.id, c.number])), { v1: 1, w1: 2, w2: 3 });
+ // A citation only an entry cites is in the view (and so in its references).
+ assert.ok(v.citations.w2);
+ // The path names the entry's place in the file, not on the page.
+ assert.deepEqual(reportCitationUses(report()).find((u) => u.entryId === "e1")?.path, ["entries", 0, "body"]);
+});
+
+test("cited-in: a citation in an entry links to the entry's anchor", () => {
+ const r: Report = {
+ ...report(),
+ entries: [{ id: "e9", date: "2026-10-02", title: "Clip", body: "Watch [it](cite:v1)." }],
+ };
+ const index = buildCitedIn([r]);
+ const entries = index["demo-channel/abc123/10.00-20.00"];
+ assert.deepEqual(entries[1], { reportId: "living", sectionId: null, claimId: null, entryId: "e9", citationId: "v1" });
+ assert.equal(citedInHref(entries[1]), "/reports/living/#e9");
+ assert.equal(citedInHref(entries[0]), "/reports/living/");
+ const views = citedInViews(entries, [view(r)]);
+ assert.equal(views[1].entryTitle, "Clip");
+});
+
+test("the page view: entries newest first with their dates; updated counts the newest entry; feeds only with entries", () => {
+ const v = view();
+ assert.deepEqual(
+ v.entries!.map((e) => e.id),
+ ["e2", "e3", "e4", "e1"],
+ );
+ assert.deepEqual(v.entries![2], {
+ id: "e4",
+ date: "2026-10-03",
+ updated: "2026-10-04",
+ title: "The second update, b",
+ body: "And more.",
+ });
+ assert.equal(v.updated, "2026-10-05T09:30:00Z");
+ assert.equal(reportIndexEntry(v).updated, "2026-10-05T09:30:00Z");
+ assert.equal(v.feeds?.rss, "https://site.example/reports/living/feed.xml");
+ const none = view({ ...report(), entries: undefined });
+ assert.equal(none.entries, undefined);
+ assert.equal(none.feeds, undefined);
+ assert.equal(none.updated, undefined);
+ assert.equal(view({ ...report(), entries: [] }).entries, undefined);
+});
+
+test("effective updated: the latest of updated and the entries' dates, never before published", () => {
+ assert.equal(effectiveUpdated({ published: "2026-10-01" }), undefined);
+ assert.equal(effectiveUpdated({ published: "2026-10-01", updated: "2026-10-09" , entries: [{ date: "2026-10-03" }] }), "2026-10-09");
+ assert.equal(effectiveUpdated({ published: "2026-10-01", entries: [{ date: "2026-10-03", updated: "2026-10-07" }] }), "2026-10-07");
+ // An entry dated before publication does not make the report "updated".
+ assert.equal(effectiveUpdated({ published: "2026-10-01", entries: [{ date: "2026-09-01" }] }), undefined);
+ assert.equal(effectiveUpdated({ entries: [{ date: "2026-09-01" }] }), "2026-09-01");
+});
+
+test("slides: one per entry, newest first, after In brief; each anchored to its entry", () => {
+ const v = view();
+ const slides = buildReportSlides(v);
+ assert.deepEqual(
+ slides.map((s) => s.key),
+ ["title", "summary", "entry:e2", "entry:e3", "entry:e4", "entry:e1", "section:s1", "sources"],
+ );
+ const e2 = slides[2];
+ assert.equal(e2.kind, "entry");
+ if (e2.kind !== "entry") return;
+ assert.deepEqual(
+ { anchorId: e2.anchorId, title: e2.title, date: e2.date, datetime: e2.datetime, index: e2.index, count: e2.count, text: e2.text },
+ { anchorId: "e2", title: "The third update", date: "2026-10-05", datetime: "2026-10-05T09:30:00Z", index: 1, count: 4, text: "Then [this](cite:w1)." },
+ );
+ // The page's anchors in the page's order, entries after In brief.
+ const order = reportPageAnchorIds(v);
+ assert.deepEqual(order.slice(0, 6), ["report-head", "in-brief", "e2", "e3", "e4", "e1"]);
+ assert.equal(slideForAnchor(slides, "e4", order)?.key, "entry:e4");
+ // A report with no timeline: no entry slides.
+ assert.equal(buildReportSlides(view({ ...report(), entries: undefined })).filter((s) => s.kind === "entry").length, 0);
+});
+
+const FOOTER = { reportSha256: "ab".repeat(32) };
+
+test("the HTML export: the Timeline after In brief and before the sections, newest first, each anchored", () => {
+ const html = reportExportHtml(view(), { footer: FOOTER, image: () => undefined, siteUrl: "https://site.example" });
+ const at = (s: string) => html.indexOf(s);
+ assert.ok(at('aria-label="In brief"') < at("<h2>Timeline</h2>"));
+ assert.ok(at("<h2>Timeline</h2>") < at('id="s1"'));
+ assert.ok(at('data-entry="e2"') < at('data-entry="e3"') && at('data-entry="e4"') < at('data-entry="e1"'));
+ assert.match(html, /<li id="e2" data-entry="e2"><p class="entry-date"><a href="#e2"><time datetime="2026-10-05T09:30:00Z">2026-10-05<\/time><\/a><\/p>/);
+ assert.match(html, /<span class="meta">updated 2026-10-04<\/span>/);
+ // Its body as a section's: the citation's number, linking to its reference.
+ assert.match(html, /Then this<sup class="cite"><a href="#c-w1">\[2\]<\/a><\/sup>/);
+ // The header shows the newest entry as the update.
+ assert.match(html, /updated 2026-10-05/);
+});
+
+test("the Markdown export: the Timeline before the sections, newest first, citations numbered", () => {
+ const md = reportExportMarkdown(view(), { footer: FOOTER });
+ const at = (s: string) => md.indexOf(s);
+ assert.ok(at("It starts here [1]") < at("## Timeline"));
+ assert.ok(at("## Timeline") < at("## Section"));
+ assert.ok(at("### 2026-10-05 — The third update") < at("### 2026-10-03 — The second update, a"));
+ assert.ok(at("### 2026-10-03 — The second update, b") < at("### 2026-10-02 — The first update"));
+ assert.match(md, /Then this \[2\]/);
+ assert.match(md, /\*updated 2026-10-04\*/);
+});
+
+test("the change summary names entries added, removed and edited", () => {
+ const prev = report();
+ const next = report();
+ next.entries!.push({ id: "e5", date: "2026-10-09", title: "A fresh update", body: "New." });
+ assert.deepEqual(reportChangeSummary(prev, next), ["Entry added: e5 “A fresh update”"]);
+ const two = report();
+ two.entries!.push({ id: "e5", date: "2026-10-09", title: "A", body: "x" }, { id: "e6", date: "2026-10-09", title: "B", body: "y" });
+ assert.deepEqual(reportChangeSummary(prev, two), ["2 entries added: e5 “A”, e6 “B”"]);
+ const edited = report();
+ edited.entries!.splice(0, 1);
+ edited.entries![0].body = "Then [that](cite:w1).";
+ assert.deepEqual(reportChangeSummary(prev, edited), [
+ "Entry removed: e1 “The first update”",
+ "Entry edited: e2 (body)",
+ ]);
+ assert.deepEqual(reportChangeSummary(null, prev), ["First revision: 1 section, 0 claims, 3 citations, 4 timeline entries."]);
+ assert.deepEqual(reportChangeSummary(null, { ...prev, entries: undefined }), ["First revision: 1 section, 0 claims, 3 citations."]);
+});
diff --git a/common/lib/report/uses.ts b/common/lib/report/uses.ts
@@ -2,7 +2,8 @@
// the numbering and the back-link index share, so they cannot disagree about
// what a report cites or in what order.
//
-// Reading order: the summary; then each section's body, and each of its
+// Reading order: the summary; then the timeline's entries, newest first
+// (./entries.ts, as the page shows them); then each section's body, and each of its
// claims in turn — the claim's source sentence, its findings, then the
// citations listed under it. Within a markdown field, `cite:` links in the
// order they are written (lib/citations/inline.ts).
@@ -11,16 +12,19 @@
import { extractCiteRefs, numberCitations } from "../citations/inline";
import type { PathSegment } from "../citations/validate";
+import { entryOrder } from "./entries";
import type { Report } from "./schema";
-export type CitationUseField = "summary" | "body" | "sourceQuote" | "findings" | "citations";
+export type CitationUseField = "summary" | "entry" | "body" | "sourceQuote" | "findings" | "citations";
export type CitationUse = {
citationId: string;
- // The section and claim the use is in; null in the summary (both) or a
- // section's body (the claim).
+ // The section and claim the use is in; null in the summary or an entry
+ // (both) or a section's body (the claim).
sectionId: string | null;
claimId: string | null;
+ // The timeline entry the use is in (field `entry`); absent elsewhere.
+ entryId?: string;
field: CitationUseField;
// The JSON path of the field (with the list index for `citations`).
path: PathSegment[];
@@ -42,6 +46,20 @@ export function reportCitationUses(report: Report): CitationUse[] {
}
};
inline(report.summary, "summary", ["summary"], null, null);
+ const entries = report.entries ?? [];
+ for (const i of entryOrder(entries)) {
+ for (const ref of extractCiteRefs(entries[i].body)) {
+ out.push({
+ citationId: ref.id,
+ sectionId: null,
+ claimId: null,
+ entryId: entries[i].id,
+ field: "entry",
+ path: ["entries", i, "body"],
+ label: ref.label,
+ });
+ }
+ }
report.sections.forEach((section, si) => {
const sp: PathSegment[] = ["sections", si];
inline(section.body, "body", [...sp, "body"], section.id, null);
diff --git a/common/lib/report/validate.ts b/common/lib/report/validate.ts
@@ -8,8 +8,11 @@
// - its id is a report id (and, when asked, its directory's name);
// - the dates are dates, `updated` not before `published`;
// - the verdict overrides follow the shared rule (./verdicts.mjs);
-// - section and claim ids are reference ids, unique together (they are the
-// report page's anchors) and none of the page's own (./slideRules.ts);
+// - section, claim and entry ids are reference ids, unique together (they
+// are the report page's anchors) and none of the page's own
+// (./slideRules.ts);
+// - a timeline entry has a title (one line), a body and dates, its
+// `updated` not before its `date`;
// - the slide fields are in bounds and cite what the article cites
// (slideProblems, below);
// - a sweep's claims carry no verdict;
@@ -17,8 +20,8 @@
// spans, safe paths, URLs, verification);
// - every reference resolves: `subject.source`, each claim's `sourceQuote`
// (to a `source` citation), each listed citation (none twice in one
-// claim), and every `[label](cite:<id>)` link in the summary, the bodies
-// and the findings.
+// claim), and every `[label](cite:<id>)` link in the summary, the
+// entries, the bodies and the findings.
//
// What this cannot check is the disk and the corpus — that a still or the video exists, that
// a quote matches its cues; compose does those, where both are at hand.
@@ -36,6 +39,7 @@ import {
} from "../citations/validate";
import { extractCiteRefs } from "../citations/inline";
import { isRefId } from "../citations/schema";
+import { reportDateMs } from "./entries";
import { reportSchema, isReportId, type Claim, type Report, type SlideSpec } from "./schema";
import { RESERVED_ANCHOR_IDS, SLIDE_LINE_MAX, SLIDE_POINT_MAX, SLIDE_POINTS_MAX } from "./slideRules";
import { slidePointText } from "./slides";
@@ -117,7 +121,7 @@ function reportProblems(report: Report, opts: ReportValidateOptions): Problem[]
out.push(...citationMapProblems(citations, report.sources));
if (report.method !== undefined && extractCiteRefs(report.method).length > 0) {
- out.push(problem(["method"], "cites nothing: a citation belongs in the summary, a section or a claim"));
+ out.push(problem(["method"], "cites nothing: a citation belongs in the summary, an entry, a section or a claim"));
}
if (!report.subject) {
for (const [id, c] of Object.entries(citations)) {
@@ -166,6 +170,23 @@ function reportProblems(report: Report, opts: ReportValidateOptions): Problem[]
});
});
+ (report.entries ?? []).forEach((entry, ei) => {
+ const ep: PathSegment[] = ["entries", ei];
+ anchor(entry.id, ep);
+ if (blank(entry.title)) out.push(problem([...ep, "title"], "must not be blank"));
+ else if (/[\r\n]/.test(entry.title)) out.push(problem([...ep, "title"], "must be one line"));
+ if (blank(entry.body)) out.push(problem([...ep, "body"], "must not be blank"));
+ const dated = isReportDate(entry.date);
+ if (!dated) out.push(problem([...ep, "date"], "must be YYYY-MM-DD or an ISO 8601 date-time with a zone"));
+ if (entry.updated !== undefined) {
+ if (!isReportDate(entry.updated)) {
+ out.push(problem([...ep, "updated"], "must be YYYY-MM-DD or an ISO 8601 date-time with a zone"));
+ } else if (dated && reportDateMs(entry.updated) < reportDateMs(entry.date)) {
+ out.push(problem([...ep, "updated"], "is before the entry's date"));
+ }
+ }
+ });
+
out.push(...slideProblems(report));
for (const use of reportCitationUses(report)) {
diff --git a/common/lib/report/views.ts b/common/lib/report/views.ts
@@ -5,6 +5,8 @@
// /reports/index.json ReportIndexView the report index (and a cited site's home)
// /reports/<reportId>/page.json ReportPageView one report, its citations resolved
// /reports/<reportId>/citations.{json,csv} the report's citations, for download
+// /reports/<reportId>/feed.{xml,json} its timeline as RSS 2.0 and JSON Feed 1.1 (./feeds.ts),
+// for a report with entries on a site with a public URL
// /reports/<reportId>/stills/… a source citation's still, as the report names it
// /reports/<reportId>/history/history.json ReportHistoryView its revisions (lib/report/revisions.ts)
// /reports/<reportId>/history/repo/ a dumb-HTTP clone of its revision history
@@ -55,6 +57,7 @@ import { quoteTokens } from "../citations/verify";
import { formatTimestamp } from "../vtt";
import type { CitedIn } from "./citedIn";
import type { ReportHistoryRef } from "./revisions";
+import { effectiveUpdated, entriesNewestFirst } from "./entries";
import type { Claim, Report, ReportKind, ReportSlidesSpec, SlideSpec } from "./schema";
import { reportCitationNumbers } from "./uses";
import { resolveVerdicts, VERDICTS, type Verdict, type VerdictStyle } from "./verdicts";
@@ -111,6 +114,24 @@ export function reportExportDownloadPath(reportId: string, format: ReportExportF
return `/reports/${reportId}/${REPORT_EXPORT_FILENAMES[format]}`;
}
+// A report's feeds of its timeline (./feeds.ts): RSS 2.0 and JSON Feed 1.1,
+// published beside its page only for a report with entries on a site with a
+// public URL (a feed's links are absolute).
+export const REPORT_FEED_FORMATS = ["rss", "json"] as const;
+export type ReportFeedFormat = (typeof REPORT_FEED_FORMATS)[number];
+export const REPORT_FEED_FILENAMES: Readonly<Record<ReportFeedFormat, string>> = {
+ rss: "feed.xml",
+ json: "feed.json",
+};
+export const REPORT_FEED_MIME: Readonly<Record<ReportFeedFormat, string>> = {
+ rss: "application/rss+xml",
+ json: "application/feed+json",
+};
+
+export function reportFeedPath(reportId: string, format: ReportFeedFormat): string {
+ return `/reports/${reportId}/${REPORT_FEED_FILENAMES[format]}`;
+}
+
// A file the report names relative to its own directory (a still,
// `stills/a01.png` — validated by lib/report/validate.ts never to leave it),
// as published.
@@ -263,6 +284,17 @@ export type ClaimView = {
slide?: SlideSpec;
};
+// A timeline entry (report.json `entries[]`). Its id is its anchor on the
+// page (`#<id>`); its body is markdown citing inline, rendered as a section's.
+export type EntryView = {
+ id: string;
+ // When it was added, and last changed: as the document gives them.
+ date: string;
+ updated?: string;
+ title: string;
+ body: string;
+};
+
export type SectionView = {
id: string;
title: string;
@@ -288,6 +320,8 @@ export type ReportPageView = {
// How it was checked (markdown, cites nothing).
method?: string;
published?: string;
+ // When it last changed: report.json `updated`, or its newest timeline
+ // entry's date when that is later (lib/report/entries.ts effectiveUpdated).
updated?: string;
// The document under review: its id in `sources`.
subject?: string;
@@ -299,10 +333,16 @@ export type ReportPageView = {
// Every citation the report cites, numbered; one it defines but never cites
// is left out.
citations: Record<string, CitationView>;
+ // The timeline, newest first (lib/report/entries.ts); absent when none.
+ entries?: EntryView[];
sections: SectionView[];
// The report as files, and its citations as data, when compose published
// them (each a site-root path).
downloads?: ReportDownloads;
+ // Its timeline's feeds, when compose published them: ABSOLUTE URLs (a
+ // feed is read off the site, and the page's <link rel="alternate"> must
+ // name it so).
+ feeds?: ReportFeeds;
// Its newest revision and the history page (lib/report/revisions.ts), when
// compose published a history.
history?: ReportHistoryRef;
@@ -312,6 +352,8 @@ export type ReportPageView = {
export type ReportVideoView = { src: string; poster?: string; caption?: string };
+export type ReportFeeds = Record<ReportFeedFormat, string>;
+
// What a report page offers to download: its exports (html, pdf, md, the
// evidence pack as zip) and its citations (json, csv). Each key is present
// only when the file is published.
@@ -357,6 +399,8 @@ export type CitedInView = CitedIn & {
href: string;
reportTitle: string;
sectionTitle?: string;
+ // The timeline entry's title, when cited in one.
+ entryTitle?: string;
claimTitle?: string;
claimText?: string;
verdict?: Verdict;
@@ -417,6 +461,8 @@ export type ReportViewResolver = {
post?: (c: PostCitation) => { author?: string; text?: string; shot?: string } | undefined;
downloads?: ReportDownloads;
history?: ReportHistoryRef;
+ // The report's feeds (absolute URLs), carried only by a report with entries.
+ feeds?: ReportFeeds;
};
function sourceView(id: string, s: Source): SourceView {
@@ -552,11 +598,17 @@ export function buildReportPageView(report: Report, resolve: ReportViewResolver)
summary: report.summary,
method: report.method,
published: report.published,
- updated: report.updated,
+ updated: effectiveUpdated(report),
subject: subjectId,
sources: sourceViews,
verdicts: resolveVerdicts(report.verdicts),
citations,
+ entries:
+ report.entries && report.entries.length > 0
+ ? entriesNewestFirst(report.entries).map((e) =>
+ defined({ id: e.id, date: e.date, updated: e.updated, title: e.title, body: e.body }),
+ )
+ : undefined,
sections: report.sections.map((s) =>
defined({
id: s.id,
@@ -581,6 +633,7 @@ export function buildReportPageView(report: Report, resolve: ReportViewResolver)
}),
),
downloads: resolve.downloads,
+ feeds: report.entries && report.entries.length > 0 ? resolve.feeds : undefined,
history: resolve.history,
slides: report.slides,
});
@@ -674,6 +727,12 @@ function headerDate(v: string): string {
return /^\d{4}-\d{2}-\d{2}T/.test(v) ? v.slice(0, 10) : v;
}
+// A timeline entry's date as the page shows it: the day, as the header's
+// dates (a date-time's time is left to its `datetime` attribute).
+export function entryDateLabel(v: string): string {
+ return headerDate(v);
+}
+
// The header's date line, before its revision: the published date, then
// "updated <date>" when it differs.
export function reportDateParts(view: Pick<ReportPageView, "published" | "updated">): string[] {
@@ -731,10 +790,10 @@ export function reportIndexEntry(view: ReportPageView): ReportIndexEntry {
});
}
-// The anchor of a place in a report: the claim, else the section, else none
-// (the summary — the page's top).
-export function citedInHref(entry: Pick<CitedIn, "reportId" | "sectionId" | "claimId">): string {
- const anchor = entry.claimId ?? entry.sectionId;
+// The anchor of a place in a report: the claim, else the section, else the
+// timeline entry, else none (the summary — the page's top).
+export function citedInHref(entry: Pick<CitedIn, "reportId" | "sectionId" | "claimId" | "entryId">): string {
+ const anchor = entry.claimId ?? entry.sectionId ?? entry.entryId;
return `${reportPagePath(entry.reportId)}${anchor ? `#${anchor}` : ""}`;
}
@@ -749,12 +808,14 @@ export function citedInViews(entries: readonly CitedIn[], reports: readonly Repo
if (!report) continue;
const section = e.sectionId ? report.sections.find((s) => s.id === e.sectionId) : undefined;
const claim = e.claimId ? section?.claims.find((c) => c.id === e.claimId) : undefined;
+ const entry = e.entryId ? report.entries?.find((x) => x.id === e.entryId) : undefined;
out.push(
defined({
...e,
href: citedInHref(e),
reportTitle: reportFullTitle(report),
sectionTitle: section?.title,
+ entryTitle: entry?.title,
claimTitle: claim?.title,
claimText: claim?.text,
verdict: claim?.verdict,
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);
diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
+- **A report can keep a timeline, and a reader can subscribe to it.** `report.json` `entries` (each `{ "id", "date", "title", "body" }`, optionally `updated`) shows as **Timeline** after In brief: dated entries, newest first, each date linking to the entry's own place on the page (`#<id>`), each body citing as a section's does. The report counts as updated at its newest entry, in its header and the report list. Each entry is a slide too, and the HTML, PDF and Markdown downloads carry the Timeline. On a site with a public URL, the report publishes its timeline as feeds — `feed.xml` (RSS) and `feed.json` (JSON Feed) beside its page — and its header reads **Subscribe: RSS · JSON**; a feed reader finds them from the page too. A site with no public URL publishes no feed. The MCP's `get_report` reads the timeline, and its `section` may name an entry. Needs a rebuild and deploy of each site with reports.
- **A report reads as an article, as slides, or in an overview of both.** A report page's header has a switch, **Article · Slides · Overview**. **Slides** shows the report one 16:9 slide at a time, fitted to the screen in the site's accent and light or dark: a title slide, In brief, What the check found, one slide per section and per claim — its verdict, its finding and the evidence it rests on (a clip's still, a post's screenshot, the article's sentence, with its quote) — and a sources slide. ← → (and Page Up, Page Down, Home, End) or a swipe step through them; a citation's number opens its card; every slide has **Read this in the article**, and Esc goes back to the article where the slide stands. On a phone a slide reads top to bottom, its evidence under its words. **Overview** lays the article's parts and their slides side by side on one tilted plane, each part beside its slide: ↑ ↓ walk them, a part opens the article there and a slide opens the slides there; with reduced motion, on a narrow screen, arriving by keyboard or with **Flat**, the same pairs lie flat. Switching keeps your place: the part of the article on screen opens its slide, and back. The view and the place are in the address (`?rv=slides#s-4`, `?rv=iso#<part>`), so a shared link opens the same slide; the article itself is unchanged for search engines and readers without script. A report's author can shape its slides in `report.json` (`slides`, and a section's or claim's `slide`: its own title, up to five short points, which evidence to show, a layout, or hidden); a report without them still has slides, made from its text. Needs a rebuild and deploy of each site with reports.
- **A report can be saved as slides.** The download line adds **Slides** (`slides.html`, one file that opens with no network: the slides with their pictures inside, the arrow keys stepping through them, each citation numbered to a reference list) and **Slides PDF** (one page per slide), and the evidence pack carries the slides too. Needs `reports export` (or prepare) and a rebuild and deploy of each site with reports.
- **Posts withdrawn from a site leave a stand-in, not a stale copy.** When an X channel's posts stop being published on a site — while X posts are private, say — the site's build ships an empty posts manifest and an empty page at every address they were served from, sent with `Cache-Control: no-store`, so a reader (or the hub) asking for them gets "no posts" instead of the copy Cloudflare's edge kept for up to a week. The hub does the same for every X channel a public site carries.
diff --git a/export/app/components/reports/MomentArticle.tsx b/export/app/components/reports/MomentArticle.tsx
@@ -150,7 +150,7 @@ export default function MomentArticle({ view }: { view: MomentPageView }) {
<ul data-cited-in="" className="flex flex-col gap-2">
{view.citedIn.map((e) => (
<li
- key={`${e.reportId}/${e.sectionId}/${e.claimId}/${e.citationId}`}
+ key={`${e.reportId}/${e.sectionId}/${e.claimId}/${e.entryId ?? ""}/${e.citationId}`}
className="flex flex-col gap-1 rounded-lg border border-border bg-card p-3 text-sm"
>
<a href={e.href} className={`font-medium ${textLink}`}>
@@ -158,7 +158,13 @@ export default function MomentArticle({ view }: { view: MomentPageView }) {
</a>
<div className="flex flex-wrap items-center gap-2 text-muted-foreground">
{e.verdict && e.verdictStyle && <VerdictChip verdict={e.verdict} style={e.verdictStyle} />}
- {e.sectionTitle ? <span>{e.sectionTitle}</span> : !e.sectionId && <span>Summary</span>}
+ {e.sectionTitle ? (
+ <span>{e.sectionTitle}</span>
+ ) : e.entryId ? (
+ <span>Timeline{e.entryTitle ? ` — ${e.entryTitle}` : ""}</span>
+ ) : (
+ !e.sectionId && <span>Summary</span>
+ )}
{(e.claimTitle ?? e.claimText) && (
<span className="text-foreground">— {e.claimTitle ?? e.claimText}</span>
)}
diff --git a/export/app/components/reports/ReportArticle.tsx b/export/app/components/reports/ReportArticle.tsx
@@ -1,4 +1,4 @@
-import { ArrowDownToLine } from "lucide-react";
+import { ArrowDownToLine, Rss } from "lucide-react";
import { AddedMark, CitationCard } from "yt-dlp-transcript-common/components/citations/CitationCard";
import { CitationsProvider } from "yt-dlp-transcript-common/components/citations/CitationsContext";
import { CitedMarkdown } from "yt-dlp-transcript-common/components/citations/CitedMarkdown";
@@ -9,6 +9,7 @@ import { REPORT_PAGE_ANCHOR_IDS } from "yt-dlp-transcript-common/lib/report/slid
import { hasSlides } from "yt-dlp-transcript-common/lib/report/slides";
import type { SourceArchive } from "yt-dlp-transcript-common/lib/citations/schema";
import {
+ entryDateLabel,
foundGroups,
orderedCitations,
reportDateParts,
@@ -18,6 +19,7 @@ import {
verdictTally,
type CitationView,
type ClaimView,
+ type EntryView,
type ReportPageView,
type SourceCitationView,
} from "yt-dlp-transcript-common/lib/report/views";
@@ -28,7 +30,8 @@ import { ArchiveList, ReportName, SourceBlock, SubjectCard, textLink } from "./p
// revision, linked to its history; the document under review as a card on its
// colour's rail, with its archive links; the subtitle), the three tiers each
// opened by a depth marker — the quick take (a fact-check's tally, the
-// summary), what the check found, and every claim with its evidence: each
+// summary), the timeline when the report keeps one (dated entries, newest
+// first, each its own anchor), what the check found, and every claim with its evidence: each
// claim its verdict and flag, the document's own sentence (its still), the
// findings with their inline citations, and the evidence cards (what the
// report added first, marked; what the document gave itself folded away
@@ -36,6 +39,10 @@ import { ArchiveList, ReportName, SourceBlock, SubjectCard, textLink } from "./p
// the downloads: the report as files (HTML, PDF, Markdown, the evidence pack)
// and its citations as data.
//
+// A report with a timeline on a site with a public URL publishes feeds of it
+// (common/lib/report/feeds.ts): the header links them ("Subscribe"), and the
+// page's metadata names them (lib/reports.ts reportMetadata).
+//
// ONE ARTICLE, THREE SHAPES (release 22): the header carries the view switch
// (Article | Slides | Overview — common/components/report/slides/ReportReader),
// and every place a slide stands for has its id here: the header, the quick
@@ -144,6 +151,34 @@ function TierMarker({ depth }: { depth: 1 | 2 | 3 }) {
);
}
+// A timeline entry: its date first and largest (what a timeline is read by),
+// linking to its own anchor, then its title and its body. A dot on the
+// timeline's rail marks it.
+function Entry({ entry }: { entry: EntryView }) {
+ return (
+ <li id={entry.id} data-entry={entry.id} className="relative flex scroll-mt-20 flex-col gap-1.5">
+ <span
+ aria-hidden="true"
+ className="absolute top-[0.45rem] -left-[calc(1rem+4.5px)] size-2 rounded-full bg-brand ring-4 ring-background sm:-left-[calc(1.25rem+4.5px)]"
+ />
+ <p className="flex flex-wrap items-baseline gap-x-2 font-mono text-sm">
+ <a href={`#${entry.id}`} data-entry-date="" className="font-semibold text-brand hover:underline">
+ <time dateTime={entry.date}>{entryDateLabel(entry.date)}</time>
+ </a>
+ {entry.updated && (
+ <span className="text-xs text-muted-foreground">
+ updated <time dateTime={entry.updated}>{entryDateLabel(entry.updated)}</time>
+ </span>
+ )}
+ </p>
+ <h3 className="font-display text-lg font-semibold leading-snug text-foreground">{entry.title}</h3>
+ <CitedMarkdown className={proseClass}>
+ {entry.body}
+ </CitedMarkdown>
+ </li>
+ );
+}
+
// A claim: its verdict, flag and title; the document's own sentence (its
// still, else its verbatim words) when the claim cites one, else the claim as
// the report states it, plain; the findings; the evidence.
@@ -278,6 +313,19 @@ export default function ReportArticle({ view }: { view: ReportPageView }) {
)}
</p>
)}
+ {view.feeds && (
+ <p data-report-feeds="" className="flex items-center gap-1.5 font-mono text-xs text-muted-foreground">
+ <Rss className="size-3.5" aria-hidden />
+ Subscribe:
+ <a href={view.feeds.rss} type="application/rss+xml" data-feed="rss" className={textLink}>
+ RSS
+ </a>
+ <span aria-hidden="true">·</span>
+ <a href={view.feeds.json} type="application/feed+json" data-feed="json" className={textLink}>
+ JSON
+ </a>
+ </p>
+ )}
{hasSlides(view) && (
<div data-report-views="" className="flex flex-wrap items-center gap-3">
<ReportReader pagePath={reportViewPath(view.id)} title={view.title} series={view.series} />
@@ -337,6 +385,20 @@ export default function ReportArticle({ view }: { view: ReportPageView }) {
</p>
</section>
+ {/* The timeline: dated entries, newest first, on a rail. */}
+ {view.entries && view.entries.length > 0 && (
+ <section data-report-timeline="" aria-labelledby="timeline-heading" className="flex flex-col gap-4">
+ <h2 id="timeline-heading" className="font-display text-2xl font-semibold tracking-tight text-foreground">
+ Timeline
+ </h2>
+ <ol className="ml-1 flex flex-col gap-7 border-l border-border pl-4 sm:pl-5">
+ {view.entries.map((e) => (
+ <Entry key={e.id} entry={e} />
+ ))}
+ </ol>
+ </section>
+ )}
+
{/* What the check found: every ruled claim by verdict, one line each. */}
{groups.length > 0 && (
<section id="found" data-report-tier="2" aria-labelledby="found-heading" className="flex scroll-mt-20 flex-col gap-4">
diff --git a/export/app/lib/reports.test.ts b/export/app/lib/reports.test.ts
@@ -83,7 +83,8 @@ test("the report page: header, tally, sections and claims, inline cites, referen
const params = Promise.resolve({ reportId: "demo-factcheck" });
const html = render(await reportPage.default({ params }));
// the header: the series on one line and the title on the next, one muted
- // line of dates and the revision linked to its history, the document under
+ // line of dates and the revision linked to its history, its timeline's
+ // feeds (the fixture keeps a timeline), the document under
// review as a card, the subtitle
const header = html.slice(html.indexOf("<header"), html.indexOf("</header>"));
assert.match(
@@ -92,7 +93,7 @@ test("the report page: header, tally, sections and claims, inline cites, referen
);
assert.match(
header,
- /<\/h1><p data-report-dates=""[^>]*>2026-10-01 · updated 2026-10-04 · <a href="\/reports\/demo-factcheck\/history\/" data-report-revision="2"[^>]*>revision 2<\/a><\/p><div data-report-views=""[^>]*><div role="group" aria-label="Read as" data-view-switch="article"[^]*?<\/div><\/div><div id="source-s0" data-subject-source="s0"/,
+ /<\/h1><p data-report-dates=""[^>]*>2026-10-01 · updated 2026-10-04 · <a href="\/reports\/demo-factcheck\/history\/" data-report-revision="2"[^>]*>revision 2<\/a><\/p><p data-report-feeds=""[^>]*>[^]*?Subscribe:[^]*?<\/p><div data-report-views=""[^>]*><div role="group" aria-label="Read as" data-view-switch="article"[^]*?<\/div><\/div><div id="source-s0" data-subject-source="s0"/,
);
// the view switch (release 22): Article pressed, then Slides and Overview
assert.deepEqual(
@@ -175,7 +176,8 @@ test("the report page: header, tally, sections and claims, inline cites, referen
assert.doesNotMatch(html, />\s*\d+ min\s*<|\d+ minutes?\b/, "no reading time");
assert.equal([...tier2.matchAll(/data-depth-dot="filled"/g)].length, 2);
assert.match(tier1, /data-verdict-tally=""[^]*?The article makes four claims/);
- const jumps = [...tier1.slice(tier1.indexOf("data-report-jumps")).matchAll(/<a href="([^"]+)"[^>]*>([^<]+)<\/a>/g)].map((m) => `${m[1]} ${m[2]}`);
+ const jumpsAt = tier1.indexOf("data-report-jumps");
+ const jumps = [...tier1.slice(jumpsAt, tier1.indexOf("</p>", jumpsAt)).matchAll(/<a href="([^"]+)"[^>]*>([^<]+)<\/a>/g)].map((m) => `${m[1]} ${m[2]}`);
assert.deepEqual(jumps, ["#found What the check found", "#claims Every claim", "#downloads Downloads"]);
assert.match(html, /id="downloads"/);
// what the check found: groups in a fixed order, every row a link to its claim
@@ -228,6 +230,55 @@ test("a titled claim with no sentence of the document: its text follows plain, u
assert.doesNotMatch(claim, /data-source-sentence|says:|<q[^>]*>He opened/);
});
+test("the fixture's own timeline: newest first, its feeds on the site's URL", () => {
+ const view = fixture.buildFixtureReportView();
+ assert.deepEqual(view.entries?.map((e) => e.id), ["update-mail", "update-opening"]);
+ assert.equal(view.updated, "2026-10-04");
+ assert.deepEqual(view.feeds, {
+ rss: `${fixture.FIXTURE_SITE_URL}/reports/demo-factcheck/feed.xml`,
+ json: `${fixture.FIXTURE_SITE_URL}/reports/demo-factcheck/feed.json`,
+ });
+});
+
+test("a report's timeline: after In brief, before the claims, newest first, dated, each its anchor; Subscribe and the alternates with feeds", async () => {
+ const view = fixture.buildFixtureReportView();
+ const feeds = {
+ rss: "https://reports.example.org/reports/demo-factcheck/feed.xml",
+ json: "https://reports.example.org/reports/demo-factcheck/feed.json",
+ };
+ const entries = [
+ { id: "update-2", date: "2026-10-06T09:30:00Z", title: "A second update", body: "More [on stream](cite:c01)." },
+ { id: "update-1", date: "2026-10-05", updated: "2026-10-06", title: "A first update", body: "Something new." },
+ ];
+ const html = render(React.createElement(ReportArticle, { view: { ...view, entries, feeds } }));
+ const at = (s: string) => html.indexOf(s);
+ assert.ok(at('id="in-brief"') < at("data-report-timeline"));
+ assert.ok(at("data-report-timeline") < at('id="found"'));
+ assert.ok(at('data-entry="update-2"') < at('data-entry="update-1"'));
+ const newest = html.slice(at('<li id="update-2"'), html.indexOf("</li>", at('<li id="update-2"')));
+ assert.match(newest, /<a href="#update-2" data-entry-date=""[^>]*><time dateTime="2026-10-06T09:30:00Z">2026-10-06<\/time><\/a>/);
+ assert.match(newest, /<h3[^>]*>A second update<\/h3>/);
+ // Its body cites as a section's does: the citation's number.
+ assert.match(newest, /on stream<\/a><sup[^>]*><a href="#c-c01" data-cite-number="1"/);
+ assert.match(html, /updated <time dateTime="2026-10-06">2026-10-06<\/time>/);
+ // The header: Subscribe, to both feeds.
+ const header = html.slice(at("<header"), at("</header>"));
+ assert.match(header, /data-report-feeds=""[^]*?Subscribe:[^]*?<a href="https:\/\/reports\.example\.org\/reports\/demo-factcheck\/feed\.xml" type="application\/rss\+xml" data-feed="rss"/);
+ assert.match(header, /<a href="https:\/\/reports\.example\.org\/reports\/demo-factcheck\/feed\.json" type="application\/feed\+json" data-feed="json"/);
+ const { reportMetadata } = await import("./reports");
+ assert.deepEqual(reportMetadata({ ...view, entries, feeds }).alternates, {
+ types: {
+ "application/rss+xml": [{ url: feeds.rss, title: "Demo Checks: Checking an example article" }],
+ "application/feed+json": [{ url: feeds.json, title: "Demo Checks: Checking an example article" }],
+ },
+ });
+ // No timeline, no feeds: none of it.
+ const bare = { ...view, entries: undefined, feeds: undefined };
+ const plain = render(React.createElement(ReportArticle, { view: bare }));
+ assert.doesNotMatch(plain, /data-report-timeline|data-report-feeds/);
+ assert.equal(reportMetadata(bare).alternates, undefined);
+});
+
test("a video moment: the clip, quote, verification, cue lines, original link, cited in", async () => {
const html = render(
await momentPage.default({ params: Promise.resolve({ moment: ["demo-channel", "abc123", "3126.00-3151.00"] }) }),
diff --git a/export/app/lib/reports.ts b/export/app/lib/reports.ts
@@ -16,6 +16,7 @@ import {
REPORT_INDEX_FORMAT,
REPORT_PAGE_FORMAT,
REPORTS_INDEX_PATH,
+ REPORT_FEED_MIME,
momentViewPath,
reportFullTitle,
reportViewPath,
@@ -83,9 +84,25 @@ export function onlyReportView(): ReportPageView | null {
// A report's page metadata — its page's, and a one-report cited site's home's,
// so the tab reads the same on both: the title as one line names it
// (`<series>: <title>`, the layout's template adds the site), the subtitle as
-// the description.
+// the description, and its timeline's feeds when it publishes them (a
+// `<link rel="alternate">` each, by absolute URL: the layout sets no
+// metadataBase).
export function reportMetadata(view: ReportPageView): Metadata {
- return { title: reportFullTitle(view), ...(view.subtitle ? { description: view.subtitle } : {}) };
+ const title = reportFullTitle(view);
+ return {
+ title,
+ ...(view.subtitle ? { description: view.subtitle } : {}),
+ ...(view.feeds
+ ? {
+ alternates: {
+ types: {
+ [REPORT_FEED_MIME.rss]: [{ url: view.feeds.rss, title }],
+ [REPORT_FEED_MIME.json]: [{ url: view.feeds.json, title }],
+ },
+ },
+ }
+ : {}),
+ };
}
// The moment keys to build pages for (only keys exactly as momentKey spells
diff --git a/export/e2e-report/contract.ts b/export/e2e-report/contract.ts
@@ -1,6 +1,7 @@
// The cited fixture site's compose step (stage.ts runs it, in a child process
// with the stage's environment): what compose's reports stage writes beside
-// the views — the citation downloads, the report's exports (HTML, PDF,
+// the views — the citation downloads, its timeline's feeds (lib/report/feeds.ts,
+// on the site's public URL), the report's exports (HTML, PDF,
// Markdown, evidence pack, by publish/reportExports.ts's writer), its two
// revisions (fixture.ts FIXTURE_REVISIONS, committed by that writer into a
// store in the stage) and their published history (history.json and the
@@ -20,7 +21,9 @@ const { getPaths } = await import("yt-dlp-transcript-common/lib/paths");
const { getSite } = await import("yt-dlp-transcript-common/lib/site");
const { emitAiFiles, emitFederationFiles } = await import("yt-dlp-transcript-common/bin/compose-site");
const { citationSet, citationsCsv } = await import("yt-dlp-transcript-common/publish/composeReports");
-const { MOMENTS_INDEX_PATH, REPORTS_INDEX_PATH, reportCitationsDownloadPath, reportViewPath } = await import(
+const { renderReportJsonFeed, renderReportRss, reportFeedUrls } = await import("yt-dlp-transcript-common/lib/report/feeds");
+const { parseSiteUrl } = await import("yt-dlp-transcript-common/lib/siteSchema");
+const { MOMENTS_INDEX_PATH, REPORTS_INDEX_PATH, reportCitationsDownloadPath, reportFeedPath, reportViewPath } = await import(
"yt-dlp-transcript-common/lib/report/views"
);
const { FIXTURE_REVISIONS, buildFixtureReportView, fixtureReportFile, readFixtureReport } = await import(
@@ -54,6 +57,16 @@ for (const entry of index.reports) {
);
fs.writeFileSync(pub(reportCitationsDownloadPath(entry.id, "csv")), citationsCsv(view));
+ // Its timeline's feeds, as compose writes them: on the site's public URL,
+ // which the view must already name (fixture.ts FIXTURE_SITE_URL).
+ const siteUrl = parseSiteUrl(site.siteUrl);
+ if (!siteUrl) throw new Error("contract.ts: the fixture site has no public siteUrl");
+ if (JSON.stringify(view.feeds) !== JSON.stringify(reportFeedUrls(siteUrl, entry.id))) {
+ throw new Error(`contract.ts: the view's feeds ${JSON.stringify(view.feeds)} are not the site's (${siteUrl})`);
+ }
+ fs.writeFileSync(pub(reportFeedPath(entry.id, "rss")), renderReportRss(view, { siteUrl }));
+ fs.writeFileSync(pub(reportFeedPath(entry.id, "json")), `${JSON.stringify(renderReportJsonFeed(view, { siteUrl }), null, 2)}\n`);
+
// The report's exports, by the host step's own writer, from the files
// already in public/ (its stills, the post's shot, the clips), published
// where compose publishes them. Each earlier revision is exported first (its
diff --git a/export/e2e-report/overview.spec.ts b/export/e2e-report/overview.spec.ts
@@ -18,7 +18,7 @@ test("overview: each part beside its slide; arrows walk the pairs; a card opens
await expect(page).toHaveURL(`${REPORT}?rv=iso#bridge`);
const ov = overview(page);
await expect(ov).toHaveAttribute("aria-roledescription", "overview");
- await expect(ov.locator("[data-pair]")).toHaveCount(11);
+ await expect(ov.locator("[data-pair]")).toHaveCount(13);
await expect(ov.locator('[data-pair="section:bridge"]')).toHaveAttribute("data-active", "true");
await ov.locator('[data-pair="section:bridge"] [data-ov-card]').focus();
await page.keyboard.press("ArrowDown");
@@ -32,7 +32,7 @@ test("overview: each part beside its slide; arrows walk the pairs; a card opens
await expect(ov.locator('[data-ov-block][tabindex="0"], [data-ov-card][tabindex="0"]')).toHaveCount(2);
await page.keyboard.press("ArrowRight");
await page.keyboard.press("Enter");
- await expect(page).toHaveURL(`${REPORT}?rv=slides#s-6`);
+ await expect(page).toHaveURL(`${REPORT}?rv=slides#s-8`);
await expect(stage(page).locator('[data-slide="claim:claim-2"]')).toBeVisible();
await page.goBack();
@@ -67,7 +67,7 @@ test("overview: tilted for a pointer; flat for reduced motion; the Flat toggle l
test("a phone: the overview lies flat, each part stacked over its slide; nothing scrolls sideways", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 800 });
- await page.goto(`${REPORT}?rv=slides#s-5`);
+ await page.goto(`${REPORT}?rv=slides#s-7`);
await page.locator("[data-reader-overlay] [data-view-switch]").getByRole("button", { name: "Overview" }).click();
await expect(page).toHaveURL(`${REPORT}?rv=iso#claim-1`);
await expect(overview(page)).toHaveAttribute("data-report-overview", "flat");
diff --git a/export/e2e-report/slides.spec.ts b/export/e2e-report/slides.spec.ts
@@ -6,12 +6,14 @@ import type { Page } from "@playwright/test";
// (stage.ts). The
// fixture's deck, from its report.json and its slide fields:
//
-// 1 title 2 In brief 3 What the check found
-// 4 The bridge (points, the city's record as its evidence)
-// 5 claim-1 (evidence: the stream) 6 claim-2 ("Every year?", points)
-// 7 Moving away (no body: its claims)
-// 8 claim-3 (quote: the post) 9 claim-4 (statement) 10 claim-5
-// 11 sources (the closing line)
+// 1 title 2 In brief
+// 3 update-mail 4 update-opening (the timeline, newest first: timeline.spec.ts)
+// 5 What the check found
+// 6 The bridge (points, the city's record as its evidence)
+// 7 claim-1 (evidence: the stream) 8 claim-2 ("Every year?", points)
+// 9 Moving away (no body: its claims)
+// 10 claim-3 (quote: the post) 11 claim-4 (statement) 12 claim-5
+// 13 sources (the closing line)
//
// Every test also holds the page's requests to the cited promise (helpers.ts):
// the slides are built from the report's own page.json, nothing else.
@@ -29,7 +31,7 @@ test("the switch: Article pressed in the header; every place a slide stands for
await expect(sw.getByRole("button")).toHaveText(["Article", "Slides", "Overview"]);
await expect(sw.getByRole("button", { name: "Article" })).toHaveAttribute("aria-pressed", "true");
await expect(sw.getByRole("button", { name: "Slides" })).toHaveAttribute("aria-pressed", "false");
- for (const id of ["report-head", "in-brief", "found", "bridge", "claim-1", "claim-2", "moving", "claim-3", "claim-4", "claim-5", "report-end"]) {
+ for (const id of ["report-head", "in-brief", "update-mail", "update-opening", "found", "bridge", "claim-1", "claim-2", "moving", "claim-3", "claim-4", "claim-5", "report-end"]) {
await expect(page.locator(`[id="${id}"]`), id).toHaveCount(1);
}
});
@@ -38,14 +40,14 @@ test("slides: the switch opens the slide for the part in view, and Esc goes back
await page.goto(`${REPORT}#claim-3`);
await expect(page.locator("article#claim-3")).toBeInViewport();
await switchIn(page, "header").getByRole("button", { name: "Slides" }).click();
- await expect(page).toHaveURL(`${REPORT}?rv=slides#s-8`);
- await expect(stage(page)).toHaveAttribute("data-report-slides", "8");
+ await expect(page).toHaveURL(`${REPORT}?rv=slides#s-10`);
+ await expect(stage(page)).toHaveAttribute("data-report-slides", "10");
await expect(stage(page).locator('[data-slide="claim:claim-3"]')).toBeVisible();
// A region with a live label; the focus is in it; the article behind is inert.
await expect(stage(page)).toHaveAttribute("aria-roledescription", "slides");
await expect(stage(page)).toBeFocused();
await expect(page.locator("[data-slide-label]")).toHaveAttribute("aria-live", "polite");
- await expect(page.locator("[data-slide-label]")).toContainText("Slide 8 of 11: Claim — He has said many times that he would move away.");
+ await expect(page.locator("[data-slide-label]")).toContainText("Slide 10 of 13: Claim — He has said many times that he would move away.");
await expect(page.locator("main")).toHaveAttribute("inert", "");
await expect(switchIn(page, "overlay").getByRole("button", { name: "Slides" })).toHaveAttribute("aria-pressed", "true");
@@ -69,22 +71,26 @@ test("slides: ← → PgUp PgDn Home End step; the rail and the hash follow; Bac
await expect(page).toHaveURL(`${REPORT}?rv=slides#s-2`);
await page.keyboard.press("PageDown");
await expect(page).toHaveURL(`${REPORT}?rv=slides#s-3`);
+ await expect(stage(page).locator('[data-slide="entry:update-mail"]')).toBeVisible();
+ await page.keyboard.press("ArrowRight");
+ await page.keyboard.press("ArrowRight");
+ await expect(page).toHaveURL(`${REPORT}?rv=slides#s-5`);
await expect(stage(page).locator("[data-found-group]")).toHaveCount(4);
await page.keyboard.press("End");
- await expect(page).toHaveURL(`${REPORT}?rv=slides#s-11`);
+ await expect(page).toHaveURL(`${REPORT}?rv=slides#s-13`);
await expect(stage(page).locator('[data-slide="sources"]')).toContainText("Every citation opens the moment it quotes.");
await page.keyboard.press("ArrowRight");
- await expect(page).toHaveURL(`${REPORT}?rv=slides#s-11`);
+ await expect(page).toHaveURL(`${REPORT}?rv=slides#s-13`);
await page.keyboard.press("PageUp");
- await expect(page).toHaveURL(`${REPORT}?rv=slides#s-10`);
+ await expect(page).toHaveURL(`${REPORT}?rv=slides#s-12`);
await page.keyboard.press("Home");
await expect(page).toHaveURL(`${REPORT}?rv=slides#s-1`);
- await expect(page.locator(".rs-rail button")).toHaveCount(11);
- await page.locator(".rs-rail button").nth(5).click();
- await expect(page).toHaveURL(`${REPORT}?rv=slides#s-6`);
+ await expect(page.locator(".rs-rail button")).toHaveCount(13);
+ await page.locator(".rs-rail button").nth(7).click();
+ await expect(page).toHaveURL(`${REPORT}?rv=slides#s-8`);
await expect(stage(page).locator('[data-slide="claim:claim-2"]')).toContainText("Every year?");
await page.getByRole("button", { name: "Previous slide" }).click();
- await expect(page).toHaveURL(`${REPORT}?rv=slides#s-5`);
+ await expect(page).toHaveURL(`${REPORT}?rv=slides#s-7`);
// Stepping replaces the entry: one Back leaves the slides.
await page.goBack();
await expect(page).toHaveURL(REPORT);
@@ -92,19 +98,19 @@ test("slides: ← → PgUp PgDn Home End step; the rail and the hash follow; Bac
});
test("slides: a shared link opens the same slide; a section's link opens its slide", async ({ page }) => {
- await page.goto(`${REPORT}?rv=slides#s-4`);
+ await page.goto(`${REPORT}?rv=slides#s-6`);
await expect(stage(page).locator('[data-slide="section:bridge"]')).toBeVisible();
await expect(stage(page).locator('[data-slide="section:bridge"] .rs-points li')).toHaveText([
"The article dates the opening to 2018",
/He says 2019 on stream, and the city's record\s*\[4\]\s*agrees/,
]);
await page.goto(`${REPORT}?rv=slides#moving`);
- await expect(page).toHaveURL(`${REPORT}?rv=slides#s-7`);
+ await expect(page).toHaveURL(`${REPORT}?rv=slides#s-9`);
await expect(stage(page).locator('[data-slide="section:moving"] .rs-claimlist li')).toHaveCount(3);
});
test("slides: a citation's number opens its card; the evidence shows the clip's poster", async ({ page }) => {
- await page.goto(`${REPORT}?rv=slides#s-5`);
+ await page.goto(`${REPORT}?rv=slides#s-7`);
const slide = stage(page).locator('[data-slide="claim:claim-1"]');
await expect(slide.locator('[data-verdict="CONTRADICTED"]')).toBeVisible();
const pic = slide.locator('[data-slide-evidence="c01"] img');
@@ -115,11 +121,11 @@ test("slides: a citation's number opens its card; the evidence shows the clip's
await expect(page.locator('[data-cite-preview="c01"]')).toBeVisible();
await expect(page.locator('[data-cite-preview="c01"]')).toContainText("The bridge opened in the spring of twenty nineteen");
// The click opened the card; it did not leave the slide.
- await expect(page).toHaveURL(`${REPORT}?rv=slides#s-5`);
+ await expect(page).toHaveURL(`${REPORT}?rv=slides#s-7`);
});
test("slides: Read this in the article opens the article at the slide's place", async ({ page }) => {
- await page.goto(`${REPORT}?rv=slides#s-6`);
+ await page.goto(`${REPORT}?rv=slides#s-8`);
await stage(page).locator('[data-slide="claim:claim-2"] [data-slide-read]').click();
await expect(page).toHaveURL(`${REPORT}#claim-2`);
await expect(page.locator("article#claim-2")).toBeInViewport();
@@ -128,7 +134,7 @@ test("slides: Read this in the article opens the article at the slide's place",
test("a phone: the slide reflows, its evidence under its words; nothing scrolls sideways", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 800 });
- await page.goto(`${REPORT}?rv=slides#s-5`);
+ await page.goto(`${REPORT}?rv=slides#s-7`);
const slide = stage(page).locator('[data-slide="claim:claim-1"]');
await expect(slide).toHaveAttribute("data-fit", "reflow");
const words = (await slide.locator("h2").boundingBox())!;
@@ -140,7 +146,7 @@ test("a phone: the slide reflows, its evidence under its words; nothing scrolls
});
test("light and dark: the slides follow the base and wear the site's accent", async ({ page }) => {
- await page.goto(`${REPORT}?rv=slides#s-4`);
+ await page.goto(`${REPORT}?rv=slides#s-6`);
const slide = stage(page).locator('[data-slide="section:bridge"]');
const accent = await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue("--brand").trim());
const band = await slide.locator(".rs-band").evaluate((el) => getComputedStyle(el).backgroundColor);
@@ -172,7 +178,7 @@ test("slides.html opens with no network: one page per slide, its pictures inline
});
await page.setViewportSize({ width: 1280, height: 720 });
await page.goto(url);
- await expect(page.locator(".rs-page")).toHaveCount(11);
+ await expect(page.locator(".rs-page")).toHaveCount(13);
await expect(page.locator('#s-1 [data-slide="title"]')).toContainText("Checking an example article");
const imgs = page.locator("img");
expect(await imgs.count()).toBeGreaterThan(0);
@@ -183,6 +189,6 @@ test("slides.html opens with no network: one page per slide, its pictures inline
await page.keyboard.press("ArrowRight");
await expect(page).toHaveURL(`${url}#s-2`);
await expect(page.locator("#s-2")).toBeInViewport();
- await expect(page.locator("#s-11 [data-export-footer]")).toContainText("Revision 2");
+ await expect(page.locator("#s-13 [data-export-footer]")).toContainText("Revision 2");
expect(asked).toEqual([]);
});
diff --git a/export/e2e-report/timeline.spec.ts b/export/e2e-report/timeline.spec.ts
@@ -0,0 +1,113 @@
+import { expect, test } from "./helpers";
+
+// A REPORT'S TIMELINE (report.json `entries`): dated entries newest first,
+// after In brief and before what the check found, each its own anchor; and,
+// on a site with a public URL, its feeds — linked from the header and named
+// in the page's metadata, served beside the page with their own media types
+// (the `_headers` compose writes, which serve-out applies as Pages does).
+//
+// The fixture's timeline, newest first:
+// update-mail 2026-10-04 "A reader asked about another year"
+// update-opening 2026-10-02 "The opening date, said on air" (updated 2026-10-04)
+// Both cite only c01, the summary's first citation, so no number moves.
+
+const REPORT = "/reports/demo-factcheck/";
+const SITE = "https://reports.example.org";
+const RSS = `${SITE}${REPORT}feed.xml`;
+const JSON_FEED = `${SITE}${REPORT}feed.json`;
+
+test("the Timeline: after In brief, before what the check found, newest first, each dated and anchored", async ({ page }) => {
+ await page.goto(REPORT);
+ const timeline = page.locator("[data-report-timeline]");
+ await expect(timeline.locator("h2")).toHaveText("Timeline");
+ const order = await page.evaluate(() =>
+ ["#in-brief", "[data-report-timeline]", "#found", "#claims"].map((s) => {
+ const el = document.querySelector(s)!;
+ return [...document.querySelectorAll("#in-brief, [data-report-timeline], #found, #claims")].indexOf(el);
+ }),
+ );
+ expect(order).toEqual([0, 1, 2, 3]);
+
+ const entries = timeline.locator("li[data-entry]");
+ expect(await entries.evaluateAll((els) => els.map((e) => e.id))).toEqual(["update-mail", "update-opening"]);
+ const newest = entries.nth(0);
+ await expect(newest.locator("[data-entry-date]")).toHaveAttribute("href", "#update-mail");
+ await expect(newest.locator("[data-entry-date] time")).toHaveText("2026-10-04");
+ await expect(newest.locator("[data-entry-date] time")).toHaveAttribute("datetime", "2026-10-04");
+ await expect(newest.locator("h3")).toHaveText("A reader asked about another year");
+ // Its body cites as a section's does: c01 keeps its number.
+ await expect(newest.locator('[data-inline-cite="c01"] [data-cite-number]')).toHaveText("[1]");
+ await expect(entries.nth(1)).toContainText("updated 2026-10-04");
+ // The header's update is the report's own: no entry is newer.
+ await expect(page.locator("[data-report-dates]")).toHaveText("2026-10-01 · updated 2026-10-04 · revision 2");
+
+ // An entry's date is its permalink.
+ await entries.nth(1).locator("[data-entry-date]").click();
+ await expect(page).toHaveURL(`${REPORT}#update-opening`);
+ await expect(page.locator("#update-opening")).toBeInViewport();
+});
+
+test("Subscribe: RSS and JSON in the header, and the page's alternates name both", async ({ page }) => {
+ await page.goto(REPORT);
+ const feeds = page.locator("header [data-report-feeds]");
+ await expect(feeds).toContainText("Subscribe:");
+ await expect(feeds.locator('a[data-feed="rss"]')).toHaveAttribute("href", RSS);
+ await expect(feeds.locator('a[data-feed="rss"]')).toHaveAttribute("type", "application/rss+xml");
+ await expect(feeds.locator('a[data-feed="json"]')).toHaveAttribute("href", JSON_FEED);
+ await expect(feeds.locator('a[data-feed="json"]')).toHaveAttribute("type", "application/feed+json");
+ await expect(page.locator('head link[rel="alternate"][type="application/rss+xml"]')).toHaveAttribute("href", RSS);
+ await expect(page.locator('head link[rel="alternate"][type="application/feed+json"]')).toHaveAttribute("href", JSON_FEED);
+});
+
+test("feed.xml: RSS 2.0, served as application/rss+xml, one item per entry newest first", async ({ request }) => {
+ const res = await request.get(`${REPORT}feed.xml`);
+ expect(res.status()).toBe(200);
+ expect(res.headers()["content-type"]).toMatch(/^application\/rss\+xml\b/);
+ expect(res.headers()["access-control-allow-origin"]).toBe("*");
+ const xml = await res.text();
+ expect(xml).toMatch(/^<\?xml version="1\.0" encoding="UTF-8"\?>\n<rss version="2\.0"/);
+ expect(xml).toContain(`<atom:link href="${RSS}" rel="self" type="application/rss+xml"/>`);
+ const links = [...xml.matchAll(/<guid isPermaLink="true">([^<]+)<\/guid>/g)].map((m) => m[1]);
+ expect(links).toEqual([`${SITE}${REPORT}#update-mail`, `${SITE}${REPORT}#update-opening`]);
+ expect(xml).toContain("<pubDate>Sun, 04 Oct 2026 00:00:00 GMT</pubDate>");
+ // A citation's marker links its reference on the page, absolutely.
+ expect(xml).toContain(`<a href="${SITE}${REPORT}#c-c01">[1]`);
+});
+
+test("feed.json: JSON Feed 1.1, served as application/feed+json, the same items", async ({ request }) => {
+ const res = await request.get(`${REPORT}feed.json`);
+ expect(res.status()).toBe(200);
+ expect(res.headers()["content-type"]).toMatch(/^application\/feed\+json\b/);
+ expect(res.headers()["access-control-allow-origin"]).toBe("*");
+ const feed = (await res.json()) as {
+ version: string;
+ home_page_url: string;
+ feed_url: string;
+ items: { id: string; title: string; date_published: string; date_modified?: string }[];
+ };
+ expect(feed.version).toBe("https://jsonfeed.org/version/1.1");
+ expect(feed.home_page_url).toBe(`${SITE}${REPORT}`);
+ expect(feed.feed_url).toBe(JSON_FEED);
+ expect(feed.items.map((i) => [i.id, i.title, i.date_published, i.date_modified])).toEqual([
+ [`${SITE}${REPORT}#update-mail`, "A reader asked about another year", "2026-10-04T00:00:00.000Z", undefined],
+ [`${SITE}${REPORT}#update-opening`, "The opening date, said on air", "2026-10-02T00:00:00.000Z", "2026-10-04T00:00:00.000Z"],
+ ]);
+});
+
+test("the timeline in the other shapes: a slide per entry, and an entry's link opens its slide", async ({ page }) => {
+ await page.goto(`${REPORT}?rv=slides#update-opening`);
+ await expect(page).toHaveURL(`${REPORT}?rv=slides#s-4`);
+ const slide = page.locator('[data-report-slides] [data-slide="entry:update-opening"]');
+ await expect(slide).toBeVisible();
+ await expect(slide.locator("time")).toHaveText("2026-10-02");
+ await expect(slide.locator("h2")).toHaveText("The opening date, said on air");
+});
+
+test("a phone: the Timeline reads in the column; nothing scrolls sideways", async ({ page }) => {
+ await page.setViewportSize({ width: 375, height: 800 });
+ await page.goto(`${REPORT}#update-mail`);
+ const entry = (await page.locator("#update-mail").boundingBox())!;
+ expect(entry.x).toBeGreaterThanOrEqual(0);
+ expect(entry.x + entry.width).toBeLessThanOrEqual(375);
+ expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(375);
+});
diff --git a/export/fixtures/report-site/fixture.ts b/export/fixtures/report-site/fixture.ts
@@ -22,6 +22,7 @@ import { buildCitedIn } from "yt-dlp-transcript-common/lib/report/citedIn";
import { parseReport } from "yt-dlp-transcript-common/lib/report/validate";
import type { Report } from "yt-dlp-transcript-common/lib/report/schema";
import { reportHistoryPagePath } from "yt-dlp-transcript-common/lib/report/revisions";
+import { reportFeedUrls } from "yt-dlp-transcript-common/lib/report/feeds";
import { momentKeyOf, parseMomentKey, type SpanMoment } from "yt-dlp-transcript-common/lib/citations/moments";
import {
MOMENT_INDEX_FORMAT,
@@ -48,6 +49,9 @@ import {
export const FIXTURE_DIR = path.dirname(fileURLToPath(import.meta.url));
export const FIXTURE_PUBLIC_DIR = path.join(FIXTURE_DIR, "public");
export const FIXTURE_REPORT_ID = "demo-factcheck";
+// The fixture site's public URL (e2e-report/fixtures/sites/reportsite/site.json
+// `siteUrl`): the report's timeline feeds are named by it, as compose names them.
+export const FIXTURE_SITE_URL = "https://reports.example.org";
// The report's revisions, oldest first: the file under source/<id>/ and the
// commit's date.
@@ -125,6 +129,9 @@ export function buildFixtureReportView(report = readFixtureReport()): ReportPage
json: reportCitationsDownloadPath(report.id, "json"),
csv: reportCitationsDownloadPath(report.id, "csv"),
},
+ // Its timeline's feeds, as compose names them on a site with a public URL
+ // (contract.ts writes them).
+ feeds: reportFeedUrls(FIXTURE_SITE_URL, report.id),
// The newest revision, as compose names it (contract.ts commits both).
history: {
revision: FIXTURE_REVISIONS.length,
diff --git a/export/fixtures/report-site/public/m/demo-channel/abc123/3126.00-3151.00/moment.json b/export/fixtures/report-site/public/m/demo-channel/abc123/3126.00-3151.00/moment.json
@@ -77,6 +77,28 @@
},
{
"reportId": "demo-factcheck",
+ "sectionId": null,
+ "claimId": null,
+ "entryId": "update-mail",
+ "citationId": "c01",
+ "href": "/reports/demo-factcheck/#update-mail",
+ "reportTitle": "Demo Checks: Checking an example article",
+ "entryTitle": "A reader asked about another year",
+ "number": 1
+ },
+ {
+ "reportId": "demo-factcheck",
+ "sectionId": null,
+ "claimId": null,
+ "entryId": "update-opening",
+ "citationId": "c01",
+ "href": "/reports/demo-factcheck/#update-opening",
+ "reportTitle": "Demo Checks: Checking an example article",
+ "entryTitle": "The opening date, said on air",
+ "number": 1
+ },
+ {
+ "reportId": "demo-factcheck",
"sectionId": "bridge",
"claimId": "claim-1",
"citationId": "c01",
diff --git a/export/fixtures/report-site/public/reports/demo-factcheck/page.json b/export/fixtures/report-site/public/reports/demo-factcheck/page.json
@@ -157,6 +157,21 @@
"shot": "/media/posts/demo-social/1234567890/shot.png"
}
},
+ "entries": [
+ {
+ "id": "update-mail",
+ "date": "2026-10-04",
+ "title": "A reader asked about another year",
+ "body": "No other year turns up: every time he dates the opening it is the year he said [on stream](cite:c01)."
+ },
+ {
+ "id": "update-opening",
+ "date": "2026-10-02",
+ "updated": "2026-10-04",
+ "title": "The opening date, said on air",
+ "body": "The host gave the year of the opening [on stream](cite:c01), the year the city's record names."
+ }
+ ],
"sections": [
{
"id": "bridge",
@@ -252,6 +267,10 @@
"json": "/reports/demo-factcheck/citations.json",
"csv": "/reports/demo-factcheck/citations.csv"
},
+ "feeds": {
+ "rss": "https://reports.example.org/reports/demo-factcheck/feed.xml",
+ "json": "https://reports.example.org/reports/demo-factcheck/feed.json"
+ },
"history": {
"revision": 2,
"date": "2026-10-04T12:00:00Z",
diff --git a/export/fixtures/report-site/source/demo-factcheck/report.json b/export/fixtures/report-site/source/demo-factcheck/report.json
@@ -85,6 +85,21 @@
"quote": "Not cited anywhere."
}
},
+ "entries": [
+ {
+ "id": "update-opening",
+ "date": "2026-10-02",
+ "updated": "2026-10-04",
+ "title": "The opening date, said on air",
+ "body": "The host gave the year of the opening [on stream](cite:c01), the year the city's record names."
+ },
+ {
+ "id": "update-mail",
+ "date": "2026-10-04",
+ "title": "A reader asked about another year",
+ "body": "No other year turns up: every time he dates the opening it is the year he said [on stream](cite:c01)."
+ }
+ ],
"sections": [
{
"id": "bridge",
diff --git a/export/scripts/serve-out.mjs b/export/scripts/serve-out.mjs
@@ -11,6 +11,12 @@
//
// Byte ranges are honoured (a media element asks for them).
//
+// The directory's `_headers` (compose writes it: common/lib/archive/headers.ts)
+// is applied as Pages applies it — a rule's path a literal, a trailing `*`
+// splat or `:name` placeholders, each of its headers set on what it matches,
+// a `Content-Type` over the extension's — so what a spec sees (CORS, a feed's
+// media type) is what a deployed site sends. Read once, at start.
+//
// node scripts/serve-out.mjs <dir> <port>
//
// Every interface, as `serve` did; `HOST=127.0.0.1` keeps a private site on this machine.
@@ -57,6 +63,45 @@ const TYPES = {
".wasm": "application/wasm",
};
+// `_headers` as [{ re, headers: [[name, value]] }], in file order.
+function readHeaderRules(root) {
+ let text;
+ try {
+ text = fs.readFileSync(path.join(root, "_headers"), "utf8");
+ } catch {
+ return [];
+ }
+ const rules = [];
+ for (const line of text.split("\n")) {
+ if (!line.trim() || line.trimStart().startsWith("#")) continue;
+ if (/^\s/.test(line)) {
+ const at = line.indexOf(":");
+ if (at > 0 && rules.length > 0) rules[rules.length - 1].headers.push([line.slice(0, at).trim(), line.slice(at + 1).trim()]);
+ continue;
+ }
+ const pattern = line
+ .trim()
+ .replace(/[.+?^${}()|[\]\\]/g, "\\$&")
+ .replace(/\*/g, ".*")
+ .replace(/:[A-Za-z_]\w*/g, "[^/]+");
+ rules.push({ re: new RegExp(`^${pattern}$`), headers: [] });
+ }
+ return rules;
+}
+const HEADER_RULES = readHeaderRules(ROOT);
+
+function ruleHeaders(pathname) {
+ const out = {};
+ for (const rule of HEADER_RULES) {
+ if (!rule.re.test(pathname)) continue;
+ for (const [name, value] of rule.headers) {
+ const key = Object.keys(out).find((k) => k.toLowerCase() === name.toLowerCase()) ?? name;
+ out[key] = value;
+ }
+ }
+ return out;
+}
+
function statOrNull(p) {
try {
return fs.statSync(p);
@@ -65,12 +110,19 @@ function statOrNull(p) {
}
}
-function sendFile(req, res, file, status = 200) {
+function sendFile(req, res, file, status = 200, pathname) {
const size = fs.statSync(file).size;
+ const fromRules = status === 200 && pathname ? ruleHeaders(pathname) : {};
+ const type = Object.keys(fromRules).find((k) => k.toLowerCase() === "content-type");
const headers = {
"Content-Type": TYPES[path.extname(file).toLowerCase()] ?? "application/octet-stream",
"Accept-Ranges": "bytes",
+ ...fromRules,
};
+ if (type && type !== "Content-Type") {
+ headers["Content-Type"] = fromRules[type];
+ delete headers[type];
+ }
const range = status === 200 ? /^bytes=(\d*)-(\d*)$/.exec(req.headers.range ?? "") : null;
if (range && (range[1] || range[2])) {
let start = range[1] ? Number(range[1]) : size - Number(range[2]);
@@ -108,11 +160,11 @@ http
return void res.writeHead(308, { Location: `${url.pathname}/${url.search}` }).end();
}
const index = path.join(file, "index.html");
- if (statOrNull(index)?.isFile()) return sendFile(req, res, index);
+ if (statOrNull(index)?.isFile()) return sendFile(req, res, index, 200, pathname);
} else if (st?.isFile()) {
- return sendFile(req, res, file);
+ return sendFile(req, res, file, 200, pathname);
} else if (!pathname.endsWith("/") && statOrNull(`${file}.html`)?.isFile()) {
- return sendFile(req, res, `${file}.html`);
+ return sendFile(req, res, `${file}.html`, 200, pathname);
}
const notFound = path.join(ROOT, "404.html");
if (statOrNull(notFound)?.isFile()) return sendFile(req, res, notFound, 404);
diff --git a/mcp/src/reports.test.ts b/mcp/src/reports.test.ts
@@ -14,7 +14,7 @@ import {
import { CONTRACT } from "yt-dlp-transcript-common/lib/archive/contract";
import { LocalSource, type ShardSource } from "./source";
import { createServer } from "./server";
-import { renderReportIndex, reportsLine } from "./reports";
+import { renderReportIndex, renderReportPage, reportsLine } from "./reports";
// list_reports / get_report and the "cited-only site" line, over a composed
// public dir on disk read by the real LocalSource.
@@ -222,3 +222,33 @@ test("a source with no reports of its own (a hub) points at its members", () =>
assert.match(out, /reports are per site/);
assert.equal(reportsLine({ supported: false, scope: "full", reports: [] }), null);
});
+
+test("get_report's text: the timeline newest first, dated, before the sections; `section` may name an entry", () => {
+ const view = buildReportPageView(
+ {
+ ...REPORT,
+ entries: [
+ { id: "e-old", date: "2026-10-02", title: "The first update", body: "It began [here](cite:v1)." },
+ { id: "e-new", date: "2026-10-05T09:30:00Z", updated: "2026-10-06", title: "The second update", body: "Then more." },
+ ],
+ },
+ { record: (c) => ({ channel: c.channel, id: c.id, title: `Record ${c.id}` }) },
+ );
+ const text = renderReportPage(view, ORIGIN);
+ const at = (s: string) => text.indexOf(s);
+ assert.ok(at("## Timeline (newest first)") > 0);
+ assert.ok(at("### 2026-10-05T09:30:00Z (updated 2026-10-06) — The second update (#e-new)") > at("## Timeline"));
+ assert.ok(at("### 2026-10-02 — The first update (#e-old)") > at("(#e-new)"));
+ assert.ok(at("## Section one (#one)") > at("(#e-old)"));
+ // An entry's citations, as a section body's.
+ assert.match(text, /\(#e-old\)\nIt began \[here\]\(cite:v1\)\.\n\[1\] video/);
+ // The header's update is the newest entry's.
+ assert.match(text, /updated 2026-10-06/);
+ assert.match(text, /\(sections: one, two; timeline entries: e-new, e-old — pass section:"<id>" for one\)$/);
+
+ const one = renderReportPage(view, ORIGIN, "e-old");
+ assert.match(one, /### 2026-10-02 — The first update \(#e-old\)/);
+ assert.doesNotMatch(one, /e-new|Section one/);
+ const section = renderReportPage(view, ORIGIN, "one");
+ assert.doesNotMatch(section, /Timeline/);
+});
diff --git a/mcp/src/reports.ts b/mcp/src/reports.ts
@@ -21,6 +21,7 @@ import {
verdictTally,
type CitationView,
type ClaimView,
+ type EntryView,
type ReportIndexEntry,
type ReportPageView,
type SectionView,
@@ -214,6 +215,20 @@ function renderSection(view: ReportPageView, s: SectionView, origin: string | nu
return out.join("\n");
}
+// A timeline entry: its date, title and anchor, its body, and the citations
+// it cites.
+function renderEntry(view: ReportPageView, e: EntryView, origin: string | null): string {
+ const out = [`### ${e.date}${e.updated ? ` (updated ${e.updated})` : ""} — ${e.title} (#${e.id})`];
+ out.push(e.body.trim());
+ for (const id of [...new Set(extractCiteRefs(e.body).map((r) => r.id))]) {
+ const c = view.citations[id];
+ if (c) out.push(...citationLines(c, origin));
+ }
+ return out.join("\n");
+}
+
+// One report as text. `sectionId` narrows it to one section — or one
+// timeline entry, by its id.
export function renderReportPage(
view: ReportPageView,
origin: string | null,
@@ -241,10 +256,19 @@ export function renderReportPage(
}
if (view.summary && !sectionId) head.push("", view.summary.trim());
+ // The timeline, newest first, before the sections (as the page has it).
+ const allEntries = view.entries ?? [];
+ const entries = sectionId ? allEntries.filter((e) => e.id === sectionId) : allEntries;
+ const body: string[] = [];
+ if (entries.length > 0) {
+ body.push([`## Timeline (newest first)`, ...entries.map((e) => renderEntry(view, e, origin))].join("\n\n"));
+ }
const sections = sectionId ? view.sections.filter((s) => s.id === sectionId) : view.sections;
- const body = sections.map((s) => renderSection(view, s, origin));
+ body.push(...sections.map((s) => renderSection(view, s, origin)));
const outline = sectionId
? ""
- : `\n\n(sections: ${view.sections.map((s) => s.id).join(", ")} — pass section:"<id>" for one)`;
+ : `\n\n(sections: ${view.sections.map((s) => s.id).join(", ")}` +
+ (allEntries.length > 0 ? `; timeline entries: ${allEntries.map((e) => e.id).join(", ")}` : "") +
+ ` — pass section:"<id>" for one)`;
return `${head.join("\n")}\n\n${body.join("\n\n")}${outline}`;
}
diff --git a/mcp/src/server.ts b/mcp/src/server.ts
@@ -387,7 +387,8 @@ export const TOOLS: Tool[] = [
{
name: "get_report",
description:
- "Read one published report: title, kind, verdict tally, then each " +
+ "Read one published report: title, kind, verdict tally, its timeline " +
+ "(dated entries, newest first) when it keeps one, then each " +
"section's claims (verdict, findings) with every citation's verbatim " +
"quote, original URL (the platform at the cited second, the post, the " +
"document) and the site's moment page. Ids from list_reports.",
@@ -398,7 +399,7 @@ export const TOOLS: Tool[] = [
report: { type: "string", description: "The report id." },
section: {
type: "string",
- description: "Optional: one section's id, to read just that section.",
+ description: "Optional: one section's (or timeline entry's) id, to read just that part.",
},
},
required: ["report"],
@@ -1495,9 +1496,11 @@ async function handleGetReport(
);
}
const section = typeof args.section === "string" && args.section.trim() ? args.section.trim() : undefined;
- if (section && !view.sections.some((s) => s.id === section)) {
+ if (section && !view.sections.some((s) => s.id === section) && !(view.entries ?? []).some((e) => e.id === section)) {
+ const entries = (view.entries ?? []).map((e) => e.id);
return errorText(
- `report ${id} has no section ${section} — its sections: ${view.sections.map((s) => s.id).join(", ")}`,
+ `report ${id} has no section ${section} — its sections: ${view.sections.map((s) => s.id).join(", ")}` +
+ (entries.length > 0 ? `; its timeline entries: ${entries.join(", ")}` : ""),
);
}
return text(renderReportPage(view, await reportOrigin(source), section));