commit b2d1ae21259ae52ebe3eb532708881175b936c47
parent 94e9c6d78429a46f19b69d9362a35245b060bcb8
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 5 Oct 2026 03:01:06 -0400
common: report and moment page views (lib/report/views.ts) — the JSON compose writes and the export pages read; buildReportPageView, paths, placeholders
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
2 files changed, 789 insertions(+), 0 deletions(-)
diff --git a/common/lib/report/views.test.ts b/common/lib/report/views.test.ts
@@ -0,0 +1,226 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { isRefId } from "../citations/schema";
+import { parseMomentKey } from "../citations/moments";
+import { buildCitedIn } from "./citedIn";
+import { isReportId, type Report } from "./schema";
+import { validateReport } from "./validate";
+import {
+ MOMENT_PLACEHOLDER_KEY,
+ REPORT_PLACEHOLDER_ID,
+ buildReportPageView,
+ citedInViews,
+ evidenceClipPath,
+ momentViewPath,
+ orderedCitations,
+ reportAssetPath,
+ reportIndexEntry,
+ sectionCitations,
+ spanLabel,
+ verdictTally,
+ type RecordView,
+} from "./views";
+
+const report: Report = {
+ format: "archilyzer-report",
+ version: 1,
+ id: "demo",
+ kind: "factcheck",
+ title: "A demo fact-check",
+ summary: "It starts [here](cite:v1).",
+ subject: { source: "s0" },
+ verdicts: { PARTLY: { label: "Half" } },
+ sources: {
+ s0: {
+ kind: "article",
+ title: "An article",
+ url: "https://example.org/a",
+ archives: [{ label: "archive", url: "https://archive.example.org/a" }],
+ saved: "sources/s0/page.html",
+ },
+ },
+ citations: {
+ v1: { kind: "video", channel: "demo-channel", id: "abc123", start: 3126, end: 3151.004, quote: "one" },
+ a1: { kind: "audio", channel: "demo-podcast", id: "ep-1", start: 10.5, end: 20, quote: "two", speaker: "Guest" },
+ p1: { kind: "post", channel: "demo-social", id: "123", quote: "three" },
+ s1: { kind: "source", source: "s0", quote: "four", image: "stills/s1.png" },
+ w1: { kind: "page", url: "https://example.org/p", title: "A page", quote: "five" },
+ never: { kind: "page", url: "https://example.org/never", quote: "never cited" },
+ },
+ sections: [
+ {
+ id: "one",
+ title: "One",
+ claims: [
+ {
+ id: "c1",
+ text: "Claim one.",
+ verdict: "CONTRADICTED",
+ sourceQuote: { citation: "s1" },
+ findings: "See [this](cite:w1) and `[code](cite:never)`.",
+ citations: ["v1", "a1"],
+ },
+ ],
+ },
+ {
+ id: "two",
+ title: "Two",
+ body: "A body citing [a post](cite:p1).",
+ claims: [{ id: "c2", text: "Claim two.", verdict: "PARTLY", citations: ["a1"] }],
+ },
+ ],
+};
+
+const record = (channel: string, id: string): RecordView => ({ channel, id, title: `${channel} ${id}` });
+
+const view = buildReportPageView(report, {
+ record: (c) => record(c.channel, c.id),
+ poster: (c) => (c.kind === "video" ? "/poster.png" : undefined),
+ post: () => ({ author: "@someone", text: "three, and more" }),
+ downloads: { json: "/reports/demo/citations.json" },
+});
+
+test("the fixture report here is sound", () => {
+ assert.deepEqual(validateReport(report), []);
+});
+
+test("numbers by first appearance in reading order; an uncited citation is left out", () => {
+ const numbers = Object.fromEntries(Object.values(view.citations).map((c) => [c.id, c.number]));
+ // summary v1; claim c1: source s1, findings w1, list v1 a1; section two body p1.
+ assert.deepEqual(numbers, { v1: 1, s1: 2, w1: 3, a1: 4, p1: 5 });
+ assert.equal(view.citations.never, undefined);
+ assert.deepEqual(
+ orderedCitations(view).map((c) => c.id),
+ ["v1", "s1", "w1", "a1", "p1"],
+ );
+});
+
+test("span and post citations link to their moment page, keyed at two decimals", () => {
+ const v1 = view.citations.v1;
+ assert.equal(v1.kind, "video");
+ if (v1.kind !== "video") return;
+ assert.equal(v1.moment, "demo-channel/abc123/3126.00-3151.00");
+ assert.equal(v1.href, "/m/demo-channel/abc123/3126.00-3151.00/");
+ assert.equal(v1.end, 3151);
+ assert.equal(v1.poster, "/poster.png");
+ assert.deepEqual(v1.record, record("demo-channel", "abc123"));
+ const p1 = view.citations.p1;
+ assert.equal(p1.kind, "post");
+ if (p1.kind !== "post") return;
+ assert.equal(p1.href, "/m/demo-social/123/");
+ assert.equal(p1.author, "@someone");
+ assert.equal(p1.text, "three, and more");
+ const a1 = view.citations.a1;
+ assert.equal(a1.kind === "audio" && a1.poster, undefined);
+});
+
+test("a source citation carries its document (never the saved copy) and its published still", () => {
+ const s1 = view.citations.s1;
+ assert.equal(s1.kind, "source");
+ if (s1.kind !== "source") return;
+ assert.equal(s1.image, "/reports/demo/stills/s1.png");
+ assert.equal(s1.href, "https://example.org/a");
+ assert.equal("saved" in s1.source, false);
+ assert.equal(view.subject?.title, "An article");
+ assert.equal(view.subject && "saved" in view.subject, false);
+ assert.ok(!JSON.stringify(view).includes("sources/s0/page.html"));
+});
+
+test("a page citation links out; the verdict overrides are laid over the defaults", () => {
+ const w1 = view.citations.w1;
+ assert.equal(w1.kind === "page" && w1.href, "https://example.org/p");
+ assert.equal(view.verdicts.PARTLY.label, "Half");
+ assert.equal(view.verdicts.PARTLY.color, "#e3b23c");
+ assert.equal(view.verdicts.CONTRADICTED.label, "Contradicted");
+});
+
+test("the view keeps the claim structure, with the source sentence as an id", () => {
+ assert.deepEqual(view.sections[0].claims[0], {
+ id: "c1",
+ text: "Claim one.",
+ verdict: "CONTRADICTED",
+ sourceQuote: "s1",
+ findings: "See [this](cite:w1) and `[code](cite:never)`.",
+ citations: ["v1", "a1"],
+ });
+ assert.deepEqual(view.sections[1].claims[0].citations, ["a1"]);
+ assert.deepEqual(view.downloads, { json: "/reports/demo/citations.json" });
+});
+
+test("a section's citations, in number order; code is not citing", () => {
+ assert.deepEqual(
+ sectionCitations(view, view.sections[0]).map((c) => c.id),
+ ["v1", "s1", "w1", "a1"],
+ );
+ assert.deepEqual(
+ sectionCitations(view, view.sections[1]).map((c) => c.id),
+ ["a1", "p1"],
+ );
+});
+
+test("the tally counts verdicts in the vocabulary's order; the index entry carries it", () => {
+ assert.deepEqual(verdictTally(view), [
+ { verdict: "PARTLY", count: 1 },
+ { verdict: "CONTRADICTED", count: 1 },
+ ]);
+ const entry = reportIndexEntry(view);
+ assert.equal(entry.href, "/reports/demo/");
+ assert.equal(entry.citationCount, 5);
+ assert.equal(entry.claimCount, 2);
+ assert.deepEqual(entry.tally, verdictTally(view));
+ assert.equal(reportIndexEntry({ ...view, kind: "sweep" }).tally, undefined);
+});
+
+test("cited-in entries resolve to the claim's anchor with titles and the citation's number", () => {
+ const index = buildCitedIn([report]);
+ const entries = citedInViews(index["demo-podcast/ep-1/10.50-20.00"], [view]);
+ assert.deepEqual(
+ entries.map((e) => [e.href, e.claimText, e.verdict, e.verdictStyle?.label, e.number]),
+ [
+ ["/reports/demo/#c1", "Claim one.", "CONTRADICTED", "Contradicted", 4],
+ ["/reports/demo/#c2", "Claim two.", "PARTLY", "Half", 4],
+ ],
+ );
+ const summary = citedInViews(index["demo-channel/abc123/3126.00-3151.00"], [view]);
+ assert.equal(summary[0].href, "/reports/demo/");
+ assert.equal(citedInViews(index["demo-social/123"], []).length, 0);
+});
+
+test("paths: the clip, the moment view and a report asset", () => {
+ assert.equal(
+ evidenceClipPath({ kind: "span", channel: "demo-channel", id: "-abc", start: 1, end: 2.5 }),
+ "/media/clips/demo-channel/-abc/1.00-2.50.mp4",
+ );
+ assert.equal(momentViewPath("demo-social/123"), "/m/demo-social/123/moment.json");
+ assert.equal(reportAssetPath("demo", "./stills/x.png"), "/reports/demo/stills/x.png");
+});
+
+test("the placeholder params can never be a report or a moment", () => {
+ assert.equal(isReportId(REPORT_PLACEHOLDER_ID), false);
+ assert.equal(parseMomentKey(MOMENT_PLACEHOLDER_KEY), null);
+ assert.equal(isRefId("x"), true);
+});
+
+test("a span reads as clock times", () => {
+ assert.equal(spanLabel(3126, 3151), "52:06–52:31");
+ assert.equal(spanLabel(3725.9, 3730), "1:02:05–1:02:10");
+});
+
+test("a citation with no moment key, or a source the report lacks, fails the build loudly", () => {
+ const bad: Report = {
+ ...report,
+ summary: "[x](cite:bad)",
+ citations: { bad: { kind: "video", channel: "../up", id: "abc", start: 1, end: 2, quote: "q" } },
+ sections: [],
+ };
+ assert.throws(() => buildReportPageView(bad, { record: (c) => record(c.channel, c.id) }), /no moment key/);
+ const noSource: Report = {
+ ...report,
+ summary: "[x](cite:s)",
+ sources: {},
+ subject: undefined,
+ citations: { s: { kind: "source", source: "gone", quote: "q" } },
+ sections: [],
+ };
+ assert.throws(() => buildReportPageView(noSource, { record: (c) => record(c.channel, c.id) }), /does not define/);
+});
diff --git a/common/lib/report/views.ts b/common/lib/report/views.ts
@@ -0,0 +1,563 @@
+// REPORT AND MOMENT PAGE VIEWS — the JSON the reports stage of compose writes
+// under the export app's public dir, and the export app's pages read. One
+// definition, so the writer and the reader cannot disagree:
+//
+// /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>/stills/… a source citation's still, as the report names it
+// /m/index.json MomentIndexView every moment page to build
+// /m/<momentKey>/moment.json MomentPageView one moment: clip, cues, record, "cited in"
+// /media/clips/<channel>/<id>/<start>-<end>.mp4 a span's evidence clip
+//
+// A VIEW IS RESOLVED, a document is not: a report names a citation by
+// `channel`/`id`; its view carries what a reader sees — the record's title,
+// channel title and date, the moment page's href, the per-report number, a
+// poster. The components (common/components/citations/) take views as props and
+// fetch nothing, so the export site, the editor's previews and umtool's report
+// preview render the same thing from the same data.
+//
+// A view carries ONLY what is cited: no `saved` source copy (never published),
+// no cue beyond the moment's bounded context, no record field a page does not
+// show.
+//
+// `buildReportPageView` is the one way a report becomes its view: the numbers
+// are lib/report/uses.ts's (first appearance, reading order), the hrefs are
+// lib/citations/moments.ts's, the verdict styles the shared vocabulary's with
+// the report's overrides laid over. The caller (compose) supplies what only the
+// corpus knows — each cited record's metadata, a poster, a post's text — via a
+// resolver. A moment page's view is assembled by compose from the cues and the
+// clip it prepares; its "cited in" entries come from `citedInViews`.
+//
+// Pure, no imports but types and pure helpers: the export site's pages and the
+// browser can use it.
+
+import { citationAnchor, extractCiteRefs } from "../citations/inline";
+import {
+ formatMomentSeconds,
+ momentKeyOf,
+ momentPath,
+ type SpanMoment,
+} from "../citations/moments";
+import type {
+ CitationKind,
+ CitationVerification,
+ PostCitation,
+ Source,
+ SourceArchive,
+ SpanCitation,
+} from "../citations/schema";
+import { formatTimestamp } from "../vtt";
+import type { CitedIn } from "./citedIn";
+import type { Report, ReportKind } from "./schema";
+import { reportCitationNumbers } from "./uses";
+import { resolveVerdicts, VERDICTS, type Verdict, type VerdictStyle } from "./verdicts";
+
+export const REPORT_VIEWS_VERSION = 1;
+
+export const REPORT_INDEX_FORMAT = "archilyzer-report-index";
+export const REPORT_PAGE_FORMAT = "archilyzer-report-page";
+export const MOMENT_INDEX_FORMAT = "archilyzer-moment-index";
+export const MOMENT_PAGE_FORMAT = "archilyzer-moment-page";
+
+// ─── Where each view lives (site-root URL paths; a file under the public dir) ───
+
+export const REPORTS_INDEX_PATH = "/reports/index.json";
+export const MOMENTS_INDEX_PATH = "/m/index.json";
+
+// THE PLACEHOLDER PARAM. A static export refuses a dynamic route whose
+// generateStaticParams is empty, so a site with no reports (or no moments)
+// builds ONE page under this param, which renders "no reports". It can never
+// be a report id (REPORT_ID_RE starts with a letter or digit) nor a moment key
+// (a key has two or three segments). The out/ audit expects exactly these.
+export const REPORT_PLACEHOLDER_ID = "_none";
+export const MOMENT_PLACEHOLDER_KEY = "_none";
+
+export function reportPagePath(reportId: string): string {
+ return `/reports/${reportId}/`;
+}
+
+export function reportViewPath(reportId: string): string {
+ return `/reports/${reportId}/page.json`;
+}
+
+export function reportCitationsDownloadPath(reportId: string, format: "json" | "csv"): string {
+ return `/reports/${reportId}/citations.${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.
+export function reportAssetPath(reportId: string, relPath: string): string {
+ return `/reports/${reportId}/${relPath.replace(/^\.\//, "")}`;
+}
+
+export function momentViewPath(key: string): string {
+ return `${momentPath(key)}moment.json`;
+}
+
+// A span's evidence clip, named by the moment (the pad widens the clip, not
+// the name).
+export function evidenceClipPath(m: SpanMoment): string {
+ return `/media/clips/${m.channel}/${m.id}/${formatMomentSeconds(m.start)}-${formatMomentSeconds(m.end)}.mp4`;
+}
+
+// ─── Views ───
+
+// A cited record, as a reader sees it.
+export type RecordView = {
+ channel: string;
+ channelTitle?: string;
+ id: string;
+ title?: string;
+ // The record's own date (`YYYY-MM-DD`).
+ date?: string;
+ platform?: string;
+ // The record on its own platform at the cited time (lib/momentUrl.ts
+ // platformMomentUrl), or the post's own URL.
+ originalUrl?: string;
+ // The record in this site's corpus (`/?v=<channel>/<id>&t=<s>`): a FULL site
+ // only — a cited site has no corpus to open.
+ corpusUrl?: string;
+};
+
+// A document a `source` citation quotes. Never its `saved` copy.
+export type SourceView = {
+ id: string;
+ kind: Source["kind"];
+ title: string;
+ url?: string;
+ publisher?: string;
+ author?: string;
+ date?: string;
+ // A line shown under the byline, e.g. which copy of the document was read.
+ note?: string;
+ archives: SourceArchive[];
+};
+
+type CitationViewCommon = {
+ id: string;
+ // The citation's number in its report (absent outside one).
+ number?: number;
+ quote: string;
+ speaker?: string;
+ date?: string;
+ label?: string;
+ note?: string;
+ verification?: CitationVerification;
+};
+
+export type SpanCitationView = CitationViewCommon & {
+ kind: "video" | "audio";
+ // The moment's key and page.
+ moment: string;
+ href: string;
+ start: number;
+ end: number;
+ record: RecordView;
+ // A still for the card: a site-root path or an http(s) URL.
+ poster?: string;
+};
+
+export type PostCitationView = CitationViewCommon & {
+ kind: "post";
+ moment: string;
+ href: string;
+ record: RecordView;
+ author?: string;
+ text?: string;
+ // The captured screenshot of the post: a site-root path.
+ shot?: string;
+ thread?: boolean;
+};
+
+export type SourceCitationView = CitationViewCommon & {
+ kind: "source";
+ source: SourceView;
+ // The still of the sentence, published (reportAssetPath).
+ image?: string;
+ // The document itself, when it has a URL.
+ href?: string;
+};
+
+export type PageCitationView = CitationViewCommon & {
+ kind: "page";
+ href: string;
+ title?: string;
+ archiveUrl?: string;
+};
+
+export type CitationView = SpanCitationView | PostCitationView | SourceCitationView | PageCitationView;
+
+export type ClaimView = {
+ id: string;
+ // A short headline shown above the claim (e.g. a quoted phrase).
+ title?: string;
+ // The claim, as the document under review states it.
+ text: string;
+ verdict?: Verdict;
+ // The id of the `source` citation holding the document's own sentence.
+ sourceQuote?: string;
+ findings?: string;
+ citations: string[];
+};
+
+export type SectionView = {
+ id: string;
+ title: string;
+ body?: string;
+ claims: ClaimView[];
+};
+
+export type ReportPageView = {
+ format: typeof REPORT_PAGE_FORMAT;
+ version: typeof REPORT_VIEWS_VERSION;
+ id: string;
+ kind: ReportKind;
+ title: string;
+ subtitle?: string;
+ summary?: string;
+ published?: string;
+ updated?: string;
+ subject?: SourceView;
+ // Every verdict's label and colour, the report's overrides applied.
+ verdicts: Record<Verdict, VerdictStyle>;
+ // Every citation the report cites, numbered; one it defines but never cites
+ // is left out.
+ citations: Record<string, CitationView>;
+ sections: SectionView[];
+ // The report's citations as files, when compose wrote them.
+ downloads?: { json?: string; csv?: string };
+};
+
+export type VerdictCount = { verdict: Verdict; count: number };
+
+export type ReportIndexEntry = {
+ id: string;
+ kind: ReportKind;
+ title: string;
+ subtitle?: string;
+ published?: string;
+ updated?: string;
+ href: string;
+ citationCount: number;
+ claimCount: number;
+ // A fact-check's tally (verdictTally); absent on a sweep.
+ tally?: VerdictCount[];
+ verdicts?: Record<Verdict, VerdictStyle>;
+};
+
+export type ReportIndexView = {
+ format: typeof REPORT_INDEX_FORMAT;
+ version: typeof REPORT_VIEWS_VERSION;
+ reports: ReportIndexEntry[];
+};
+
+// One line of the transcript around a span.
+export type CueLineView = {
+ start: number;
+ end?: number;
+ text: string;
+ // Whether the line overlaps the cited span.
+ inSpan: boolean;
+};
+
+// One place that cites a moment, ready to link.
+export type CitedInView = CitedIn & {
+ href: string;
+ reportTitle: string;
+ sectionTitle?: string;
+ claimTitle?: string;
+ claimText?: string;
+ verdict?: Verdict;
+ // The verdict's label and colour in its report (overrides applied).
+ verdictStyle?: VerdictStyle;
+ number?: number;
+};
+
+export type MomentClipView = {
+ src: string;
+ // The record seconds the clip covers: the span with its pad.
+ start: number;
+ end: number;
+ poster?: string;
+};
+
+export type MomentPostView = {
+ author?: string;
+ text: string;
+ shot?: string;
+ media?: { src: string; kind: "image" | "video" }[];
+};
+
+export type MomentPageView = {
+ format: typeof MOMENT_PAGE_FORMAT;
+ version: typeof REPORT_VIEWS_VERSION;
+ key: string;
+ kind: Extract<CitationKind, "video" | "audio" | "post">;
+ record: RecordView;
+ // The quote, speaker and check of the first citation of the moment.
+ quote: string;
+ speaker?: string;
+ date?: string;
+ verification?: CitationVerification;
+ // A span: the cited seconds, the clip, the bounded cue context.
+ start?: number;
+ end?: number;
+ clip?: MomentClipView;
+ cues?: CueLineView[];
+ // A post.
+ post?: MomentPostView;
+ citedIn: CitedInView[];
+};
+
+export type MomentIndexView = {
+ format: typeof MOMENT_INDEX_FORMAT;
+ version: typeof REPORT_VIEWS_VERSION;
+ moments: string[];
+};
+
+// ─── Building a report's view ───
+
+export type ReportViewResolver = {
+ // The cited record's metadata. Compose knows the corpus; a citation of a
+ // record it cannot resolve is a compose failure, not a missing field here.
+ record: (c: SpanCitation | PostCitation) => RecordView;
+ poster?: (c: SpanCitation) => string | undefined;
+ post?: (c: PostCitation) => { author?: string; text?: string; shot?: string } | undefined;
+ downloads?: { json?: string; csv?: string };
+};
+
+function sourceView(id: string, s: Source): SourceView {
+ return {
+ id,
+ kind: s.kind,
+ title: s.title,
+ ...(s.url ? { url: s.url } : {}),
+ ...(s.publisher ? { publisher: s.publisher } : {}),
+ ...(s.author ? { author: s.author } : {}),
+ ...(s.date ? { date: s.date } : {}),
+ ...((s as { note?: string }).note ? { note: (s as { note?: string }).note } : {}),
+ archives: s.archives ?? [],
+ };
+}
+
+const defined = <T extends object>(o: T): T =>
+ Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined)) as T;
+
+// A report's page view. Throws on a citation whose moment cannot be keyed, or
+// a source citation naming a source the report does not define — both are
+// validation problems (lib/report/validate.ts) a published report cannot have.
+export function buildReportPageView(report: Report, resolve: ReportViewResolver): ReportPageView {
+ const numbers = reportCitationNumbers(report);
+ const all = report.citations ?? {};
+ const sources = report.sources ?? {};
+ const citations: Record<string, CitationView> = {};
+ for (const [id, number] of numbers) {
+ const c = all[id];
+ const common = defined({
+ id,
+ number,
+ quote: c.quote,
+ speaker: c.speaker,
+ date: c.date,
+ label: c.label,
+ note: c.note,
+ verification: c.verification,
+ });
+ switch (c.kind) {
+ case "video":
+ case "audio": {
+ const key = momentKeyOf(c);
+ if (!key) throw new Error(`report ${report.id}: citation ${id} has no moment key`);
+ citations[id] = defined({
+ kind: c.kind,
+ ...common,
+ moment: key,
+ href: momentPath(key),
+ start: Number(formatMomentSeconds(c.start)),
+ end: Number(formatMomentSeconds(c.end)),
+ record: resolve.record(c),
+ poster: resolve.poster?.(c),
+ });
+ break;
+ }
+ case "post": {
+ const key = momentKeyOf(c);
+ if (!key) throw new Error(`report ${report.id}: citation ${id} has no moment key`);
+ const post = resolve.post?.(c);
+ citations[id] = defined({
+ kind: "post" as const,
+ ...common,
+ moment: key,
+ href: momentPath(key),
+ record: resolve.record(c),
+ author: post?.author,
+ text: post?.text,
+ shot: post?.shot,
+ thread: c.thread,
+ });
+ break;
+ }
+ case "source": {
+ const s = sources[c.source];
+ if (!s) throw new Error(`report ${report.id}: citation ${id} names source ${c.source}, which it does not define`);
+ citations[id] = defined({
+ kind: "source" as const,
+ ...common,
+ source: sourceView(c.source, s),
+ image: c.image ? reportAssetPath(report.id, c.image) : undefined,
+ href: s.url,
+ });
+ break;
+ }
+ case "page":
+ citations[id] = defined({
+ kind: "page" as const,
+ ...common,
+ href: c.url,
+ title: c.title,
+ archiveUrl: c.archiveUrl,
+ });
+ break;
+ }
+ }
+ const subject = report.subject && sources[report.subject.source];
+ return defined({
+ format: REPORT_PAGE_FORMAT,
+ version: REPORT_VIEWS_VERSION,
+ id: report.id,
+ kind: report.kind,
+ title: report.title,
+ subtitle: report.subtitle,
+ summary: report.summary,
+ published: report.published,
+ updated: report.updated,
+ subject: subject ? sourceView(report.subject!.source, subject) : undefined,
+ verdicts: resolveVerdicts(report.verdicts),
+ citations,
+ sections: report.sections.map((s) =>
+ defined({
+ id: s.id,
+ title: s.title,
+ body: s.body,
+ claims: (s.claims ?? []).map((c) =>
+ defined({
+ id: c.id,
+ title: (c as { title?: string }).title,
+ text: c.text,
+ verdict: c.verdict,
+ sourceQuote: c.sourceQuote?.citation,
+ findings: c.findings,
+ citations: c.citations ?? [],
+ }),
+ ),
+ }),
+ ),
+ downloads: resolve.downloads,
+ });
+}
+
+// ─── Reading a view ───
+
+// The report's citations in number order.
+export function orderedCitations(view: Pick<ReportPageView, "citations">): CitationView[] {
+ return Object.values(view.citations)
+ .filter((c) => c.number !== undefined)
+ .sort((a, b) => a.number! - b.number!);
+}
+
+// The citations a section cites (its body, then each claim's source sentence,
+// findings and list), in number order — a per-section reference list.
+export function sectionCitations(view: Pick<ReportPageView, "citations">, section: SectionView): CitationView[] {
+ const ids = new Set<string>();
+ for (const r of extractCiteRefs(section.body)) ids.add(r.id);
+ for (const claim of section.claims) {
+ if (claim.sourceQuote) ids.add(claim.sourceQuote);
+ for (const r of extractCiteRefs(claim.findings)) ids.add(r.id);
+ for (const id of claim.citations) ids.add(id);
+ }
+ return orderedCitations(view).filter((c) => ids.has(c.id));
+}
+
+// How many claims carry each verdict, in the vocabulary's order; verdicts no
+// claim carries are left out.
+export function verdictTally(view: Pick<ReportPageView, "sections">): VerdictCount[] {
+ const counts = new Map<Verdict, number>();
+ for (const s of view.sections) {
+ for (const c of s.claims) if (c.verdict) counts.set(c.verdict, (counts.get(c.verdict) ?? 0) + 1);
+ }
+ return VERDICTS.filter((v) => counts.has(v)).map((verdict) => ({ verdict, count: counts.get(verdict)! }));
+}
+
+// The report's entry in the index.
+export function reportIndexEntry(view: ReportPageView): ReportIndexEntry {
+ const tally = view.kind === "factcheck" ? verdictTally(view) : undefined;
+ return defined({
+ id: view.id,
+ kind: view.kind,
+ title: view.title,
+ subtitle: view.subtitle,
+ published: view.published,
+ updated: view.updated,
+ href: reportPagePath(view.id),
+ citationCount: Object.keys(view.citations).length,
+ claimCount: view.sections.reduce((n, s) => n + s.claims.length, 0),
+ tally,
+ verdicts: tally ? view.verdicts : undefined,
+ });
+}
+
+// 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;
+ return `${reportPagePath(entry.reportId)}${anchor ? `#${anchor}` : ""}`;
+}
+
+// A moment's "cited in" entries (lib/report/citedIn.ts buildCitedIn), resolved
+// against the reports' views for their titles; an entry whose report has no
+// view is dropped.
+export function citedInViews(entries: readonly CitedIn[], reports: readonly ReportPageView[]): CitedInView[] {
+ const byId = new Map(reports.map((r) => [r.id, r]));
+ const out: CitedInView[] = [];
+ for (const e of entries) {
+ const report = byId.get(e.reportId);
+ 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;
+ out.push(
+ defined({
+ ...e,
+ href: citedInHref(e),
+ reportTitle: report.title,
+ sectionTitle: section?.title,
+ claimTitle: claim?.title,
+ claimText: claim?.text,
+ verdict: claim?.verdict,
+ verdictStyle: claim?.verdict ? report.verdicts[claim.verdict] : undefined,
+ number: report.citations[e.citationId]?.number,
+ }),
+ );
+ }
+ return out;
+}
+
+// ─── Labels ───
+
+// A span as a reader reads it: `52:06–52:31` (lib/vtt.ts formatTimestamp).
+export function spanLabel(start: number, end: number): string {
+ return `${formatTimestamp(start)}–${formatTimestamp(end)}`;
+}
+
+export const CITATION_KIND_LABELS: Record<CitationKind, string> = {
+ video: "Video",
+ audio: "Audio",
+ post: "Post",
+ source: "Source",
+ page: "Web page",
+};
+
+// The anchor of a citation's reference entry (re-exported so a page needs one
+// import for the view and its anchors).
+export { citationAnchor };