Archilyzer · Source

archilyzer

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

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:
MREPORT.md | 27+++++++++++++++++++++------
Mcommon/components/report/slides/ReportOverview.tsx | 5+++++
Mcommon/components/report/slides/ReportSlides.tsx | 2++
Mcommon/components/report/slides/Slide.tsx | 23+++++++++++++++++++++++
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++++++++++++++++
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++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
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,