Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

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:
MREPORT.md | 27+++++++++++++++++++++------
Mcommon/bin/compose-site.ts | 4++--
Mcommon/components/report/slides/ReportOverview.tsx | 5+++++
Mcommon/components/report/slides/ReportSlides.tsx | 2++
Mcommon/components/report/slides/Slide.tsx | 23+++++++++++++++++++++++
Mcommon/lib/archive/headers.test.ts | 30++++++++++++++++++++++++++++++
Mcommon/lib/archive/headers.ts | 28++++++++++++++++++++++++++--
Mcommon/lib/builtExport.ts | 5++++-
Mcommon/lib/report/citedIn.ts | 11+++++++----
Mcommon/lib/report/docs.ts | 33++++++++++++++++++++++++++++-----
Acommon/lib/report/entries.ts | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/report/exportHtml.ts | 58+++++++++++++++++++++++++++++++++++++++++++++++-----------
Mcommon/lib/report/exportMarkdown.ts | 19++++++++++++++++---
Mcommon/lib/report/exportSlides.test.ts | 10++++++++++
Mcommon/lib/report/exportSlidesHtml.ts | 16++++++++++++++++
Acommon/lib/report/feeds.test.ts | 133+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/report/feeds.ts | 194+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/report/revisions.ts | 25++++++++++++++++++++++---
Mcommon/lib/report/schema.ts | 33+++++++++++++++++++++++++++++----
Mcommon/lib/report/slides.ts | 53++++++++++++++++++++++++++++++++++++++++++++++-------
Acommon/lib/report/timeline.test.ts | 233+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/report/uses.ts | 26++++++++++++++++++++++----
Mcommon/lib/report/validate.ts | 31++++++++++++++++++++++++++-----
Mcommon/lib/report/views.ts | 71++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcommon/publish/composeReports.test.ts | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/publish/composeReports.ts | 21++++++++++++++++++++-
Mexport/CHANGELOG.md | 1+
Mexport/app/components/reports/MomentArticle.tsx | 10++++++++--
Mexport/app/components/reports/ReportArticle.tsx | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mexport/app/lib/reports.test.ts | 57++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mexport/app/lib/reports.ts | 21+++++++++++++++++++--
Mexport/e2e-report/contract.ts | 17+++++++++++++++--
Mexport/e2e-report/overview.spec.ts | 6+++---
Mexport/e2e-report/slides.spec.ts | 58++++++++++++++++++++++++++++++++--------------------------
Aexport/e2e-report/timeline.spec.ts | 113+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mexport/fixtures/report-site/fixture.ts | 7+++++++
Mexport/fixtures/report-site/public/m/demo-channel/abc123/3126.00-3151.00/moment.json | 22++++++++++++++++++++++
Mexport/fixtures/report-site/public/reports/demo-factcheck/page.json | 19+++++++++++++++++++
Mexport/fixtures/report-site/source/demo-factcheck/report.json | 15+++++++++++++++
Mexport/scripts/serve-out.mjs | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mmcp/src/reports.test.ts | 32+++++++++++++++++++++++++++++++-
Mmcp/src/reports.ts | 28++++++++++++++++++++++++++--
Mmcp/src/server.ts | 11+++++++----
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 &amp; b &lt; c &gt; d &quot;e&quot; &apos;f&apos;"); + 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 &amp; &lt;pieces&gt;<\/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 &amp; &quot;quoted&quot;<\/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], /&lt;a href=&quot;https:\/\/site\.example\/reports\/living\/#s1&quot;&gt;the section&lt;\/a&gt;/); + assert.match(items[0], /&lt;a href=&quot;https:\/\/site\.example\/reports\/&quot;&gt;the index&lt;\/a&gt;/); + assert.match(items[0], /one&lt;sup class=&quot;cite&quot;&gt;&lt;a href=&quot;https:\/\/site\.example\/reports\/living\/#c-v1&quot;&gt;\[1\]/); + // Raw HTML in an entry is text, escaped twice over in the feed. + assert.match(items[1], /&amp;lt;b&amp;gt;here&amp;lt;\/b&amp;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> &lt;b&gt;here&lt;/b&gt;.</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, "&amp;") + .replace(/</g, "&lt;") + .replace(/>/g, "&gt;") + .replace(/"/g, "&quot;") + .replace(/'/g, "&apos;"); +} + +// 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 &amp; &lt;update&gt;<\/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(`&lt;a href=&quot;${SITE}${REPORT}#c-c01&quot;&gt;[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));