commit 43cb0d43b242b693741e0ec1594e4050064088bf
parent a8a5806f075b23620d1d8568390b120f47f4fb4e
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 9 Oct 2026 17:53:35 -0400
report: a timeline — dated entries, newest first, in the view, the slides and the exports
report.json gains `entries` ({id, date, title, body, updated?}): ids share the
section/claim anchor namespace and refuse the page's own anchors; dates are
report dates, an entry's `updated` not before its `date`; every cite: link in
a body resolves. lib/report/entries.ts is the one order (newest first, the
same instant in file order) the numbering, the view, the slides and the
exports read. The view's `updated` counts the newest entry (the index card
with it). One slide per entry after In brief. report.html / report.md carry a
Timeline before the sections. The change summary names entries added,
removed and edited. REPORT.md regenerated.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
18 files changed, 676 insertions(+), 57 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/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/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/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,