commit 721530d506ba681198278d641dc774b0ded3ce81
parent a3f629619c71a4f35e9c84e89b2a66e1e41c9ec5
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 5 Oct 2026 03:16:50 -0400
Merge report-r4-pages (report + moment pages, shared citation components: inline cites with previews, kind-aware cards, reference lists, verification badges, verdict chips; the cited site's shell)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
41 files changed, 3535 insertions(+), 32 deletions(-)
diff --git a/common/components/Markdown.tsx b/common/components/Markdown.tsx
@@ -73,18 +73,22 @@ const OVERRIDES = {
// spacing is consistent regardless of content shape. `linkComponent` optionally
// overrides how `<a>` renders (e.g. to make in-page citation anchors scroll
// instead of opening a new tab); when omitted, links use the default external
-// styling above.
-export function Markdown({
+// styling above. `linkProps` are extra props every `<a>` override receives
+// beside the link's own (e.g. a citation map), so the link component can stay
+// one stable component instead of a closure made per render.
+export function Markdown<P extends object = object>({
children,
className,
linkComponent,
+ linkProps,
}: {
children: string;
className?: string;
- linkComponent?: ComponentType<AnchorHTMLAttributes<HTMLAnchorElement>>;
+ linkComponent?: ComponentType<AnchorHTMLAttributes<HTMLAnchorElement> & P>;
+ linkProps?: P;
}) {
const overrides = linkComponent
- ? { ...OVERRIDES, a: { component: linkComponent } }
+ ? { ...OVERRIDES, a: { component: linkComponent, ...(linkProps ? { props: linkProps } : {}) } }
: OVERRIDES;
return (
<div className={className}>
diff --git a/common/components/citations/CitationCard.tsx b/common/components/citations/CitationCard.tsx
@@ -0,0 +1,250 @@
+import { Archive, ExternalLink, FileText, Globe, MessageSquareQuote, Mic, Play, Video } from "lucide-react";
+import type { CitationKind } from "../../lib/citations/schema";
+import {
+ CITATION_KIND_LABELS,
+ spanLabel,
+ type CitationView,
+ type PageCitationView,
+ type PostCitationView,
+ type SourceCitationView,
+ type SpanCitationView,
+} from "../../lib/report/views";
+import { cn } from "../../lib/utils";
+import { VerificationBadge } from "./VerificationBadge";
+
+// ONE CITATION, as a reader sees it — kind-aware, from a resolved view
+// (lib/report/views.ts), fetching nothing:
+//
+// video / audio the record's title, channel, date, the span ("52:06–52:31"),
+// the speaker and quote, a poster when the view has one, and
+// a play affordance that opens the moment page (its clip)
+// post the author, date, quote (and the post's text when it says
+// more), the captured shot when the view has one
+// source the document's sentence: its still, the quote, and the
+// document's archive links in context
+// page the page's title and URL, its archived copy
+//
+// Three densities: `card` (a claim's evidence list), `preview` (InlineCite's
+// hover/tap card: the same facts, a smaller picture, the quote clamped) and
+// `reference` (a ReferenceList entry: no picture, the number is the list's).
+//
+// Pure/presentational: server-rendered or in a client tree alike.
+
+export type CitationCardVariant = "card" | "preview" | "reference";
+
+const KIND_ICON: Record<CitationKind, typeof Video> = {
+ video: Video,
+ audio: Mic,
+ post: MessageSquareQuote,
+ source: FileText,
+ page: Globe,
+};
+
+const linkClass =
+ "inline-flex items-center gap-1 text-brand underline decoration-brand/40 underline-offset-2 hover:decoration-brand focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm";
+
+function hostOf(url: string): string {
+ try {
+ return new URL(url).hostname.replace(/^www\./, "");
+ } catch {
+ return url;
+ }
+}
+
+function ExternalA({ href, children, title }: { href: string; children: React.ReactNode; title?: string }) {
+ return (
+ <a href={href} target="_blank" rel="noopener noreferrer" title={title} className={linkClass}>
+ {children}
+ <ExternalLink className="size-3 shrink-0" aria-hidden />
+ </a>
+ );
+}
+
+// The title line and its meta, per kind.
+function heading(c: CitationView): { title: string; meta: string[] } {
+ switch (c.kind) {
+ case "video":
+ case "audio":
+ return {
+ title: c.record.title ?? `${c.record.channel}/${c.record.id}`,
+ meta: [c.record.channelTitle ?? c.record.channel, c.date ?? c.record.date, spanLabel(c.start, c.end)].filter(
+ (x): x is string => !!x,
+ ),
+ };
+ case "post":
+ return {
+ title: c.author ?? c.record.channelTitle ?? c.record.channel,
+ meta: [c.author ? (c.record.channelTitle ?? c.record.channel) : undefined, c.date ?? c.record.date].filter(
+ (x): x is string => !!x,
+ ),
+ };
+ case "source":
+ return {
+ title: c.source.title,
+ meta: [c.source.publisher, c.source.author, c.date ?? c.source.date].filter((x): x is string => !!x),
+ };
+ case "page":
+ return { title: c.title ?? hostOf(c.href), meta: [hostOf(c.href), c.date].filter((x): x is string => !!x) };
+ }
+}
+
+function Picture({ c, variant }: { c: CitationView; variant: CitationCardVariant }) {
+ if (variant === "reference") return null;
+ const size = variant === "preview" ? "max-h-28" : "max-h-48";
+ if (c.kind === "video" || c.kind === "audio") {
+ if (!c.poster) return null;
+ return (
+ <a href={c.href} className="group relative block overflow-hidden rounded-md border border-border bg-muted">
+ <img src={c.poster} alt="" loading="lazy" className={cn("w-full object-cover", size)} />
+ <span className="absolute inset-0 flex items-center justify-center bg-black/10 transition-colors group-hover:bg-black/25">
+ <span className="flex size-10 items-center justify-center rounded-full bg-background/90 text-foreground shadow">
+ <Play className="size-4 translate-x-px" aria-hidden />
+ </span>
+ </span>
+ <span className="sr-only">Play {spanLabel(c.start, c.end)}</span>
+ </a>
+ );
+ }
+ if (c.kind === "post" && c.shot) {
+ return (
+ <img
+ src={c.shot}
+ alt={`Screenshot of the post${c.author ? ` by ${c.author}` : ""}`}
+ loading="lazy"
+ className={cn("w-full rounded-md border border-border object-contain object-left-top", size)}
+ />
+ );
+ }
+ if (c.kind === "source" && c.image) {
+ return (
+ <img
+ src={c.image}
+ alt={`The sentence as ${c.source.title} shows it: ${c.quote}`}
+ loading="lazy"
+ className={cn("w-full rounded-md border border-border bg-white object-contain object-left-top", size)}
+ />
+ );
+ }
+ return null;
+}
+
+function SpanLinks({ c }: { c: SpanCitationView }) {
+ return (
+ <>
+ <a href={c.href} className={linkClass}>
+ <Play className="size-3 shrink-0" aria-hidden />
+ Play {spanLabel(c.start, c.end)}
+ </a>
+ {c.record.originalUrl && <ExternalA href={c.record.originalUrl}>Original</ExternalA>}
+ {c.record.corpusUrl && (
+ <a href={c.record.corpusUrl} className={linkClass}>
+ Transcript
+ </a>
+ )}
+ </>
+ );
+}
+
+function PostLinks({ c }: { c: PostCitationView }) {
+ return (
+ <>
+ <a href={c.href} className={linkClass}>
+ Open post
+ </a>
+ {c.record.originalUrl && <ExternalA href={c.record.originalUrl}>Original</ExternalA>}
+ </>
+ );
+}
+
+function SourceLinks({ c }: { c: SourceCitationView }) {
+ return (
+ <>
+ {c.href && <ExternalA href={c.href}>{c.source.publisher ?? hostOf(c.href)}</ExternalA>}
+ {c.source.archives.map((a) => (
+ <ExternalA key={a.url} href={a.url} title={a.context}>
+ <Archive className="size-3 shrink-0" aria-hidden />
+ {a.label}
+ </ExternalA>
+ ))}
+ </>
+ );
+}
+
+function PageLinks({ c }: { c: PageCitationView }) {
+ return (
+ <>
+ <ExternalA href={c.href}>{hostOf(c.href)}</ExternalA>
+ {c.archiveUrl && (
+ <ExternalA href={c.archiveUrl}>
+ <Archive className="size-3 shrink-0" aria-hidden />
+ Archived copy
+ </ExternalA>
+ )}
+ </>
+ );
+}
+
+export function CitationCard({
+ citation: c,
+ variant = "card",
+ className,
+}: {
+ citation: CitationView;
+ variant?: CitationCardVariant;
+ className?: string;
+}) {
+ const Icon = KIND_ICON[c.kind];
+ const { title, meta } = heading(c);
+ const showNumber = variant !== "reference" && c.number !== undefined;
+ const postText = c.kind === "post" && c.text && c.text.trim() !== c.quote.trim() ? c.text : null;
+ return (
+ <div
+ data-citation={c.id}
+ data-citation-kind={c.kind}
+ className={cn(
+ "flex min-w-0 flex-col gap-2 text-sm",
+ variant === "card" && "rounded-lg border border-border bg-card p-3 sm:p-4",
+ variant === "preview" && "gap-1.5",
+ className,
+ )}
+ >
+ <div className="flex min-w-0 items-start gap-2">
+ {showNumber && (
+ <span className="mt-px shrink-0 rounded bg-brand-soft px-1.5 font-mono text-xs font-semibold leading-5 text-brand-strong">
+ {c.number}
+ </span>
+ )}
+ <div className="min-w-0 flex-1">
+ <div className="flex items-center gap-1.5 font-mono text-[10px] uppercase tracking-[0.14em] text-muted-foreground">
+ <Icon className="size-3 shrink-0" aria-hidden />
+ {CITATION_KIND_LABELS[c.kind]}
+ {c.label && <span className="truncate normal-case tracking-normal">· {c.label}</span>}
+ </div>
+ <div className="font-medium leading-snug text-foreground break-words">{title}</div>
+ {meta.length > 0 && <div className="font-mono text-xs text-muted-foreground">{meta.join(" · ")}</div>}
+ </div>
+ </div>
+ <Picture c={c} variant={variant} />
+ <blockquote
+ className={cn(
+ "border-l-2 border-brand/50 pl-3 leading-relaxed text-foreground",
+ variant === "preview" && "line-clamp-4",
+ )}
+ >
+ {c.speaker && <span className="font-medium text-muted-foreground">{c.speaker}: </span>}
+ <q className="italic">{c.quote}</q>
+ </blockquote>
+ {postText && variant !== "preview" && (
+ <p className="line-clamp-4 whitespace-pre-line text-muted-foreground">{postText}</p>
+ )}
+ {c.note && variant !== "preview" && <p className="text-xs text-muted-foreground">{c.note}</p>}
+ <VerificationBadge verification={c.verification} />
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
+ {(c.kind === "video" || c.kind === "audio") && <SpanLinks c={c} />}
+ {c.kind === "post" && <PostLinks c={c} />}
+ {c.kind === "source" && <SourceLinks c={c} />}
+ {c.kind === "page" && <PageLinks c={c} />}
+ </div>
+ </div>
+ );
+}
diff --git a/common/components/citations/CitedMarkdown.tsx b/common/components/citations/CitedMarkdown.tsx
@@ -0,0 +1,57 @@
+import type { AnchorHTMLAttributes } from "react";
+import { CITE_SCHEME } from "../../lib/citations/inline";
+import type { CitationView } from "../../lib/report/views";
+import { Markdown } from "../Markdown";
+import { InlineCite } from "./InlineCite";
+
+// Report markdown with its citations live: every `[label](cite:<id>)` link
+// (lib/citations/inline.ts) becomes an InlineCite for the citation's view;
+// every other link renders as Markdown's own external link. The house markdown
+// setup (Markdown.tsx — markdown-to-jsx, raw HTML escaped, token-styled) is
+// unchanged; this only swaps its link component. A `cite:` link inside code is
+// code, never a citation: markdown-to-jsx makes no link there.
+//
+// Pure: the citations come in as props, keyed by id.
+
+const EXTERNAL_LINK_CLASS = "text-brand underline decoration-brand/40 hover:decoration-brand";
+
+export function citeIdOf(href: string | undefined): string | null {
+ return href?.startsWith(CITE_SCHEME) ? href.slice(CITE_SCHEME.length).trim() : null;
+}
+
+type Citations = Readonly<Record<string, CitationView>>;
+
+// The `<a>` of cited markdown: one stable component, the citations passed in
+// as a prop (Markdown `linkProps`).
+function CiteLink({
+ href,
+ children: label,
+ citations,
+ ...rest
+}: AnchorHTMLAttributes<HTMLAnchorElement> & { citations: Citations }) {
+ const id = citeIdOf(href);
+ if (id !== null) {
+ return <InlineCite citation={Object.hasOwn(citations, id) ? citations[id] : undefined}>{label}</InlineCite>;
+ }
+ return (
+ <a {...rest} href={href} target="_blank" rel="noopener noreferrer" className={EXTERNAL_LINK_CLASS}>
+ {label}
+ </a>
+ );
+}
+
+export function CitedMarkdown({
+ children,
+ citations,
+ className,
+}: {
+ children: string;
+ citations: Citations;
+ className?: string;
+}) {
+ return (
+ <Markdown className={className} linkComponent={CiteLink} linkProps={{ citations }}>
+ {children}
+ </Markdown>
+ );
+}
diff --git a/common/components/citations/InlineCite.tsx b/common/components/citations/InlineCite.tsx
@@ -0,0 +1,192 @@
+"use client";
+
+import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState, type ReactNode } from "react";
+import { createPortal } from "react-dom";
+import { citationAnchor } from "../../lib/citations/inline";
+import { CITATION_KIND_LABELS, type CitationView } from "../../lib/report/views";
+import { CitationCard } from "./CitationCard";
+
+// AN INLINE CITATION — what a `[label](cite:<id>)` link in report markdown
+// renders as (CitedMarkdown): the label, linked to what it cites (the moment
+// page of a span or a post, the source's or the page's URL), then the
+// citation's number in its report as a superscript, linked to its reference
+// entry (`#c-<id>`).
+//
+// THE PREVIEW: hovering the marker, or focusing the number, opens the
+// citation's card (CitationCard `preview`) beside it without leaving the text.
+// On touch the first tap on the number opens the preview and the second
+// follows the link, so a phone reader can peek before jumping. Escape, a tap
+// outside, or focus leaving the marker closes it.
+//
+// The card is PORTALLED to <body>, never rendered inside the marker: the
+// marker sits inside a markdown <p>, and a card's blocks (blockquote, div)
+// inside a <p> are invalid HTML the parser would re-nest — a hydration error.
+// It is positioned against the document (so it scrolls with the text), under
+// the number, or above it when there is no room below, and kept inside the
+// viewport's width. The number is the keyboard's way in; its reference entry
+// carries the same card with every link.
+//
+// A ref the page could not resolve (no view for the id — validation keeps a
+// published report from having one) renders its label alone.
+
+const CLOSE_DELAY_MS = 150;
+const EDGE_PX = 8;
+const GAP_PX = 6;
+
+function isExternal(c: CitationView): boolean {
+ return c.kind === "source" || c.kind === "page";
+}
+
+export function InlineCite({ citation, children }: { citation: CitationView | undefined; children: ReactNode }) {
+ const previewId = useId();
+ const [open, setOpen] = useState(false);
+ const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
+ const wrapRef = useRef<HTMLSpanElement>(null);
+ const numberRef = useRef<HTMLAnchorElement>(null);
+ const cardRef = useRef<HTMLDivElement>(null);
+ const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
+ const lastPointer = useRef<string>("mouse");
+
+ const cancelClose = useCallback(() => {
+ if (closeTimer.current) clearTimeout(closeTimer.current);
+ closeTimer.current = null;
+ }, []);
+ const show = useCallback(() => {
+ cancelClose();
+ setOpen(true);
+ }, [cancelClose]);
+ const hide = useCallback(() => {
+ cancelClose();
+ setOpen(false);
+ setPos(null);
+ }, [cancelClose]);
+ const hideSoon = useCallback(() => {
+ cancelClose();
+ closeTimer.current = setTimeout(hide, CLOSE_DELAY_MS);
+ }, [cancelClose, hide]);
+
+ // Place the card once it has a size: under the number, or above it when the
+ // viewport has no room below; clamped to the viewport's width.
+ useLayoutEffect(() => {
+ if (!open || !cardRef.current) return;
+ const anchor = (numberRef.current ?? wrapRef.current)?.getBoundingClientRect();
+ if (!anchor) return;
+ const card = cardRef.current.getBoundingClientRect();
+ const below = anchor.bottom + GAP_PX;
+ const top =
+ below + card.height > window.innerHeight - EDGE_PX && anchor.top - GAP_PX - card.height > EDGE_PX
+ ? anchor.top - GAP_PX - card.height
+ : below;
+ const maxLeft = Math.max(EDGE_PX, window.innerWidth - card.width - EDGE_PX);
+ const left = Math.min(Math.max(EDGE_PX, anchor.left - 12), maxLeft);
+ setPos({ top: top + window.scrollY, left: left + window.scrollX });
+ }, [open]);
+
+ // A tap or click outside the marker and the card closes it; so does a resize.
+ useEffect(() => {
+ if (!open) return;
+ const onDown = (e: PointerEvent) => {
+ const t = e.target as Node;
+ if (wrapRef.current?.contains(t) || cardRef.current?.contains(t)) return;
+ hide();
+ };
+ document.addEventListener("pointerdown", onDown);
+ window.addEventListener("resize", hide);
+ return () => {
+ document.removeEventListener("pointerdown", onDown);
+ window.removeEventListener("resize", hide);
+ };
+ }, [open, hide]);
+
+ useEffect(() => cancelClose, [cancelClose]);
+
+ if (!citation) return <>{children}</>;
+ const c = citation;
+ const external = isExternal(c);
+ const n = c.number;
+
+ return (
+ <span
+ ref={wrapRef}
+ data-inline-cite={c.id}
+ onMouseEnter={() => {
+ if (lastPointer.current !== "touch") show();
+ }}
+ onMouseLeave={() => {
+ if (lastPointer.current !== "touch") hideSoon();
+ }}
+ onFocus={show}
+ onBlur={(e) => {
+ const next = e.relatedTarget as Node | null;
+ if (!wrapRef.current?.contains(next) && !cardRef.current?.contains(next)) hideSoon();
+ }}
+ onKeyDown={(e) => {
+ if (e.key === "Escape" && open) {
+ e.stopPropagation();
+ hide();
+ }
+ }}
+ onPointerDown={(e) => {
+ lastPointer.current = e.pointerType;
+ }}
+ >
+ {c.href ? (
+ <a
+ href={c.href}
+ {...(external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
+ className="text-brand underline decoration-brand/40 underline-offset-2 hover:decoration-brand"
+ >
+ {children}
+ </a>
+ ) : (
+ children
+ )}
+ {n !== undefined && (
+ <sup className="ml-0.5 font-mono text-[0.72em] font-semibold">
+ <a
+ ref={numberRef}
+ href={`#${citationAnchor(c.id)}`}
+ data-cite-number={n}
+ aria-label={`Citation ${n}, ${CITATION_KIND_LABELS[c.kind].toLowerCase()}`}
+ aria-expanded={open}
+ aria-controls={open ? previewId : undefined}
+ onClick={(e) => {
+ // First tap on touch peeks; the second follows the link.
+ if (lastPointer.current === "touch" && !open) {
+ e.preventDefault();
+ show();
+ }
+ }}
+ className="rounded-sm px-0.5 text-brand-strong no-underline hover:bg-brand-soft focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
+ >
+ [{n}]
+ </a>
+ </sup>
+ )}
+ {open &&
+ createPortal(
+ <div
+ ref={cardRef}
+ id={previewId}
+ role="group"
+ aria-label={n !== undefined ? `Citation ${n}` : "Citation"}
+ data-cite-preview={c.id}
+ style={{
+ position: "absolute",
+ top: pos?.top ?? 0,
+ left: pos?.left ?? 0,
+ visibility: pos ? "visible" : "hidden",
+ }}
+ className="z-50 w-[min(22rem,calc(100vw-1rem))] rounded-lg border border-border bg-popover p-3 text-left text-popover-foreground shadow-lg"
+ onMouseEnter={cancelClose}
+ onMouseLeave={() => {
+ if (lastPointer.current !== "touch") hideSoon();
+ }}
+ >
+ <CitationCard citation={c} variant="preview" />
+ </div>,
+ document.body,
+ )}
+ </span>
+ );
+}
diff --git a/common/components/citations/ReferenceList.tsx b/common/components/citations/ReferenceList.tsx
@@ -0,0 +1,43 @@
+import { citationAnchor } from "../../lib/citations/inline";
+import type { CitationView } from "../../lib/report/views";
+import { cn } from "../../lib/utils";
+import { CitationCard } from "./CitationCard";
+
+// A NUMBERED REFERENCE LIST — a report's citations (or one section's), in
+// number order, each entry a CitationCard (`reference`) under the number the
+// inline markers show. The report-wide list carries the anchors `c-<id>` the
+// markers jump to; a per-section list passes `anchors={false}` so an id is on
+// the page once.
+//
+// Pure: the citations come in as props, already numbered (lib/report/views.ts
+// orderedCitations / sectionCitations).
+
+export function ReferenceList({
+ citations,
+ anchors = true,
+ className,
+}: {
+ citations: readonly CitationView[];
+ anchors?: boolean;
+ className?: string;
+}) {
+ if (citations.length === 0) return null;
+ return (
+ <ol data-reference-list="" className={cn("flex flex-col divide-y divide-border", className)}>
+ {citations.map((c) => (
+ <li
+ key={c.id}
+ id={anchors ? citationAnchor(c.id) : undefined}
+ value={c.number}
+ data-reference={c.id}
+ className="flex scroll-mt-20 gap-3 py-3 target:bg-brand-soft/60 target:rounded-md"
+ >
+ <span className="w-7 shrink-0 pt-0.5 text-right font-mono text-sm font-semibold text-brand-strong">
+ {c.number !== undefined ? `${c.number}.` : "·"}
+ </span>
+ <CitationCard citation={c} variant="reference" className="flex-1" />
+ </li>
+ ))}
+ </ol>
+ );
+}
diff --git a/common/components/citations/VerificationBadge.tsx b/common/components/citations/VerificationBadge.tsx
@@ -0,0 +1,71 @@
+import { BadgeCheck, Mic } from "lucide-react";
+import type { CitationVerification } from "../../lib/citations/schema";
+import { cn } from "../../lib/utils";
+
+// What checking a citation found (its COMPUTED `verification` block, written by
+// compose — lib/citations/schema.ts): the quote's match against the record's
+// transcript as a percentage, whether the voice was checked, and when. Renders
+// nothing when the citation was never checked, so an unchecked citation reads
+// as unchecked rather than as a zero.
+//
+// Pure/presentational: server-rendered or in a client tree alike.
+
+export const QUOTE_SCORE_TOOLTIP =
+ "How closely the quoted words match the record's transcript at the cited place: checked against the transcript, 100% is verbatim.";
+
+export function quoteScorePercent(score: number): number {
+ return Math.round(Math.min(1, Math.max(0, score)) * 100);
+}
+
+// `2026-10-04T12:00:00Z` → `2026-10-04`; anything else as given.
+function dateOf(v: string): string {
+ return /^\d{4}-\d{2}-\d{2}/.test(v) ? v.slice(0, 10) : v;
+}
+
+export function VerificationBadge({
+ verification,
+ className,
+}: {
+ verification: CitationVerification | undefined;
+ className?: string;
+}) {
+ const v = verification;
+ if (!v) return null;
+ const hasScore = typeof v.quoteScore === "number" && Number.isFinite(v.quoteScore);
+ if (!hasScore && !v.voiceChecked) return null;
+ const checked = v.quoteCheckedAt ? dateOf(v.quoteCheckedAt) : null;
+ return (
+ <span
+ data-verification=""
+ className={cn(
+ "inline-flex flex-wrap items-center gap-x-2 gap-y-1 font-mono text-[11px] text-muted-foreground",
+ className,
+ )}
+ >
+ {hasScore && (
+ <span
+ title={`${QUOTE_SCORE_TOOLTIP}${v.method ? ` Method: ${v.method}.` : ""}`}
+ data-quote-score={quoteScorePercent(v.quoteScore!)}
+ className="inline-flex items-center gap-1 rounded border border-success/40 bg-success-soft px-1.5 py-0.5 text-success"
+ >
+ <BadgeCheck className="size-3" aria-hidden />
+ <span>
+ Quote {quoteScorePercent(v.quoteScore!)}%
+ <span className="sr-only"> match, checked against the transcript</span>
+ </span>
+ </span>
+ )}
+ {v.voiceChecked && (
+ <span
+ title="The speaker's voice in the cited span was checked."
+ data-voice-checked=""
+ className="inline-flex items-center gap-1 rounded border border-border px-1.5 py-0.5"
+ >
+ <Mic className="size-3" aria-hidden />
+ Voice checked
+ </span>
+ )}
+ {checked && <span>checked {checked}</span>}
+ </span>
+ );
+}
diff --git a/common/components/citations/citations.test.ts b/common/components/citations/citations.test.ts
@@ -0,0 +1,202 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import * as React from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import type { CitationView, PageCitationView, PostCitationView, SourceCitationView, SpanCitationView } from "../../lib/report/views";
+import { CitationCard } from "./CitationCard";
+import { CitedMarkdown, citeIdOf } from "./CitedMarkdown";
+import { InlineCite } from "./InlineCite";
+import { ReferenceList } from "./ReferenceList";
+import { QUOTE_SCORE_TOOLTIP, VerificationBadge, quoteScorePercent } from "./VerificationBadge";
+
+// common's tsconfig has `jsx: "preserve"` (Next compiles it), so tsx falls
+// back to the classic transform — `React.createElement` on a free `React`.
+(globalThis as { React?: typeof React }).React = React;
+const h = React.createElement;
+const render = (el: Parameters<typeof renderToStaticMarkup>[0]) => renderToStaticMarkup(el);
+
+const video: SpanCitationView = {
+ kind: "video",
+ id: "c01",
+ number: 1,
+ quote: "The bridge opened in the spring.",
+ speaker: "The host",
+ moment: "demo-channel/abc123/3126.00-3151.00",
+ href: "/m/demo-channel/abc123/3126.00-3151.00/",
+ start: 3126,
+ end: 3151,
+ record: {
+ channel: "demo-channel",
+ channelTitle: "Demo Channel",
+ id: "abc123",
+ title: "Demo stream",
+ date: "2026-01-10",
+ originalUrl: "https://media.example.org/demo-channel/abc123?t=3126",
+ },
+ poster: "/poster.png",
+ verification: { quoteScore: 0.974, quoteCheckedAt: "2026-10-04T12:00:00Z", voiceChecked: true, method: "cue-window v1" },
+};
+
+const audio: SpanCitationView = {
+ ...video,
+ kind: "audio",
+ id: "au1",
+ number: 2,
+ poster: undefined,
+ verification: undefined,
+ speaker: undefined,
+ moment: "demo-podcast/ep-042/610.50-628.00",
+ href: "/m/demo-podcast/ep-042/610.50-628.00/",
+ start: 610.5,
+ end: 628,
+ record: { channel: "demo-podcast", id: "ep-042", title: "Episode 42" },
+};
+
+const post: PostCitationView = {
+ kind: "post",
+ id: "p01",
+ number: 3,
+ quote: "Never said I would move.",
+ moment: "demo-social/123",
+ href: "/m/demo-social/123/",
+ record: { channel: "demo-social", channelTitle: "Demo Social", id: "123", date: "2025-03-14" },
+ author: "@demo_account",
+ text: "Never said I would move.\n\nPosted to settle it.",
+ shot: "/media/posts/demo-social/123/shot.png",
+};
+
+const source: SourceCitationView = {
+ kind: "source",
+ id: "a01",
+ number: 4,
+ quote: "He opened the bridge himself.",
+ source: {
+ id: "s0",
+ kind: "article",
+ title: "An example article",
+ url: "https://example.org/a",
+ publisher: "Example Gazette",
+ archives: [{ label: "archive.org", url: "https://web.archive.org/x", context: "as published" }],
+ },
+ image: "/reports/demo/stills/a01.png",
+ href: "https://example.org/a",
+};
+
+const page: PageCitationView = {
+ kind: "page",
+ id: "w01",
+ number: 5,
+ quote: "Opened to traffic: April 2019.",
+ href: "https://example.org/city/history",
+ title: "City records",
+ archiveUrl: "https://web.archive.org/y",
+ note: "The city's own record.",
+};
+
+const all: CitationView[] = [video, audio, post, source, page];
+const byId = Object.fromEntries(all.map((c) => [c.id, c]));
+
+test("a video card: title, channel, date, span, speaker, quote, poster and play", () => {
+ const html = render(h(CitationCard, { citation: video }));
+ for (const s of ["Demo stream", "Demo Channel", "2026-01-10", "52:06–52:31", "The host", "The bridge opened in the spring."]) {
+ assert.ok(html.includes(s), s);
+ }
+ assert.match(html, /<img src="\/poster.png"/);
+ assert.match(html, /href="\/m\/demo-channel\/abc123\/3126.00-3151.00\/"[^>]*>.*Play 52:06–52:31/);
+ assert.match(html, /href="https:\/\/media.example.org\/demo-channel\/abc123\?t=3126" target="_blank"/);
+ assert.match(html, /data-citation-kind="video"/);
+});
+
+test("an audio card has no picture without a poster, and still plays its span", () => {
+ const html = render(h(CitationCard, { citation: audio }));
+ assert.ok(!html.includes("<img"));
+ assert.ok(html.includes("10:10–10:28"));
+ assert.ok(html.includes("Audio"));
+});
+
+test("a post card: author, date, quote, the post's fuller text, the shot", () => {
+ const html = render(h(CitationCard, { citation: post }));
+ for (const s of ["@demo_account", "Demo Social", "2025-03-14", "Never said I would move.", "Posted to settle it.", "Open post"]) {
+ assert.ok(html.includes(s), s);
+ }
+ assert.match(html, /<img src="\/media\/posts\/demo-social\/123\/shot.png"/);
+});
+
+test("a source card: the still, the quote, the document's archive links in context", () => {
+ const html = render(h(CitationCard, { citation: source }));
+ assert.match(html, /<img src="\/reports\/demo\/stills\/a01.png" alt="The sentence as An example article shows it: He opened the bridge himself."/);
+ assert.match(html, /href="https:\/\/web.archive.org\/x"[^>]*title="as published"/);
+ assert.ok(html.includes("Example Gazette"));
+});
+
+test("a page card: title, host, the archived copy, the note", () => {
+ const html = render(h(CitationCard, { citation: page }));
+ for (const s of ["City records", "example.org", "Archived copy", "The city's own record."]) assert.ok(html.includes(s), s);
+});
+
+test("the preview density keeps the facts, clamps the quote and drops the note and post text", () => {
+ const html = render(h(CitationCard, { citation: post, variant: "preview" }));
+ assert.ok(html.includes("line-clamp-4"));
+ assert.ok(!html.includes("Posted to settle it."));
+ assert.ok(!render(h(CitationCard, { citation: page, variant: "preview" })).includes("own record"));
+ // the number shows on a card and a preview, never on a reference entry (the list's)
+ assert.match(html, />3<\/span>/);
+ assert.ok(!render(h(CitationCard, { citation: video, variant: "reference" })).includes("<img"));
+});
+
+test("the verification badge: a rounded percentage, voice, date; nothing when unchecked", () => {
+ const html = render(h(VerificationBadge, { verification: video.verification }));
+ assert.match(html, /data-quote-score="97"/);
+ assert.ok(html.includes("Quote 97%"));
+ assert.ok(html.includes("Voice checked"));
+ assert.ok(html.includes("checked 2026-10-04"));
+ assert.ok(html.includes("checked against the transcript"));
+ assert.ok(html.includes("Method: cue-window v1."));
+ assert.ok(QUOTE_SCORE_TOOLTIP.includes("checked against the transcript"));
+ assert.equal(render(h(VerificationBadge, { verification: undefined })), "");
+ assert.equal(render(h(VerificationBadge, { verification: { method: "x" } })), "");
+ assert.equal(quoteScorePercent(1.2), 100);
+ assert.equal(quoteScorePercent(-1), 0);
+});
+
+test("an inline cite: the label links to what it cites, the number to its reference entry", () => {
+ const html = render(h(InlineCite, { citation: video, children: "on stream" }));
+ assert.match(html, /<a href="\/m\/demo-channel\/abc123\/3126.00-3151.00\/"[^>]*>on stream<\/a>/);
+ assert.match(html, /<sup[^>]*><a href="#c-c01" data-cite-number="1" aria-label="Citation 1, video" aria-expanded="false"/);
+ assert.ok(html.includes("[1]"));
+ // closed: no preview in the server markup (it is portalled when opened)
+ assert.ok(!html.includes("data-cite-preview"));
+ // a page or a source opens off-site
+ assert.match(render(h(InlineCite, { citation: page, children: "records" })), /href="https:\/\/example.org\/city\/history" target="_blank" rel="noopener noreferrer"/);
+ // an unresolved ref is its label alone
+ assert.equal(render(h(InlineCite, { citation: undefined, children: "label" })), "label");
+});
+
+test("cited markdown: cite links become inline cites, other links stay links, code stays code", () => {
+ const md = "He said so [on stream](cite:c01), the [city](cite:w01) agrees; see [elsewhere](https://example.org/z) and `[x](cite:c01)`.";
+ const html = render(h(CitedMarkdown, { citations: byId, children: md }));
+ assert.match(html, /data-inline-cite="c01"/);
+ assert.match(html, /data-inline-cite="w01"/);
+ assert.match(html, /\[1\]/);
+ assert.match(html, /\[5\]/);
+ assert.match(html, /<a [^>]*href="https:\/\/example.org\/z" target="_blank" rel="noopener noreferrer"[^>]*>elsewhere<\/a>/);
+ assert.match(html, /<code[^>]*>\[x\]\(cite:c01\)<\/code>/);
+ // an id the map lacks renders as its label
+ assert.ok(render(h(CitedMarkdown, { citations: {}, children: "[gone](cite:zz)" })).includes("gone"));
+ assert.equal(citeIdOf("cite:c01"), "c01");
+ assert.equal(citeIdOf("https://x"), null);
+});
+
+test("the reference list: number order as given, anchors c-<id>, none when asked not to", () => {
+ const html = render(h(ReferenceList, { citations: all }));
+ const anchors = [...html.matchAll(/<li id="(c-[^"]+)" value="(\d+)"/g)].map((m) => [m[1], m[2]]);
+ assert.deepEqual(anchors, [
+ ["c-c01", "1"],
+ ["c-au1", "2"],
+ ["c-p01", "3"],
+ ["c-a01", "4"],
+ ["c-w01", "5"],
+ ]);
+ assert.ok(!render(h(ReferenceList, { citations: all, anchors: false })).includes('id="c-'));
+ assert.equal(render(h(ReferenceList, { citations: [] })), "");
+});
diff --git a/common/components/report/VerdictChip.test.ts b/common/components/report/VerdictChip.test.ts
@@ -0,0 +1,37 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import * as React from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { resolveVerdicts } from "../../lib/report/verdicts";
+import { VerdictChip, VerdictTally } from "./VerdictChip";
+
+(globalThis as { React?: typeof React }).React = React;
+const h = React.createElement;
+
+const styles = resolveVerdicts({ PARTLY: { label: "Half true", color: "#123456" } });
+
+test("a chip: the verdict's label, its colour as the chip's mark, a count when given", () => {
+ const html = renderToStaticMarkup(h(VerdictChip, { verdict: "PARTLY", style: styles.PARTLY, count: 3 }));
+ assert.match(html, /data-verdict="PARTLY"/);
+ assert.match(html, /style="--verdict:#123456"/);
+ assert.ok(html.includes("Half true"));
+ assert.match(html, />3<\/span>/);
+});
+
+test("a tally: one chip per verdict given, nothing for none", () => {
+ const html = renderToStaticMarkup(
+ h(VerdictTally, {
+ tally: [
+ { verdict: "CORROBORATED", count: 2 },
+ { verdict: "CONTRADICTED", count: 1 },
+ ],
+ styles,
+ }),
+ );
+ assert.deepEqual(
+ [...html.matchAll(/data-verdict="([A-Z_]+)"/g)].map((m) => m[1]),
+ ["CORROBORATED", "CONTRADICTED"],
+ );
+ assert.ok(html.includes("Corroborated"));
+ assert.equal(renderToStaticMarkup(h(VerdictTally, { tally: [], styles })), "");
+});
diff --git a/common/components/report/VerdictChip.tsx b/common/components/report/VerdictChip.tsx
@@ -0,0 +1,58 @@
+import type { VerdictCount } from "../../lib/report/views";
+import type { Verdict, VerdictStyle } from "../../lib/report/verdicts";
+import { cn } from "../../lib/utils";
+
+// A fact-check's verdict, from the shared vocabulary (lib/report/verdicts.mjs)
+// with the report's overrides applied — the one place a verdict is drawn, so
+// the export site, the editor's previews and umtool's report preview agree.
+// The colour marks the chip (its dot and edge); the label's text stays in the
+// foreground colour so it reads on every base whatever the colour is.
+
+export function VerdictChip({
+ verdict,
+ style: s,
+ count,
+ className,
+}: {
+ verdict: Verdict;
+ style: VerdictStyle;
+ count?: number;
+ className?: string;
+}) {
+ return (
+ <span
+ data-verdict={verdict}
+ style={{ "--verdict": s.color } as React.CSSProperties}
+ className={cn(
+ "inline-flex shrink-0 items-center gap-1.5 rounded-full border border-[color:var(--verdict)] bg-[color-mix(in_oklab,var(--verdict)_14%,transparent)] px-2.5 py-0.5 text-xs font-semibold text-foreground",
+ className,
+ )}
+ >
+ <span aria-hidden className="size-2 rounded-full bg-[color:var(--verdict)]" />
+ {s.label}
+ {count !== undefined && <span className="font-mono tabular-nums text-muted-foreground">{count}</span>}
+ </span>
+ );
+}
+
+// A fact-check's tally: one chip per verdict a claim carries, with its count.
+export function VerdictTally({
+ tally,
+ styles,
+ className,
+}: {
+ tally: readonly VerdictCount[];
+ styles: Readonly<Record<Verdict, VerdictStyle>>;
+ className?: string;
+}) {
+ if (tally.length === 0) return null;
+ return (
+ <ul data-verdict-tally="" className={cn("flex flex-wrap gap-2", className)}>
+ {tally.map((t) => (
+ <li key={t.verdict}>
+ <VerdictChip verdict={t.verdict} style={styles[t.verdict]} count={t.count} />
+ </li>
+ ))}
+ </ul>
+ );
+}
diff --git a/common/lib/report/views.test.ts b/common/lib/report/views.test.ts
@@ -0,0 +1,230 @@
+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",
+ note: "The first edition.",
+ 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",
+ title: "One",
+ 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?.note, "The first edition.");
+ 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",
+ title: "One",
+ 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.claimTitle, e.claimText, e.verdict, e.verdictStyle?.label, e.number]),
+ [
+ ["/reports/demo/#c1", "One", "Claim one.", "CONTRADICTED", "Contradicted", 4],
+ ["/reports/demo/#c2", undefined, "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.note ? { note: s.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.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 };
diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md
@@ -1,5 +1,8 @@
# Changelog
+## [Unreleased]
+- **A site can show cited reports, and every citation opens on a page of its own.** A site built with reports has a **Reports** link in its header and a page at `/reports/` listing them. A report's page has its title, subtitle, dates and the document under review with its archive links; a fact-check's tally of verdicts; the summary; the sections and their claims, each with its verdict, the document's own sentence as an image, the findings and the evidence cards; a numbered reference list; and links to download its citations as JSON and CSV. A citation in the text shows as its words plus a number: hovering it, focusing the number or tapping it once shows a card of the citation (the quote, who said it and when, a picture or the post's screenshot, and how closely the quote matched the transcript when it was checked); the words open what it cites and the number jumps to its reference. A cited span of a video or audio record opens at `/m/<channel>/<id>/<start>-<end>/`: a short clip of the span with a little context either side, the quote, the transcript lines around it, the record's title, channel and date, a link to the original at that time, and every report on the site that cites it. A cited post opens at `/m/<channel>/<id>/` with its screenshot and text. A site that publishes only its reports (`site.json` `publish: "cited"`) opens on the report index and has no search, Ask AI, downloads or duplicates. A site with no reports is unchanged. Needs a rebuild and deploy of each site.
+
## [0.11.1] - 2026-10-01
- **Use with AI goes to the Archilyzer site's AI and MCP doc; the page on each site is gone.** The header's, the slide-out menu's, the footer's and Ask AI's **Use with AI** keep their label and open https://archilyzer.pages.dev/docs/ai-and-mcp/ in the same tab, on every site and the hub, where one block says how to run Claude Code against any archive (the source, `pnpm install`, `claude mcp add archilyzer`, `/ask`). `/use-with-ai/` is no longer built. `corpus.json`'s `useWithAi` names the doc; `llms.txt`'s Ask AI section lists the site's `/ask/` chat and the doc; the sitemap drops `/use-with-ai`. Needs a rebuild and deploy of each site and the hub.
- **A search with a layer that has nothing to read finishes.** A "Posts" layer under a tag chip, or a "Live chat" layer where no video in the selection has live chat, read "searched N/M…" for ever and never said "No matching videos."; it now finishes at once, having matched nothing. Needs a rebuild and deploy of each site and the hub.
diff --git a/export/app/(workspace)/ask/page.tsx b/export/app/(workspace)/ask/page.tsx
@@ -3,6 +3,8 @@ import { AI_DOC_URL } from "yt-dlp-transcript-common/lib/project";
import { currentSite } from "../../lib/site";
import { instanceMode } from "../../lib/mode";
import AskHub from "../../ask/AskHub";
+import { isCitedSite } from "yt-dlp-transcript-common/lib/siteSchema";
+import { NotOnCitedSite } from "../../components/reports/parts";
export const metadata: Metadata = { title: "Ask AI" };
@@ -19,6 +21,8 @@ export default function AskPage() {
const site = currentSite();
const isHub = instanceMode() === "hub";
const scope = isHub ? "the federation" : "the transcripts";
+ // A cited site has no corpus to ask about.
+ if (!isHub && isCitedSite(site)) return <NotOnCitedSite title="Ask AI" />;
return (
<div className="mx-auto flex max-w-3xl flex-col gap-6">
diff --git a/export/app/(workspace)/layout.tsx b/export/app/(workspace)/layout.tsx
@@ -1,3 +1,4 @@
+import { isCitedSite } from "yt-dlp-transcript-common/lib/siteSchema";
import { instanceMode } from "../lib/mode";
import { currentSite } from "../lib/site";
import SiteWorkspace from "./SiteWorkspace";
@@ -8,13 +9,15 @@ import SiteWorkspace from "./SiteWorkspace";
// survives navigation between them.
//
// Hub mode keeps its own self-contained pages (<HubHome/>, <AskHub/>), so here
-// it's a straight pass-through — the fork is preserved exactly as before.
+// it's a straight pass-through — the fork is preserved exactly as before. So
+// is a CITED site (site.json `publish: "cited"`): it has no corpus to search,
+// and its home is the report index.
export default function WorkspaceLayout({
children,
}: {
children: React.ReactNode;
}) {
- if (instanceMode() === "hub") return <>{children}</>;
+ if (instanceMode() === "hub" || isCitedSite(currentSite())) return <>{children}</>;
// site.json `transcriptDownloads: false` hides the transcript modal's
// per-video export controls (absent = on).
return (
diff --git a/export/app/(workspace)/page.tsx b/export/app/(workspace)/page.tsx
@@ -1,8 +1,10 @@
-import { countTranscripts } from "yt-dlp-transcript-common/lib/transcripts";
+import { isCitedSite } from "yt-dlp-transcript-common/lib/siteSchema";
import { currentSite } from "../lib/site";
import { instanceMode } from "../lib/mode";
+import { readReportIndex } from "../lib/reports";
import NewSinceLastVisit from "../components/NewSinceLastVisit";
import HubHome from "../components/hub/HubHome";
+import ReportIndex from "../components/reports/ReportIndex";
export default async function Home() {
// Hub mode renders the federated shelf + cross-site search and reads no local
@@ -14,6 +16,14 @@ export default async function Home() {
}
const settings = currentSite();
+ // A cited site publishes only its reports and their moments: its home is the
+ // report index (a static export has no redirect to send it there), and it
+ // has no summaries manifest, so countTranscripts() — which throws without
+ // one — is never reached: its module is not even loaded on this path.
+ if (isCitedSite(settings)) {
+ return <ReportIndex index={readReportIndex()} heading={settings.siteTitle} tagline={settings.homeTagline} />;
+ }
+ const { countTranscripts } = await import("yt-dlp-transcript-common/lib/transcripts");
const count = await countTranscripts();
// The corpus data source, SearchSession, the persistent search bar, AND the
diff --git a/export/app/components/Footer.tsx b/export/app/components/Footer.tsx
@@ -15,9 +15,14 @@ import { SocialLinks } from "yt-dlp-transcript-common/components/SocialLinks";
import { currentSite } from "../lib/site";
import { instanceMode } from "../lib/mode";
import { hasArchives } from "../lib/archives";
+import { isCitedSite } from "yt-dlp-transcript-common/lib/siteSchema";
export default function Footer() {
const site = currentSite();
+ // A cited site (site.json `publish: "cited"`) has no archives to download
+ // and nothing for an AI to read; its footer keeps the rest.
+ const cited = instanceMode() === "site" && isCitedSite(site);
+ const showArchives = !cited && hasArchives();
const socialLinks = resolveSocialLinks(site, getSettings());
// The whole pool is present at build time, so siblings (other sites with a
// siteUrl) resolve here; single-site installs get an empty list and the
@@ -36,14 +41,14 @@ export default function Footer() {
project site's job (one snapshot, one place, one checksum), and
leaving the label behind would caption an empty space on a site
with neither archives nor a PWA. */}
- {(hasArchives() || site.pwa) && (
+ {(showArchives || site.pwa) && (
<span className="font-mono text-xs uppercase tracking-[0.14em]">
Downloads
</span>
)}
{/* Transcript & live-chat archive zips, generated per build. Shown only
when this build actually produced servable archives. */}
- {hasArchives() && (
+ {showArchives && (
<a
href="/downloads"
className="underline underline-offset-2 hover:text-foreground transition-colors"
@@ -64,18 +69,20 @@ export default function Footer() {
)}
{/* The dot parts the downloads from the rest, so it shows only when
there is something before it. */}
- {(hasArchives() || site.pwa) && (
+ {(showArchives || site.pwa) && (
<span aria-hidden="true" className="text-muted-foreground/50">
·
</span>
)}
{/* The homepage's AI and MCP doc, in the same tab (release 16). */}
- <a
- href={AI_DOC_URL}
- className="underline underline-offset-2 hover:text-foreground transition-colors"
- >
- Use with AI
- </a>
+ {!cited && (
+ <a
+ href={AI_DOC_URL}
+ className="underline underline-offset-2 hover:text-foreground transition-colors"
+ >
+ Use with AI
+ </a>
+ )}
{/* Changelog lives here, not in the header (release 14). */}
<a
href="/changelog"
diff --git a/export/app/components/Header.tsx b/export/app/components/Header.tsx
@@ -2,7 +2,7 @@ import Link from "next/link";
import { getSettings } from "yt-dlp-transcript-common/lib/settings";
import { resolveSocialLinks } from "yt-dlp-transcript-common/lib/site";
import { headerSocialLinks } from "yt-dlp-transcript-common/lib/socialLinks";
-import { AI_DOC_URL, INSTANCES_URL } from "yt-dlp-transcript-common/lib/project";
+import { INSTANCES_URL } from "yt-dlp-transcript-common/lib/project";
import { ThemeToggle } from "yt-dlp-transcript-common/components/ThemeToggle";
import { SocialLinks } from "yt-dlp-transcript-common/components/SocialLinks";
import { SocialScroll } from "yt-dlp-transcript-common/components/SocialScroll";
@@ -14,6 +14,9 @@ import { headerMarkPalette } from "../lib/brand";
import { instanceMode } from "../lib/mode";
import { hasArchives } from "../lib/archives";
import { hasDuplicates } from "../lib/duplicates";
+import { hasReports } from "../lib/reports";
+import { headerNavLinks } from "../lib/nav";
+import { isCitedSite } from "yt-dlp-transcript-common/lib/siteSchema";
import MobileMenu from "./MobileMenu";
// The export site's masthead (every site, and the hub): the Found-line mark +
@@ -76,21 +79,14 @@ export default function Header() {
const socialLinks = resolveSocialLinks(site, getSettings());
const narrow = headerSocialLinks(socialLinks, "narrow").length;
const wide = headerSocialLinks(socialLinks, "wide").length;
- const showDownloads = hasArchives();
- const showDuplicates = hasDuplicates();
-
- // The inline nav from lg. Ask AI is new: the chat was only reachable from
- // the workspace control or from Use with AI, which is not where anyone looks
- // for it.
- // Use with AI is the homepage's AI and MCP doc (release 16 slice DX): off
- // this site, so a plain <a> in the same tab, not a client navigation.
- const navLinks: { href: string; label: string; external?: boolean }[] = [
- { href: "/", label: "Search" },
- { href: "/ask/", label: "Ask AI" },
- ...(showDuplicates ? [{ href: "/duplicates", label: "Duplicates" }] : []),
- ...(showDownloads ? [{ href: "/downloads", label: "Downloads" }] : []),
- { href: AI_DOC_URL, label: "Use with AI", external: true },
- ];
+ // The inline nav from lg: lib/nav.ts says which links this build has.
+ const cited = isSite && isCitedSite(site);
+ const navLinks = headerNavLinks({
+ cited,
+ reports: isSite && hasReports(),
+ duplicates: !cited && hasDuplicates(),
+ downloads: !cited && hasArchives(),
+ });
// The sheet also takes Offline on a PWA-shipping site — gated exactly as
// Footer.tsx gates it, so the two never disagree about whether this instance
// has an offline mode.
diff --git a/export/app/components/reports/MomentArticle.tsx b/export/app/components/reports/MomentArticle.tsx
@@ -0,0 +1,167 @@
+import { VerificationBadge } from "yt-dlp-transcript-common/components/citations/VerificationBadge";
+import { VerdictChip } from "yt-dlp-transcript-common/components/report/VerdictChip";
+import { formatTimestamp } from "yt-dlp-transcript-common/lib/vtt";
+import { CITATION_KIND_LABELS, spanLabel, type MomentPageView } from "yt-dlp-transcript-common/lib/report/views";
+import { ExternalLinkText, Eyebrow, dateLabel, textLink } from "./parts";
+
+// ONE CITED MOMENT, from its view (common/lib/report/views.ts): a span's
+// self-hosted evidence clip (video, or audio with its poster) or a post's
+// captured shot and text; the quote and what checking it found; the transcript
+// lines around the span; the record's metadata and a link to the original at
+// its time (and into the corpus on a full site); and every place on the site
+// that cites it.
+
+export default function MomentArticle({ view }: { view: MomentPageView }) {
+ const r = view.record;
+ const isSpan = view.kind === "video" || view.kind === "audio";
+ const span = isSpan && view.start !== undefined && view.end !== undefined ? spanLabel(view.start, view.end) : null;
+ const meta = [r.channelTitle ?? r.channel, dateLabel(view.date ?? r.date), span].filter(Boolean).join(" · ");
+ const title = r.title ?? (view.kind === "post" ? (view.post?.author ?? r.channelTitle ?? r.channel) : `${r.channel}/${r.id}`);
+ const clip = view.clip;
+ const padded =
+ clip && view.start !== undefined && view.end !== undefined && (clip.start < view.start || clip.end > view.end);
+
+ return (
+ <article data-moment={view.key} data-moment-kind={view.kind} className="mx-auto flex w-full max-w-3xl flex-col gap-6">
+ <header className="flex flex-col gap-2">
+ <Eyebrow>Cited {CITATION_KIND_LABELS[view.kind].toLowerCase()}</Eyebrow>
+ <h1 className="font-display text-2xl font-semibold leading-tight tracking-tight text-foreground sm:text-3xl">
+ {title}
+ </h1>
+ {meta && <p className="font-mono text-xs text-muted-foreground">{meta}</p>}
+ </header>
+
+ {clip && view.kind === "video" && (
+ <video
+ data-evidence-clip=""
+ controls
+ playsInline
+ preload="metadata"
+ poster={clip.poster}
+ src={clip.src}
+ className="aspect-video w-full rounded-lg border border-border bg-black"
+ />
+ )}
+ {clip && view.kind === "audio" && (
+ <div className="flex flex-col gap-3">
+ {clip.poster && (
+ // eslint-disable-next-line @next/next/no-img-element -- a published file of unknown size; the export serves images unoptimized
+ <img src={clip.poster} alt="" className="aspect-video w-full rounded-lg border border-border object-cover" />
+ )}
+ <audio data-evidence-clip="" controls preload="metadata" src={clip.src} className="w-full" />
+ </div>
+ )}
+ {clip && padded && (
+ <p className="-mt-3 text-xs text-muted-foreground">
+ The clip runs {spanLabel(clip.start, clip.end)}, a little either side of the cited {span} for context.
+ </p>
+ )}
+ {view.post?.shot && (
+ // eslint-disable-next-line @next/next/no-img-element -- a published file of unknown size; the export serves images unoptimized
+ <img
+ src={view.post.shot}
+ alt={`Screenshot of the post${view.post.author ? ` by ${view.post.author}` : ""}`}
+ className="w-full max-w-xl rounded-lg border border-border"
+ />
+ )}
+
+ <div className="flex flex-col gap-2">
+ <blockquote className="border-l-4 border-brand pl-4 text-lg leading-relaxed text-foreground">
+ {view.speaker && <span className="font-medium text-muted-foreground">{view.speaker}: </span>}
+ <q className="italic">{view.quote}</q>
+ </blockquote>
+ <VerificationBadge verification={view.verification} />
+ </div>
+
+ {view.post && view.post.text.trim() !== view.quote.trim() && (
+ <div className="flex flex-col gap-1">
+ <p className="font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground">The post</p>
+ <p className="whitespace-pre-line text-sm leading-relaxed text-foreground">{view.post.text}</p>
+ </div>
+ )}
+ {view.post?.media && view.post.media.length > 0 && (
+ <ul className="flex flex-col gap-3">
+ {view.post.media.map((m) =>
+ m.kind === "image" ? (
+ <li key={m.src}>
+ {/* eslint-disable-next-line @next/next/no-img-element -- a published file of unknown size; the export serves images unoptimized */}
+ <img src={m.src} alt="" className="max-w-full rounded-md border border-border" />
+ </li>
+ ) : (
+ <li key={m.src}>
+ <video src={m.src} controls playsInline preload="metadata" className="w-full rounded-md border border-border" />
+ </li>
+ ),
+ )}
+ </ul>
+ )}
+
+ {view.cues && view.cues.length > 0 && (
+ <section aria-labelledby="cues-heading" className="flex flex-col gap-2">
+ <h2 id="cues-heading" className="font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
+ Transcript
+ </h2>
+ <ol data-cue-lines="" className="flex flex-col rounded-lg border border-border bg-card text-sm">
+ {view.cues.map((c, i) => (
+ <li
+ key={`${c.start}-${i}`}
+ data-in-span={c.inSpan ? "" : undefined}
+ className={
+ c.inSpan
+ ? "flex gap-3 bg-brand-soft px-3 py-1.5 text-foreground"
+ : "flex gap-3 px-3 py-1.5 text-muted-foreground"
+ }
+ >
+ <span className="w-14 shrink-0 font-mono text-xs leading-6 tabular-nums">{formatTimestamp(c.start)}</span>
+ <span className="leading-6">{c.text}</span>
+ </li>
+ ))}
+ </ol>
+ </section>
+ )}
+
+ {(r.originalUrl || r.corpusUrl) && (
+ <p className="flex flex-wrap gap-x-4 gap-y-1 text-sm">
+ {r.originalUrl && (
+ <ExternalLinkText href={r.originalUrl}>
+ {isSpan && view.start !== undefined ? `Original at ${formatTimestamp(view.start)}` : "Original post"}
+ </ExternalLinkText>
+ )}
+ {r.corpusUrl && (
+ <a href={r.corpusUrl} className={textLink}>
+ Open in the archive
+ </a>
+ )}
+ </p>
+ )}
+
+ {view.citedIn.length > 0 && (
+ <section aria-labelledby="cited-in-heading" className="flex flex-col gap-2 border-t border-border pt-5">
+ <h2 id="cited-in-heading" className="font-display text-xl font-semibold text-foreground">
+ Cited in
+ </h2>
+ <ul data-cited-in="" className="flex flex-col gap-2">
+ {view.citedIn.map((e) => (
+ <li
+ key={`${e.reportId}/${e.sectionId}/${e.claimId}/${e.citationId}`}
+ className="flex flex-col gap-1 rounded-lg border border-border bg-card p-3 text-sm"
+ >
+ <a href={e.href} className={`font-medium ${textLink}`}>
+ {e.reportTitle}
+ </a>
+ <div className="flex flex-wrap items-center gap-2 text-muted-foreground">
+ {e.verdict && e.verdictStyle && <VerdictChip verdict={e.verdict} style={e.verdictStyle} />}
+ {e.sectionTitle ? <span>{e.sectionTitle}</span> : !e.sectionId && <span>Summary</span>}
+ {(e.claimTitle ?? e.claimText) && (
+ <span className="text-foreground">— {e.claimTitle ?? e.claimText}</span>
+ )}
+ {e.number !== undefined && <span className="font-mono text-xs">[{e.number}]</span>}
+ </div>
+ </li>
+ ))}
+ </ul>
+ </section>
+ )}
+ </article>
+ );
+}
diff --git a/export/app/components/reports/ReportArticle.tsx b/export/app/components/reports/ReportArticle.tsx
@@ -0,0 +1,203 @@
+import { ArrowDownToLine } from "lucide-react";
+import { CitationCard } from "yt-dlp-transcript-common/components/citations/CitationCard";
+import { CitedMarkdown } from "yt-dlp-transcript-common/components/citations/CitedMarkdown";
+import { ReferenceList } from "yt-dlp-transcript-common/components/citations/ReferenceList";
+import { VerdictChip, VerdictTally } from "yt-dlp-transcript-common/components/report/VerdictChip";
+import {
+ orderedCitations,
+ verdictTally,
+ type CitationView,
+ type ClaimView,
+ type ReportPageView,
+ type SourceCitationView,
+} from "yt-dlp-transcript-common/lib/report/views";
+import { ExternalLinkText, Eyebrow, SourceBlock, dateLabel, textLink } from "./parts";
+
+// ONE REPORT, from its view (common/lib/report/views.ts): the header (title,
+// subtitle, dates, the document under review with its archive links), a
+// fact-check's tally, the summary, the sections and their claims — each claim
+// its verdict, the document's own sentence (its still), the findings with
+// their inline citations, and the evidence cards — then the numbered
+// reference list the inline markers jump to, and the citations as files.
+
+const proseClass = "text-[0.95rem] leading-relaxed text-foreground";
+
+function SourceSentence({ c }: { c: SourceCitationView }) {
+ return (
+ <figure data-source-sentence={c.id} className="flex flex-col gap-1.5">
+ {c.image ? (
+ // eslint-disable-next-line @next/next/no-img-element -- a published file of unknown size; the export serves images unoptimized
+ <img
+ src={c.image}
+ alt={c.quote}
+ loading="lazy"
+ className="w-full max-w-xl rounded-md border border-border bg-white object-contain object-left-top"
+ />
+ ) : (
+ <blockquote className="border-l-2 border-border pl-3 italic text-muted-foreground">
+ <q>{c.quote}</q>
+ </blockquote>
+ )}
+ <figcaption className="flex flex-wrap items-baseline gap-x-3 gap-y-1 text-xs text-muted-foreground">
+ <span>{c.source.title}</span>
+ {c.source.archives.map((a) => (
+ <ExternalLinkText key={a.url} href={a.url} title={a.context}>
+ {a.label}
+ </ExternalLinkText>
+ ))}
+ </figcaption>
+ </figure>
+ );
+}
+
+function Claim({
+ claim,
+ view,
+ subjectLabel,
+}: {
+ claim: ClaimView;
+ view: ReportPageView;
+ subjectLabel: string;
+}) {
+ const sentence = claim.sourceQuote ? view.citations[claim.sourceQuote] : undefined;
+ const evidence = claim.citations
+ .filter((id) => id !== claim.sourceQuote)
+ .map((id) => view.citations[id])
+ .filter((c): c is CitationView => !!c);
+ return (
+ <article
+ id={claim.id}
+ data-claim={claim.id}
+ className="flex scroll-mt-20 flex-col gap-3 rounded-lg border border-border bg-surface p-4 sm:p-5"
+ >
+ <div className="flex flex-wrap items-start gap-x-3 gap-y-2">
+ {claim.verdict && <VerdictChip verdict={claim.verdict} style={view.verdicts[claim.verdict]} className="mt-0.5" />}
+ <h3 className="min-w-0 flex-1 basis-60 font-display text-lg font-semibold leading-snug text-foreground">
+ <a href={`#${claim.id}`} className="hover:text-brand transition-colors">
+ {claim.title ?? claim.text}
+ </a>
+ </h3>
+ </div>
+ {claim.title && (
+ <p className="text-sm text-muted-foreground">
+ {subjectLabel}: <q className="text-foreground">{claim.text}</q>
+ </p>
+ )}
+ {sentence?.kind === "source" && <SourceSentence c={sentence} />}
+ {claim.findings && (
+ <CitedMarkdown citations={view.citations} className={proseClass}>
+ {claim.findings}
+ </CitedMarkdown>
+ )}
+ {evidence.length > 0 && (
+ <div className="flex flex-col gap-2">
+ <p className="font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground">Evidence</p>
+ <div className="grid gap-3 sm:grid-cols-2">
+ {evidence.map((c) => (
+ <CitationCard key={c.id} citation={c} />
+ ))}
+ </div>
+ </div>
+ )}
+ </article>
+ );
+}
+
+export default function ReportArticle({ view }: { view: ReportPageView }) {
+ const isFactcheck = view.kind === "factcheck";
+ const tally = isFactcheck ? verdictTally(view) : [];
+ const references = orderedCitations(view);
+ const subjectLabel = view.subject?.kind === "article" ? "The article says" : "The source says";
+ const dates = [
+ view.published ? `Published ${dateLabel(view.published)}` : null,
+ view.updated && view.updated !== view.published ? `Updated ${dateLabel(view.updated)}` : null,
+ ].filter(Boolean);
+ const claimCount = view.sections.reduce((n, s) => n + s.claims.length, 0);
+
+ return (
+ <article data-report={view.id} className="mx-auto flex w-full max-w-3xl flex-col gap-8">
+ <header className="flex flex-col gap-3 border-b border-border pb-6">
+ <Eyebrow>{isFactcheck ? "Fact-check" : "Report"}</Eyebrow>
+ <h1 className="font-display text-3xl font-semibold leading-tight tracking-tight text-foreground sm:text-4xl">
+ {view.title}
+ </h1>
+ {view.subtitle && <p className="text-lg text-muted-foreground">{view.subtitle}</p>}
+ {dates.length > 0 && <p className="font-mono text-xs text-muted-foreground">{dates.join(" · ")}</p>}
+ {view.subject && <SourceBlock source={view.subject} label="Under review" />}
+ {tally.length > 0 && (
+ <div className="flex flex-col gap-2">
+ <p className="font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
+ {claimCount} claim{claimCount === 1 ? "" : "s"} checked
+ </p>
+ <VerdictTally tally={tally} styles={view.verdicts} />
+ </div>
+ )}
+ </header>
+
+ {view.summary && (
+ <CitedMarkdown citations={view.citations} className={proseClass}>
+ {view.summary}
+ </CitedMarkdown>
+ )}
+
+ {view.sections.length > 1 && (
+ <nav aria-label="Sections" className="rounded-lg border border-border p-4 text-sm">
+ <ol className="flex list-decimal flex-col gap-1 pl-5 marker:text-muted-foreground">
+ {view.sections.map((s) => (
+ <li key={s.id}>
+ <a href={`#${s.id}`} className={textLink}>
+ {s.title}
+ </a>
+ {s.claims.length > 0 && (
+ <span className="ml-2 font-mono text-xs text-muted-foreground">
+ {s.claims.length} claim{s.claims.length === 1 ? "" : "s"}
+ </span>
+ )}
+ </li>
+ ))}
+ </ol>
+ </nav>
+ )}
+
+ {view.sections.map((s) => (
+ <section key={s.id} id={s.id} data-section={s.id} className="flex scroll-mt-20 flex-col gap-4">
+ <h2 className="font-display text-2xl font-semibold tracking-tight text-foreground">{s.title}</h2>
+ {s.body && (
+ <CitedMarkdown citations={view.citations} className={proseClass}>
+ {s.body}
+ </CitedMarkdown>
+ )}
+ {s.claims.map((c) => (
+ <Claim key={c.id} claim={c} view={view} subjectLabel={subjectLabel} />
+ ))}
+ </section>
+ ))}
+
+ {references.length > 0 && (
+ <section id="references" aria-labelledby="references-heading" className="flex scroll-mt-20 flex-col gap-2 border-t border-border pt-6">
+ <h2 id="references-heading" className="font-display text-2xl font-semibold tracking-tight text-foreground">
+ References
+ </h2>
+ <ReferenceList citations={references} />
+ </section>
+ )}
+
+ {(view.downloads?.json || view.downloads?.csv) && (
+ <p data-citation-downloads="" className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
+ <ArrowDownToLine className="size-4" aria-hidden />
+ Download citations:
+ {view.downloads.json && (
+ <a href={view.downloads.json} download className={textLink}>
+ JSON
+ </a>
+ )}
+ {view.downloads.csv && (
+ <a href={view.downloads.csv} download className={textLink}>
+ CSV
+ </a>
+ )}
+ </p>
+ )}
+ </article>
+ );
+}
diff --git a/export/app/components/reports/ReportIndex.tsx b/export/app/components/reports/ReportIndex.tsx
@@ -0,0 +1,61 @@
+import Link from "next/link";
+import { VerdictTally } from "yt-dlp-transcript-common/components/report/VerdictChip";
+import type { ReportIndexView } from "yt-dlp-transcript-common/lib/report/views";
+import { EmptyState, Eyebrow, dateLabel } from "./parts";
+
+// The report index: `/reports/`, and a cited site's home page. One card per
+// published report, in the site's order (site.json `reports`).
+
+const plural = (n: number, noun: string) => `${n.toLocaleString()} ${noun}${n === 1 ? "" : "s"}`;
+
+export default function ReportIndex({
+ index,
+ heading = "Reports",
+ tagline,
+}: {
+ index: ReportIndexView | null;
+ heading?: string;
+ tagline?: string;
+}) {
+ const reports = index?.reports ?? [];
+ if (reports.length === 0) {
+ return <EmptyState title="No reports">This site has not published a report yet.</EmptyState>;
+ }
+ return (
+ <div className="mx-auto flex w-full max-w-3xl flex-col gap-6">
+ <header className="flex flex-col gap-1">
+ <h1 className="font-display text-3xl font-semibold tracking-tight text-foreground">{heading}</h1>
+ {tagline && <p className="text-sm text-muted-foreground">{tagline}</p>}
+ </header>
+ <ul data-report-index="" className="flex flex-col gap-4">
+ {reports.map((r) => {
+ const dates = [
+ r.published ? `Published ${dateLabel(r.published)}` : null,
+ r.updated && r.updated !== r.published ? `updated ${dateLabel(r.updated)}` : null,
+ ]
+ .filter(Boolean)
+ .join(" · ");
+ return (
+ <li key={r.id} data-report-entry={r.id}>
+ <article className="flex flex-col gap-2 rounded-lg border border-border bg-card p-4 sm:p-5">
+ <Eyebrow>{r.kind === "factcheck" ? "Fact-check" : "Report"}</Eyebrow>
+ <h2 className="font-display text-xl font-semibold leading-snug text-foreground">
+ <Link href={r.href} className="hover:text-brand transition-colors">
+ {r.title}
+ </Link>
+ </h2>
+ {r.subtitle && <p className="text-sm text-muted-foreground">{r.subtitle}</p>}
+ {r.tally && r.verdicts && <VerdictTally tally={r.tally} styles={r.verdicts} className="mt-1" />}
+ <p className="font-mono text-xs text-muted-foreground">
+ {[dates, r.claimCount > 0 ? plural(r.claimCount, "claim") : null, plural(r.citationCount, "citation")]
+ .filter(Boolean)
+ .join(" · ")}
+ </p>
+ </article>
+ </li>
+ );
+ })}
+ </ul>
+ </div>
+ );
+}
diff --git a/export/app/components/reports/parts.tsx b/export/app/components/reports/parts.tsx
@@ -0,0 +1,81 @@
+import Link from "next/link";
+import { Archive, ExternalLink } from "lucide-react";
+import type { SourceView } from "yt-dlp-transcript-common/lib/report/views";
+
+// Small pieces the report, index and moment pages share.
+
+// `2026-10-04T12:00:00Z` → `2026-10-04`; a partial date (`2026-10`) as given.
+export function dateLabel(v: string | undefined): string | undefined {
+ if (!v) return undefined;
+ return /^\d{4}-\d{2}-\d{2}T/.test(v) ? v.slice(0, 10) : v;
+}
+
+export function Eyebrow({ children }: { children: React.ReactNode }) {
+ return <p className="font-mono text-xs uppercase tracking-[0.18em] text-brand">{children}</p>;
+}
+
+export const textLink =
+ "text-brand underline decoration-brand/40 underline-offset-2 hover:decoration-brand";
+
+export function ExternalLinkText({ href, children, title }: { href: string; children: React.ReactNode; title?: string }) {
+ return (
+ <a href={href} target="_blank" rel="noopener noreferrer" title={title} className={`inline-flex items-center gap-1 ${textLink}`}>
+ {children}
+ <ExternalLink className="size-3 shrink-0" aria-hidden />
+ </a>
+ );
+}
+
+// A document under review: its title (linked), byline, note, and its archive
+// links in the context the document gave them.
+export function SourceBlock({ source, label }: { source: SourceView; label: string }) {
+ const byline = [source.publisher, source.author, dateLabel(source.date)].filter(Boolean).join(" · ");
+ return (
+ <div data-subject-source={source.id} className="flex flex-col gap-1.5 rounded-lg border border-border bg-card p-4 text-sm">
+ <p className="font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground">{label}</p>
+ <p className="font-medium leading-snug text-foreground">
+ {source.url ? <ExternalLinkText href={source.url}>{source.title}</ExternalLinkText> : source.title}
+ </p>
+ {byline && <p className="font-mono text-xs text-muted-foreground">{byline}</p>}
+ {source.note && <p className="text-xs text-muted-foreground">{source.note}</p>}
+ {source.archives.length > 0 && (
+ <ul className="mt-1 flex flex-col gap-1 text-xs">
+ {source.archives.map((a) => (
+ <li key={a.url} className="flex flex-wrap items-baseline gap-x-2">
+ <ExternalLinkText href={a.url}>
+ <Archive className="size-3 shrink-0" aria-hidden />
+ {a.label}
+ </ExternalLinkText>
+ {a.context && <span className="text-muted-foreground">— {a.context}</span>}
+ </li>
+ ))}
+ </ul>
+ )}
+ </div>
+ );
+}
+
+export function EmptyState({ title, children }: { title: string; children: React.ReactNode }) {
+ return (
+ <div data-reports-empty="" className="mx-auto flex max-w-2xl flex-col gap-3">
+ <h1 className="font-display text-2xl font-semibold text-foreground">{title}</h1>
+ <p className="text-sm text-muted-foreground">{children}</p>
+ </div>
+ );
+}
+
+// What a corpus-shaped route (search's chat, downloads, duplicates, offline)
+// renders on a CITED site, which publishes only its reports and their moments.
+// The route still exists in the static build (every route does); it shows
+// nothing of the corpus and points at the reports.
+export function NotOnCitedSite({ title }: { title: string }) {
+ return (
+ <EmptyState title={title}>
+ This site publishes its reports only.{" "}
+ <Link href="/reports/" className={textLink}>
+ Read the reports
+ </Link>
+ .
+ </EmptyState>
+ );
+}
diff --git a/export/app/downloads/page.tsx b/export/app/downloads/page.tsx
@@ -3,6 +3,8 @@ import type { ArchiveManifestEntry } from "yt-dlp-transcript-common/lib/archiveO
import { ArrowDownToLine, FileText, MessagesSquare } from "lucide-react";
import { currentSite } from "../lib/site";
import { readArchiveManifest } from "../lib/archives";
+import { isCitedSite } from "yt-dlp-transcript-common/lib/siteSchema";
+import { NotOnCitedSite } from "../components/reports/parts";
export const metadata: Metadata = { title: "Downloads" };
@@ -176,6 +178,7 @@ function ChannelRecord({
export default function DownloadsPage() {
const site = currentSite();
+ if (isCitedSite(site)) return <NotOnCitedSite title="Downloads" />;
const manifest = readArchiveManifest();
const entries = manifest?.entries ?? [];
diff --git a/export/app/duplicates/page.tsx b/export/app/duplicates/page.tsx
@@ -4,10 +4,13 @@ import TranscriptModal from "yt-dlp-transcript-common/components/TranscriptModal
import PostModal from "yt-dlp-transcript-common/components/PostModal";
import { currentSite } from "../lib/site";
import { DuplicatesClient } from "./DuplicatesClient";
+import { isCitedSite } from "yt-dlp-transcript-common/lib/siteSchema";
+import { NotOnCitedSite } from "../components/reports/parts";
export const metadata: Metadata = { title: "Duplicates" };
export default function DuplicatesPage() {
+ if (isCitedSite(currentSite())) return <NotOnCitedSite title="Duplicates" />;
return (
<PlayerProvider
features={{ transcriptDownloads: currentSite().transcriptDownloads !== false }}
diff --git a/export/app/lib/nav.ts b/export/app/lib/nav.ts
@@ -0,0 +1,30 @@
+import { AI_DOC_URL } from "yt-dlp-transcript-common/lib/project";
+
+export type NavLink = { href: string; label: string; external?: boolean };
+
+// The header's inline nav (and the mobile sheet's first links), from what this
+// build has. Ask AI is new: the chat was only reachable from the workspace
+// control or from Use with AI, which is not where anyone looks for it. Use
+// with AI is the homepage's AI and MCP doc (release 16 slice DX): off this
+// site, so a plain <a> in the same tab, not a client navigation. Reports
+// shows when this build has a report (public/reports/index.json).
+//
+// A CITED site (site.json `publish: "cited"`) publishes only its reports and
+// their moments: no search, no chat, no downloads or duplicates, and nothing
+// for an AI to read — its nav is Reports alone.
+export function headerNavLinks(has: {
+ cited: boolean;
+ reports: boolean;
+ duplicates: boolean;
+ downloads: boolean;
+}): NavLink[] {
+ if (has.cited) return [{ href: "/reports/", label: "Reports" }];
+ return [
+ { href: "/", label: "Search" },
+ { href: "/ask/", label: "Ask AI" },
+ ...(has.reports ? [{ href: "/reports/", label: "Reports" }] : []),
+ ...(has.duplicates ? [{ href: "/duplicates", label: "Duplicates" }] : []),
+ ...(has.downloads ? [{ href: "/downloads", label: "Downloads" }] : []),
+ { href: AI_DOC_URL, label: "Use with AI", external: true },
+ ];
+}
diff --git a/export/app/lib/reports.test.ts b/export/app/lib/reports.test.ts
@@ -0,0 +1,172 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import * as React from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+
+// The report routes, rendered from the fixture report site's view JSON
+// (export/fixtures/report-site): the never-empty params, the report page, a
+// moment page per kind, the index, a cited site's home, and the header's nav.
+//
+// getPaths() is cached on first use, so the environment is set before any
+// module that reads it is imported — every import below is dynamic.
+
+(globalThis as { React?: typeof React }).React = React;
+
+const root = fs.mkdtempSync(path.join(os.tmpdir(), "export-reports-"));
+const publicDir = path.join(root, "public");
+const sitesDir = path.join(root, "transcripts", "sites");
+fs.mkdirSync(publicDir, { recursive: true });
+fs.mkdirSync(path.join(sitesDir, "demo-cited"), { recursive: true });
+fs.writeFileSync(
+ path.join(sitesDir, "demo-cited", "site.json"),
+ JSON.stringify({ siteTitle: "Demo Reports", publish: "cited", reports: ["demo-factcheck"] }),
+);
+process.env.EXPORT_PUBLIC_DIR = publicDir;
+process.env.TRANSCRIPTS_DIR = path.join(root, "transcripts");
+process.env.SITES_DIR = sitesDir;
+process.env.SITE_ID = "demo-cited";
+delete process.env.INSTANCE_MODE;
+
+const fixture = await import("../../fixtures/report-site/fixture");
+const reportPage = await import("../reports/[reportId]/page");
+const momentPage = await import("../m/[...moment]/page");
+const reportsIndexPage = await import("../reports/page");
+const home = await import("../(workspace)/page");
+const { headerNavLinks } = await import("./nav");
+
+const render = (el: React.ReactElement) => renderToStaticMarkup(el);
+
+test("with no reports, each dynamic route builds its one placeholder page, which says so", async () => {
+ assert.deepEqual(reportPage.generateStaticParams(), [{ reportId: "_none" }]);
+ assert.deepEqual(momentPage.generateStaticParams(), [{ moment: ["_none"] }]);
+ const r = render(await reportPage.default({ params: Promise.resolve({ reportId: "_none" }) }));
+ assert.match(r, /No reports/);
+ const m = render(await momentPage.default({ params: Promise.resolve({ moment: ["_none"] }) }));
+ assert.match(m, /No moments/);
+ assert.match(render(reportsIndexPage.default()), /No reports/);
+});
+
+test("the fixture's view JSON is exactly what the builder makes of its report", () => {
+ for (const [urlPath, value] of Object.entries(fixture.buildFixtureFiles())) {
+ const onDisk = fs.readFileSync(fixture.fixtureFilePath(urlPath), "utf8");
+ assert.equal(onDisk, fixture.serializeFixture(value), `${urlPath} is stale: run fixtures/report-site/write.ts`);
+ }
+});
+
+test("the fixture report cites all five kinds", () => {
+ const view = fixture.buildFixtureReportView();
+ assert.deepEqual(
+ [...new Set(Object.values(view.citations).map((c) => c.kind))].sort(),
+ ["audio", "page", "post", "source", "video"],
+ );
+});
+
+// From here on the public dir holds the fixture.
+test("the fixture public dir is in place", () => {
+ fs.cpSync(fixture.FIXTURE_PUBLIC_DIR, publicDir, { recursive: true });
+});
+
+test("params: every report and every moment, as the index files list them", () => {
+ assert.deepEqual(reportPage.generateStaticParams(), [{ reportId: "demo-factcheck" }]);
+ assert.deepEqual(momentPage.generateStaticParams(), [
+ { moment: ["demo-channel", "abc123", "3126.00-3151.00"] },
+ { moment: ["demo-podcast", "ep-042", "610.50-628.00"] },
+ { moment: ["demo-social", "1234567890"] },
+ ]);
+});
+
+test("the report page: header, tally, sections and claims, inline cites, references, downloads", async () => {
+ const params = Promise.resolve({ reportId: "demo-factcheck" });
+ const html = render(await reportPage.default({ params }));
+ assert.match(html, /<h1[^>]*>Checking an example article<\/h1>/);
+ assert.ok(html.includes("Published 2026-10-01 · Updated 2026-10-04"));
+ assert.match(html, /data-subject-source="s0"/);
+ assert.ok(html.includes("as published on the day"));
+ assert.match(html, /data-verdict-tally=""/);
+ for (const v of ["CORROBORATED", "PARTLY", "CONTRADICTED", "UNTESTABLE"]) assert.match(html, new RegExp(`data-verdict="${v}"`));
+ assert.ok(html.includes("Read in the edition published on the day"), "the source's note, under its byline");
+ assert.match(html, /<section id="bridge"/);
+ // a claim with a title: the title heads it, the claim's text follows as what the article says
+ assert.match(html, /<article id="claim-1"[^]*?<h3[^>]*><a href="#claim-1"[^>]*>“He opened it himself”<\/a><\/h3>[^]*?The article says: <q[^>]*>He opened the bridge himself in 2018.<\/q>/);
+ // a claim without one is headed by its text
+ assert.match(html, /<h3[^>]*><a href="#claim-2"[^>]*>He went back to the bridge every year.<\/a>/);
+ assert.match(html, /<article id="claim-1"/);
+ // the source sentence's still, under the claim
+ assert.match(html, /data-source-sentence="a01"[^]*?<img src="\/reports\/demo-factcheck\/stills\/a01.png"/);
+ // inline citations carry their report numbers and jump to the reference list
+ assert.match(html, /data-inline-cite="c01"[^]*?href="#c-c01" data-cite-number="1"/);
+ assert.match(html, /data-inline-cite="w01"[^]*?data-cite-number="4"/);
+ // every numbered citation has its reference entry, in number order
+ const refs = [...html.matchAll(/<li id="c-([^"]+)" value="(\d+)"/g)].map((m) => `${m[2]}:${m[1]}`);
+ assert.deepEqual(refs, ["1:c01", "2:au1", "3:a01", "4:w01", "5:a02", "6:p01"]);
+ assert.match(html, /href="\/reports\/demo-factcheck\/citations.json" download=""/);
+ assert.match(html, /href="\/reports\/demo-factcheck\/citations.csv" download=""/);
+ const meta = await reportPage.generateMetadata({ params });
+ assert.equal(meta.title, "Checking an example article");
+});
+
+test("a video moment: the clip, quote, verification, cue lines, original link, cited in", async () => {
+ const html = render(
+ await momentPage.default({ params: Promise.resolve({ moment: ["demo-channel", "abc123", "3126.00-3151.00"] }) }),
+ );
+ assert.match(html, /<video data-evidence-clip=""[^>]*poster="\/reports\/demo-factcheck\/stills\/poster.png"[^>]*src="\/media\/clips\/demo-channel\/abc123\/3126.00-3151.00.mp4"/);
+ assert.ok(html.includes("52:01–52:36"), "the padded clip's own span is stated");
+ assert.ok(html.includes("The bridge opened in the spring of twenty nineteen, I was there for it."));
+ assert.match(html, /data-quote-score="97"/);
+ assert.equal([...html.matchAll(/data-in-span=""/g)].length, 2);
+ assert.match(html, /href="https:\/\/media.example.org\/demo-channel\/abc123\?t=3126"[^>]*>Original at 52:06/);
+ assert.match(html, /data-cited-in=""/);
+ assert.match(html, /href="\/reports\/demo-factcheck\/"[^>]*>Checking an example article/);
+ assert.match(html, /href="\/reports\/demo-factcheck\/#claim-1"/);
+ assert.ok(html.includes("— “He opened it himself”"), "a cited-in entry names the claim by its title");
+ assert.match(html, /<span>Summary<\/span>/, "a summary citation is named as the summary");
+ assert.match(html, /href="\/reports\/demo-factcheck\/#claim-5"/);
+});
+
+test("an audio moment plays audio; a post moment shows its text and author", async () => {
+ const audio = render(
+ await momentPage.default({ params: Promise.resolve({ moment: ["demo-podcast", "ep-042", "610.50-628.00"] }) }),
+ );
+ assert.match(audio, /<audio data-evidence-clip=""/);
+ assert.ok(audio.includes("The guest"));
+ const post = render(await momentPage.default({ params: Promise.resolve({ moment: ["demo-social", "1234567890"] }) }));
+ assert.match(post, /data-moment-kind="post"/);
+ assert.ok(post.includes("@demo_account"));
+ assert.ok(post.includes("Posted to settle it."));
+ assert.ok(!post.includes("<video"));
+});
+
+test("an unknown moment or report under a listed route renders the empty state, never throws", async () => {
+ assert.match(render(await reportPage.default({ params: Promise.resolve({ reportId: "nope" }) })), /No reports/);
+ assert.match(render(await momentPage.default({ params: Promise.resolve({ moment: ["..", "x"] }) })), /No moments/);
+});
+
+test("the index lists the report with its tally", () => {
+ const html = render(reportsIndexPage.default());
+ assert.match(html, /data-report-entry="demo-factcheck"/);
+ assert.ok(html.includes("5 claims · 6 citations"));
+});
+
+test("a cited site's home is the report index and never counts transcripts", async () => {
+ // There is no summaries manifest here: countTranscripts() would throw.
+ const html = render(await home.default());
+ assert.match(html, /<h1[^>]*>Demo Reports<\/h1>/);
+ assert.match(html, /data-report-entry="demo-factcheck"/);
+});
+
+test("the header nav: Reports when the build has one; a cited site has Reports alone", () => {
+ const full = headerNavLinks({ cited: false, reports: true, duplicates: false, downloads: true });
+ assert.deepEqual(
+ full.map((l) => l.label),
+ ["Search", "Ask AI", "Reports", "Downloads", "Use with AI"],
+ );
+ assert.ok(!headerNavLinks({ cited: false, reports: false, duplicates: false, downloads: false }).some((l) => l.label === "Reports"));
+ assert.deepEqual(headerNavLinks({ cited: true, reports: true, duplicates: true, downloads: true }), [
+ { href: "/reports/", label: "Reports" },
+ ]);
+});
+
+test.after(() => fs.rmSync(root, { recursive: true, force: true }));
diff --git a/export/app/lib/reports.ts b/export/app/lib/reports.ts
@@ -0,0 +1,72 @@
+import fs from "node:fs";
+import path from "node:path";
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import { parseMomentKey } from "yt-dlp-transcript-common/lib/citations/moments";
+import { isReportId } from "yt-dlp-transcript-common/lib/report/schema";
+import {
+ MOMENT_INDEX_FORMAT,
+ MOMENT_PAGE_FORMAT,
+ MOMENTS_INDEX_PATH,
+ REPORT_INDEX_FORMAT,
+ REPORT_PAGE_FORMAT,
+ REPORTS_INDEX_PATH,
+ momentViewPath,
+ reportViewPath,
+ type MomentIndexView,
+ type MomentPageView,
+ type ReportIndexView,
+ type ReportPageView,
+} from "yt-dlp-transcript-common/lib/report/views";
+
+// The report and moment views compose's reports stage writes into public/
+// (common/lib/report/views.ts names every file), read at build time like
+// lib/archives.ts reads the archive manifest. Every reader is tolerant: a file
+// that is absent, unreadable or of another format reads as "none", so a site
+// without reports builds exactly as before and a cited site with none renders
+// its empty state. Read synchronously — a static export, evaluated once per
+// build.
+
+function readView<T extends { format: string }>(urlPath: string, format: T["format"]): T | null {
+ try {
+ const file = path.join(getPaths().exportPublicDir, ...urlPath.split("/").filter(Boolean));
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8")) as T;
+ return parsed && parsed.format === format ? parsed : null;
+ } catch {
+ return null;
+ }
+}
+
+export function readReportIndex(): ReportIndexView | null {
+ const v = readView<ReportIndexView>(REPORTS_INDEX_PATH, REPORT_INDEX_FORMAT);
+ return v && Array.isArray(v.reports) ? v : null;
+}
+
+// Whether this build has a report to link to: drives the header's Reports link.
+export function hasReports(): boolean {
+ return (readReportIndex()?.reports.length ?? 0) > 0;
+}
+
+// The report ids to build pages for, in index order (only well-formed ids:
+// each is a path segment).
+export function reportIds(): string[] {
+ return (readReportIndex()?.reports ?? []).map((r) => r.id).filter(isReportId);
+}
+
+export function readReportView(reportId: string): ReportPageView | null {
+ if (!isReportId(reportId)) return null;
+ const v = readView<ReportPageView>(reportViewPath(reportId), REPORT_PAGE_FORMAT);
+ return v && v.id === reportId && Array.isArray(v.sections) ? v : null;
+}
+
+// The moment keys to build pages for (only keys exactly as momentKey spells
+// them: each segment is a path segment of a page written to disk).
+export function momentKeys(): string[] {
+ const v = readView<MomentIndexView>(MOMENTS_INDEX_PATH, MOMENT_INDEX_FORMAT);
+ return (Array.isArray(v?.moments) ? v.moments : []).filter((k) => parseMomentKey(k) !== null);
+}
+
+export function readMomentView(key: string): MomentPageView | null {
+ if (!parseMomentKey(key)) return null;
+ const v = readView<MomentPageView>(momentViewPath(key), MOMENT_PAGE_FORMAT);
+ return v && v.key === key ? v : null;
+}
diff --git a/export/app/m/[...moment]/page.tsx b/export/app/m/[...moment]/page.tsx
@@ -0,0 +1,40 @@
+import type { Metadata } from "next";
+import { MOMENT_PLACEHOLDER_KEY } from "yt-dlp-transcript-common/lib/report/views";
+import MomentArticle from "../../components/reports/MomentArticle";
+import { EmptyState } from "../../components/reports/parts";
+import { momentKeys, readMomentView } from "../../lib/reports";
+
+// /m/<channel>/<id>/<start>-<end>/ (a span) and /m/<channel>/<id>/ (a post):
+// one cited moment, from /m/<key>/moment.json. The key's segments are the
+// route's (common/lib/citations/moments.ts). Static: every moment the site's
+// reports cite is listed, nothing else resolves; a site with none builds the
+// one placeholder page.
+export const dynamicParams = false;
+
+export function generateStaticParams(): { moment: string[] }[] {
+ const keys = momentKeys();
+ return (keys.length > 0 ? keys : [MOMENT_PLACEHOLDER_KEY]).map((k) => ({ moment: k.split("/") }));
+}
+
+type Params = { params: Promise<{ moment: string[] }> };
+
+// Next hands a catch-all's segments back URL-encoded on some paths; a key's
+// segments are plain ASCII, so decoding is lossless.
+async function keyOf(params: Params["params"]): Promise<string> {
+ return (await params).moment.map((s) => decodeURIComponent(s)).join("/");
+}
+
+export async function generateMetadata({ params }: Params): Promise<Metadata> {
+ const view = readMomentView(await keyOf(params));
+ if (!view) return { title: "No moments" };
+ const who = view.record.title ?? view.record.channelTitle ?? view.record.channel;
+ return { title: `“${view.quote.length > 60 ? `${view.quote.slice(0, 57)}…` : view.quote}” — ${who}` };
+}
+
+export default async function MomentPage({ params }: Params) {
+ const view = readMomentView(await keyOf(params));
+ if (!view) {
+ return <EmptyState title="No moments">No report on this site cites a moment yet.</EmptyState>;
+ }
+ return <MomentArticle view={view} />;
+}
diff --git a/export/app/offline/page.tsx b/export/app/offline/page.tsx
@@ -5,6 +5,8 @@ import { getSettings } from "yt-dlp-transcript-common/lib/settings";
import { postsVisibleTo } from "yt-dlp-transcript-common/lib/postsVisibility";
import { currentSite } from "../lib/site";
import { OfflineManager, type OfflineChannel } from "../components/OfflineManager";
+import { isCitedSite } from "yt-dlp-transcript-common/lib/siteSchema";
+import { NotOnCitedSite } from "../components/reports/parts";
export const metadata: Metadata = {
title: "Offline",
@@ -16,6 +18,7 @@ export const metadata: Metadata = {
// transcript shards through the service worker.
export default async function OfflinePage() {
const site = currentSite();
+ if (isCitedSite(site)) return <NotOnCitedSite title="Offline" />;
const paths = getPaths();
const settings = getSettings();
// The members this build publishes: an X channel is left out of a public
diff --git a/export/app/reports/[reportId]/page.tsx b/export/app/reports/[reportId]/page.tsx
@@ -0,0 +1,36 @@
+import type { Metadata } from "next";
+import { REPORT_PLACEHOLDER_ID } from "yt-dlp-transcript-common/lib/report/views";
+import ReportArticle from "../../components/reports/ReportArticle";
+import { EmptyState } from "../../components/reports/parts";
+import { readReportView, reportIds } from "../../lib/reports";
+
+// /reports/<reportId>/: one report, from /reports/<reportId>/page.json.
+// Static: every published report is listed, nothing else resolves. A site with
+// none builds the one placeholder page (a static export refuses an empty
+// generateStaticParams), which says so.
+export const dynamicParams = false;
+
+export function generateStaticParams(): { reportId: string }[] {
+ const ids = reportIds();
+ return (ids.length > 0 ? ids : [REPORT_PLACEHOLDER_ID]).map((reportId) => ({ reportId }));
+}
+
+type Params = { params: Promise<{ reportId: string }> };
+
+export async function generateMetadata({ params }: Params): Promise<Metadata> {
+ const view = readReportView((await params).reportId);
+ if (!view) return { title: "No reports" };
+ return { title: view.title, ...(view.subtitle ? { description: view.subtitle } : {}) };
+}
+
+export default async function ReportPage({ params }: Params) {
+ const view = readReportView((await params).reportId);
+ if (!view) {
+ return (
+ <EmptyState title="No reports">
+ This site has not published a report yet.
+ </EmptyState>
+ );
+ }
+ return <ReportArticle view={view} />;
+}
diff --git a/export/app/reports/page.tsx b/export/app/reports/page.tsx
@@ -0,0 +1,11 @@
+import type { Metadata } from "next";
+import ReportIndex from "../components/reports/ReportIndex";
+import { readReportIndex } from "../lib/reports";
+
+export const metadata: Metadata = { title: "Reports" };
+
+// The report index, from /reports/index.json (common/lib/report/views.ts). A
+// cited site renders the same index as its home page.
+export default function ReportsPage() {
+ return <ReportIndex index={readReportIndex()} />;
+}
diff --git a/export/fixtures/report-site/fixture.ts b/export/fixtures/report-site/fixture.ts
@@ -0,0 +1,207 @@
+// A fixture report site: one fact-check report citing all five citation kinds
+// (video, audio, post, source, page), and the views compose's reports stage
+// would write for it (common/lib/report/views.ts) — the report index, the
+// report's page view, the moment index and one moment view per cited moment.
+//
+// The views are BUILT from source/demo-factcheck/report.json by the same
+// builder compose uses, with a fixed resolver standing in for the corpus;
+// `write.ts` writes them under public/, and fixture.test.ts holds the written
+// files equal to a fresh build, so the JSON can never drift from the builder.
+//
+// Everything here is neutral demo data.
+
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { buildCitedIn } from "yt-dlp-transcript-common/lib/report/citedIn";
+import { parseReport } from "yt-dlp-transcript-common/lib/report/validate";
+import type { Report } from "yt-dlp-transcript-common/lib/report/schema";
+import { momentKeyOf, parseMomentKey, type SpanMoment } from "yt-dlp-transcript-common/lib/citations/moments";
+import {
+ MOMENT_INDEX_FORMAT,
+ MOMENT_PAGE_FORMAT,
+ MOMENTS_INDEX_PATH,
+ REPORT_INDEX_FORMAT,
+ REPORT_VIEWS_VERSION,
+ REPORTS_INDEX_PATH,
+ buildReportPageView,
+ citedInViews,
+ evidenceClipPath,
+ momentViewPath,
+ reportCitationsDownloadPath,
+ reportIndexEntry,
+ reportViewPath,
+ type MomentPageView,
+ type RecordView,
+ type ReportIndexView,
+ type ReportPageView,
+} from "yt-dlp-transcript-common/lib/report/views";
+
+export const FIXTURE_DIR = path.dirname(fileURLToPath(import.meta.url));
+export const FIXTURE_PUBLIC_DIR = path.join(FIXTURE_DIR, "public");
+export const FIXTURE_REPORT_ID = "demo-factcheck";
+
+const RECORDS: Record<string, RecordView> = {
+ "demo-channel/abc123": {
+ channel: "demo-channel",
+ channelTitle: "Demo Channel",
+ id: "abc123",
+ title: "Demo stream: the bridge, the move, and the mail",
+ date: "2026-01-10",
+ platform: "youtube",
+ },
+ "demo-podcast/ep-042": {
+ channel: "demo-podcast",
+ channelTitle: "Demo Podcast",
+ id: "ep-042",
+ title: "Episode 42: a guest looks back",
+ date: "2026-02-02",
+ },
+ "demo-social/1234567890": {
+ channel: "demo-social",
+ channelTitle: "Demo Social",
+ id: "1234567890",
+ date: "2025-03-14",
+ platform: "x",
+ },
+};
+
+const POSTER = "/reports/demo-factcheck/stills/poster.png";
+
+export function readFixtureReport(): Report {
+ const raw = JSON.parse(
+ fs.readFileSync(path.join(FIXTURE_DIR, "source", FIXTURE_REPORT_ID, "report.json"), "utf8"),
+ );
+ const parsed = parseReport(raw);
+ if (!parsed.ok || parsed.problems.length > 0) {
+ throw new Error(`fixture report is not sound: ${JSON.stringify(parsed.problems)}`);
+ }
+ return parsed.value;
+}
+
+function recordFor(channel: string, id: string, seconds?: number): RecordView {
+ const r = RECORDS[`${channel}/${id}`];
+ if (!r) throw new Error(`fixture: no record ${channel}/${id}`);
+ const base = `https://media.example.org/${channel}/${id}`;
+ return { ...r, originalUrl: seconds === undefined ? base : `${base}?t=${Math.floor(seconds)}` };
+}
+
+export function buildFixtureReportView(report = readFixtureReport()): ReportPageView {
+ return buildReportPageView(report, {
+ record: (c) => recordFor(c.channel, c.id, c.kind === "post" ? undefined : c.start),
+ poster: (c) => (c.kind === "video" ? POSTER : undefined),
+ post: (c) => ({
+ author: "@demo_account",
+ text: `${c.quote}\n\nPosted to settle it.`,
+ }),
+ downloads: {
+ json: reportCitationsDownloadPath(report.id, "json"),
+ csv: reportCitationsDownloadPath(report.id, "csv"),
+ },
+ });
+}
+
+// The cue lines around each fixture span: two lines either side.
+const CUES: Record<string, { start: number; end: number; text: string }[]> = {
+ "demo-channel/abc123": [
+ { start: 3110, end: 3118, text: "Okay so somebody asked about the bridge again." },
+ { start: 3118, end: 3126, text: "Let me be really clear about this one." },
+ { start: 3126, end: 3138, text: "The bridge opened in the spring of twenty nineteen," },
+ { start: 3138, end: 3151, text: "I was there for it." },
+ { start: 3151, end: 3160, text: "Anyway, back to the mail." },
+ { start: 3160, end: 3170, text: "Next letter is from a long-time viewer." },
+ ],
+ "demo-podcast/ep-042": [
+ { start: 600, end: 610.5, text: "So when did you first go back there?" },
+ { start: 610.5, end: 628, text: "Like I said before, spring twenty nineteen." },
+ { start: 628, end: 640, text: "And I have been back once since." },
+ ],
+};
+
+export function buildFixtureMomentViews(
+ report = readFixtureReport(),
+ view = buildFixtureReportView(report),
+): MomentPageView[] {
+ const citedIn = buildCitedIn([report]);
+ const out: MomentPageView[] = [];
+ const seen = new Set<string>();
+ for (const [id, c] of Object.entries(report.citations ?? {})) {
+ const key = momentKeyOf(c);
+ if (!key || seen.has(key) || !view.citations[id]) continue;
+ seen.add(key);
+ const m = parseMomentKey(key)!;
+ const entries = citedInViews(citedIn[key] ?? [], [view]);
+ if (c.kind === "video" || c.kind === "audio") {
+ const span = m as SpanMoment;
+ const before = c.pad?.before ?? 0;
+ const after = c.pad?.after ?? 0;
+ out.push({
+ format: MOMENT_PAGE_FORMAT,
+ version: REPORT_VIEWS_VERSION,
+ key,
+ kind: c.kind,
+ record: recordFor(c.channel, c.id, span.start),
+ quote: c.quote,
+ ...(c.speaker ? { speaker: c.speaker } : {}),
+ ...(c.verification ? { verification: c.verification } : {}),
+ start: span.start,
+ end: span.end,
+ clip: {
+ src: evidenceClipPath(span),
+ start: Math.max(0, span.start - before),
+ end: span.end + after,
+ ...(c.kind === "video" ? { poster: POSTER } : {}),
+ },
+ cues: (CUES[`${c.channel}/${c.id}`] ?? []).map((q) => ({
+ ...q,
+ inSpan: q.end > span.start && q.start < span.end,
+ })),
+ citedIn: entries,
+ });
+ } else if (c.kind === "post") {
+ out.push({
+ format: MOMENT_PAGE_FORMAT,
+ version: REPORT_VIEWS_VERSION,
+ key,
+ kind: "post",
+ record: recordFor(c.channel, c.id),
+ quote: c.quote,
+ ...(c.date ? { date: c.date } : {}),
+ post: { author: "@demo_account", text: `${c.quote}\n\nPosted to settle it.` },
+ citedIn: entries,
+ });
+ }
+ }
+ return out;
+}
+
+// Every file of the fixture's public dir, by site-root path, as JSON values.
+export function buildFixtureFiles(): Record<string, unknown> {
+ const report = readFixtureReport();
+ const view = buildFixtureReportView(report);
+ const moments = buildFixtureMomentViews(report, view);
+ const index: ReportIndexView = {
+ format: REPORT_INDEX_FORMAT,
+ version: REPORT_VIEWS_VERSION,
+ reports: [reportIndexEntry(view)],
+ };
+ const files: Record<string, unknown> = {
+ [REPORTS_INDEX_PATH]: index,
+ [reportViewPath(view.id)]: view,
+ [MOMENTS_INDEX_PATH]: {
+ format: MOMENT_INDEX_FORMAT,
+ version: REPORT_VIEWS_VERSION,
+ moments: moments.map((m) => m.key),
+ },
+ };
+ for (const m of moments) files[momentViewPath(m.key)] = m;
+ return files;
+}
+
+export function fixtureFilePath(urlPath: string): string {
+ return path.join(FIXTURE_PUBLIC_DIR, ...urlPath.split("/").filter(Boolean));
+}
+
+export function serializeFixture(value: unknown): string {
+ return `${JSON.stringify(value, null, 2)}\n`;
+}
diff --git a/export/fixtures/report-site/public/m/demo-channel/abc123/3126.00-3151.00/moment.json b/export/fixtures/report-site/public/m/demo-channel/abc123/3126.00-3151.00/moment.json
@@ -0,0 +1,112 @@
+{
+ "format": "archilyzer-moment-page",
+ "version": 1,
+ "key": "demo-channel/abc123/3126.00-3151.00",
+ "kind": "video",
+ "record": {
+ "channel": "demo-channel",
+ "channelTitle": "Demo Channel",
+ "id": "abc123",
+ "title": "Demo stream: the bridge, the move, and the mail",
+ "date": "2026-01-10",
+ "platform": "youtube",
+ "originalUrl": "https://media.example.org/demo-channel/abc123?t=3126"
+ },
+ "quote": "The bridge opened in the spring of twenty nineteen, I was there for it.",
+ "verification": {
+ "quoteScore": 0.97,
+ "quoteCheckedAt": "2026-10-04T12:00:00Z",
+ "voiceChecked": true,
+ "method": "cue-window v1"
+ },
+ "start": 3126,
+ "end": 3151,
+ "clip": {
+ "src": "/media/clips/demo-channel/abc123/3126.00-3151.00.mp4",
+ "start": 3121,
+ "end": 3156,
+ "poster": "/reports/demo-factcheck/stills/poster.png"
+ },
+ "cues": [
+ {
+ "start": 3110,
+ "end": 3118,
+ "text": "Okay so somebody asked about the bridge again.",
+ "inSpan": false
+ },
+ {
+ "start": 3118,
+ "end": 3126,
+ "text": "Let me be really clear about this one.",
+ "inSpan": false
+ },
+ {
+ "start": 3126,
+ "end": 3138,
+ "text": "The bridge opened in the spring of twenty nineteen,",
+ "inSpan": true
+ },
+ {
+ "start": 3138,
+ "end": 3151,
+ "text": "I was there for it.",
+ "inSpan": true
+ },
+ {
+ "start": 3151,
+ "end": 3160,
+ "text": "Anyway, back to the mail.",
+ "inSpan": false
+ },
+ {
+ "start": 3160,
+ "end": 3170,
+ "text": "Next letter is from a long-time viewer.",
+ "inSpan": false
+ }
+ ],
+ "citedIn": [
+ {
+ "reportId": "demo-factcheck",
+ "sectionId": null,
+ "claimId": null,
+ "citationId": "c01",
+ "href": "/reports/demo-factcheck/",
+ "reportTitle": "Checking an example article",
+ "number": 1
+ },
+ {
+ "reportId": "demo-factcheck",
+ "sectionId": "bridge",
+ "claimId": "claim-1",
+ "citationId": "c01",
+ "href": "/reports/demo-factcheck/#claim-1",
+ "reportTitle": "Checking an example article",
+ "sectionTitle": "The bridge",
+ "claimTitle": "“He opened it himself”",
+ "claimText": "He opened the bridge himself in 2018.",
+ "verdict": "CONTRADICTED",
+ "verdictStyle": {
+ "label": "Contradicted",
+ "color": "#e5534b"
+ },
+ "number": 1
+ },
+ {
+ "reportId": "demo-factcheck",
+ "sectionId": "moving",
+ "claimId": "claim-5",
+ "citationId": "c01",
+ "href": "/reports/demo-factcheck/#claim-5",
+ "reportTitle": "Checking an example article",
+ "sectionTitle": "Moving away",
+ "claimText": "He was at the opening.",
+ "verdict": "CORROBORATED",
+ "verdictStyle": {
+ "label": "Corroborated",
+ "color": "#3fbf7f"
+ },
+ "number": 1
+ }
+ ]
+}
diff --git a/export/fixtures/report-site/public/m/demo-podcast/ep-042/610.50-628.00/moment.json b/export/fixtures/report-site/public/m/demo-podcast/ep-042/610.50-628.00/moment.json
@@ -0,0 +1,74 @@
+{
+ "format": "archilyzer-moment-page",
+ "version": 1,
+ "key": "demo-podcast/ep-042/610.50-628.00",
+ "kind": "audio",
+ "record": {
+ "channel": "demo-podcast",
+ "channelTitle": "Demo Podcast",
+ "id": "ep-042",
+ "title": "Episode 42: a guest looks back",
+ "date": "2026-02-02",
+ "originalUrl": "https://media.example.org/demo-podcast/ep-042?t=610"
+ },
+ "quote": "Like I said before, spring twenty nineteen.",
+ "speaker": "The guest",
+ "verification": {
+ "quoteScore": 0.88,
+ "quoteCheckedAt": "2026-10-04T12:00:00Z"
+ },
+ "start": 610.5,
+ "end": 628,
+ "clip": {
+ "src": "/media/clips/demo-podcast/ep-042/610.50-628.00.mp4",
+ "start": 610.5,
+ "end": 628
+ },
+ "cues": [
+ {
+ "start": 600,
+ "end": 610.5,
+ "text": "So when did you first go back there?",
+ "inSpan": false
+ },
+ {
+ "start": 610.5,
+ "end": 628,
+ "text": "Like I said before, spring twenty nineteen.",
+ "inSpan": true
+ },
+ {
+ "start": 628,
+ "end": 640,
+ "text": "And I have been back once since.",
+ "inSpan": false
+ }
+ ],
+ "citedIn": [
+ {
+ "reportId": "demo-factcheck",
+ "sectionId": null,
+ "claimId": null,
+ "citationId": "au1",
+ "href": "/reports/demo-factcheck/",
+ "reportTitle": "Checking an example article",
+ "number": 2
+ },
+ {
+ "reportId": "demo-factcheck",
+ "sectionId": "bridge",
+ "claimId": "claim-2",
+ "citationId": "au1",
+ "href": "/reports/demo-factcheck/#claim-2",
+ "reportTitle": "Checking an example article",
+ "sectionTitle": "The bridge",
+ "claimText": "He went back to the bridge every year.",
+ "verdict": "PARTLY",
+ "verdictStyle": {
+ "label": "Partly true",
+ "color": "#e3b23c"
+ },
+ "number": 2
+ }
+ ]
+}
diff --git a/export/fixtures/report-site/public/m/demo-social/1234567890/moment.json b/export/fixtures/report-site/public/m/demo-social/1234567890/moment.json
@@ -0,0 +1,38 @@
+{
+ "format": "archilyzer-moment-page",
+ "version": 1,
+ "key": "demo-social/1234567890",
+ "kind": "post",
+ "record": {
+ "channel": "demo-social",
+ "channelTitle": "Demo Social",
+ "id": "1234567890",
+ "date": "2025-03-14",
+ "platform": "x",
+ "originalUrl": "https://media.example.org/demo-social/1234567890"
+ },
+ "quote": "Never said I would move. Not once.",
+ "date": "2025-03-14",
+ "post": {
+ "author": "@demo_account",
+ "text": "Never said I would move. Not once.\n\nPosted to settle it."
+ },
+ "citedIn": [
+ {
+ "reportId": "demo-factcheck",
+ "sectionId": "moving",
+ "claimId": "claim-3",
+ "citationId": "p01",
+ "href": "/reports/demo-factcheck/#claim-3",
+ "reportTitle": "Checking an example article",
+ "sectionTitle": "Moving away",
+ "claimText": "He has said many times that he would move away.",
+ "verdict": "CONTRADICTED",
+ "verdictStyle": {
+ "label": "Contradicted",
+ "color": "#e5534b"
+ },
+ "number": 6
+ }
+ ]
+}
diff --git a/export/fixtures/report-site/public/m/index.json b/export/fixtures/report-site/public/m/index.json
@@ -0,0 +1,9 @@
+{
+ "format": "archilyzer-moment-index",
+ "version": 1,
+ "moments": [
+ "demo-channel/abc123/3126.00-3151.00",
+ "demo-podcast/ep-042/610.50-628.00",
+ "demo-social/1234567890"
+ ]
+}
diff --git a/export/fixtures/report-site/public/reports/demo-factcheck/page.json b/export/fixtures/report-site/public/reports/demo-factcheck/page.json
@@ -0,0 +1,245 @@
+{
+ "format": "archilyzer-report-page",
+ "version": 1,
+ "id": "demo-factcheck",
+ "kind": "factcheck",
+ "title": "Checking an example article",
+ "subtitle": "Four claims about a demo channel, tested against what was said on air",
+ "summary": "The article makes four claims. The recordings support one, partly support another, and contradict a third; the fourth cannot be tested. The host said the opening date out loud [on stream](cite:c01), and later [repeated it](cite:au1).",
+ "published": "2026-10-01",
+ "updated": "2026-10-04",
+ "subject": {
+ "id": "s0",
+ "kind": "article",
+ "title": "An example article about a demo channel",
+ "url": "https://example.org/articles/demo",
+ "publisher": "Example Gazette",
+ "author": "A. Writer",
+ "date": "2026-09-20",
+ "note": "Read in the edition published on the day; it has since been revised.",
+ "archives": [
+ {
+ "label": "archive.org",
+ "url": "https://web.archive.org/web/2026/https://example.org/articles/demo",
+ "context": "as published on the day"
+ }
+ ]
+ },
+ "verdicts": {
+ "CORROBORATED": {
+ "label": "Corroborated",
+ "color": "#3fbf7f"
+ },
+ "PARTLY": {
+ "label": "Partly true",
+ "color": "#e3b23c"
+ },
+ "CONTRADICTED": {
+ "label": "Contradicted",
+ "color": "#e5534b"
+ },
+ "NOT_FOUND": {
+ "label": "Not found",
+ "color": "#8b93a7"
+ },
+ "UNTESTABLE": {
+ "label": "Untestable",
+ "color": "#7d8fd6"
+ }
+ },
+ "citations": {
+ "c01": {
+ "kind": "video",
+ "id": "c01",
+ "number": 1,
+ "quote": "The bridge opened in the spring of twenty nineteen, I was there for it.",
+ "verification": {
+ "quoteScore": 0.97,
+ "quoteCheckedAt": "2026-10-04T12:00:00Z",
+ "voiceChecked": true,
+ "method": "cue-window v1"
+ },
+ "moment": "demo-channel/abc123/3126.00-3151.00",
+ "href": "/m/demo-channel/abc123/3126.00-3151.00/",
+ "start": 3126,
+ "end": 3151,
+ "record": {
+ "channel": "demo-channel",
+ "channelTitle": "Demo Channel",
+ "id": "abc123",
+ "title": "Demo stream: the bridge, the move, and the mail",
+ "date": "2026-01-10",
+ "platform": "youtube",
+ "originalUrl": "https://media.example.org/demo-channel/abc123?t=3126"
+ },
+ "poster": "/reports/demo-factcheck/stills/poster.png"
+ },
+ "au1": {
+ "kind": "audio",
+ "id": "au1",
+ "number": 2,
+ "quote": "Like I said before, spring twenty nineteen.",
+ "speaker": "The guest",
+ "verification": {
+ "quoteScore": 0.88,
+ "quoteCheckedAt": "2026-10-04T12:00:00Z"
+ },
+ "moment": "demo-podcast/ep-042/610.50-628.00",
+ "href": "/m/demo-podcast/ep-042/610.50-628.00/",
+ "start": 610.5,
+ "end": 628,
+ "record": {
+ "channel": "demo-podcast",
+ "channelTitle": "Demo Podcast",
+ "id": "ep-042",
+ "title": "Episode 42: a guest looks back",
+ "date": "2026-02-02",
+ "originalUrl": "https://media.example.org/demo-podcast/ep-042?t=610"
+ }
+ },
+ "a01": {
+ "kind": "source",
+ "id": "a01",
+ "number": 3,
+ "quote": "He opened the bridge himself in 2018.",
+ "source": {
+ "id": "s0",
+ "kind": "article",
+ "title": "An example article about a demo channel",
+ "url": "https://example.org/articles/demo",
+ "publisher": "Example Gazette",
+ "author": "A. Writer",
+ "date": "2026-09-20",
+ "note": "Read in the edition published on the day; it has since been revised.",
+ "archives": [
+ {
+ "label": "archive.org",
+ "url": "https://web.archive.org/web/2026/https://example.org/articles/demo",
+ "context": "as published on the day"
+ }
+ ]
+ },
+ "image": "/reports/demo-factcheck/stills/a01.png",
+ "href": "https://example.org/articles/demo"
+ },
+ "w01": {
+ "kind": "page",
+ "id": "w01",
+ "number": 4,
+ "quote": "Opened to traffic: April 2019.",
+ "note": "The city's own record of the opening.",
+ "href": "https://example.org/city/bridge-history",
+ "title": "Bridge history — city records",
+ "archiveUrl": "https://web.archive.org/web/2026/https://example.org/city/bridge-history"
+ },
+ "a02": {
+ "kind": "source",
+ "id": "a02",
+ "number": 5,
+ "quote": "He has said many times that he would move away.",
+ "source": {
+ "id": "s0",
+ "kind": "article",
+ "title": "An example article about a demo channel",
+ "url": "https://example.org/articles/demo",
+ "publisher": "Example Gazette",
+ "author": "A. Writer",
+ "date": "2026-09-20",
+ "note": "Read in the edition published on the day; it has since been revised.",
+ "archives": [
+ {
+ "label": "archive.org",
+ "url": "https://web.archive.org/web/2026/https://example.org/articles/demo",
+ "context": "as published on the day"
+ }
+ ]
+ },
+ "href": "https://example.org/articles/demo"
+ },
+ "p01": {
+ "kind": "post",
+ "id": "p01",
+ "number": 6,
+ "quote": "Never said I would move. Not once.",
+ "date": "2025-03-14",
+ "moment": "demo-social/1234567890",
+ "href": "/m/demo-social/1234567890/",
+ "record": {
+ "channel": "demo-social",
+ "channelTitle": "Demo Social",
+ "id": "1234567890",
+ "date": "2025-03-14",
+ "platform": "x",
+ "originalUrl": "https://media.example.org/demo-social/1234567890"
+ },
+ "author": "@demo_account",
+ "text": "Never said I would move. Not once.\n\nPosted to settle it."
+ }
+ },
+ "sections": [
+ {
+ "id": "bridge",
+ "title": "The bridge",
+ "body": "The article's first chapter is about the bridge.",
+ "claims": [
+ {
+ "id": "claim-1",
+ "title": "“He opened it himself”",
+ "text": "He opened the bridge himself in 2018.",
+ "verdict": "CONTRADICTED",
+ "sourceQuote": "a01",
+ "findings": "He was there, but the bridge opened in 2019: he says so [on stream](cite:c01), and the [city's record](cite:w01) agrees.",
+ "citations": [
+ "c01",
+ "w01"
+ ]
+ },
+ {
+ "id": "claim-2",
+ "text": "He went back to the bridge every year.",
+ "verdict": "PARTLY",
+ "findings": "He mentions going back [once](cite:au1); nothing in the recordings says every year.",
+ "citations": [
+ "au1"
+ ]
+ }
+ ]
+ },
+ {
+ "id": "moving",
+ "title": "Moving away",
+ "claims": [
+ {
+ "id": "claim-3",
+ "text": "He has said many times that he would move away.",
+ "verdict": "CONTRADICTED",
+ "sourceQuote": "a02",
+ "findings": "He [denies it outright](cite:p01).",
+ "citations": [
+ "p01"
+ ]
+ },
+ {
+ "id": "claim-4",
+ "text": "He privately regrets it.",
+ "verdict": "UNTESTABLE",
+ "findings": "Nothing public speaks to a private feeling.",
+ "citations": []
+ },
+ {
+ "id": "claim-5",
+ "text": "He was at the opening.",
+ "verdict": "CORROBORATED",
+ "findings": "He says [he was there](cite:c01).",
+ "citations": [
+ "c01"
+ ]
+ }
+ ]
+ }
+ ],
+ "downloads": {
+ "json": "/reports/demo-factcheck/citations.json",
+ "csv": "/reports/demo-factcheck/citations.csv"
+ }
+}
diff --git a/export/fixtures/report-site/public/reports/demo-factcheck/stills/a01.png b/export/fixtures/report-site/public/reports/demo-factcheck/stills/a01.png
Binary files differ.
diff --git a/export/fixtures/report-site/public/reports/demo-factcheck/stills/poster.png b/export/fixtures/report-site/public/reports/demo-factcheck/stills/poster.png
Binary files differ.
diff --git a/export/fixtures/report-site/public/reports/index.json b/export/fixtures/report-site/public/reports/index.json
@@ -0,0 +1,57 @@
+{
+ "format": "archilyzer-report-index",
+ "version": 1,
+ "reports": [
+ {
+ "id": "demo-factcheck",
+ "kind": "factcheck",
+ "title": "Checking an example article",
+ "subtitle": "Four claims about a demo channel, tested against what was said on air",
+ "published": "2026-10-01",
+ "updated": "2026-10-04",
+ "href": "/reports/demo-factcheck/",
+ "citationCount": 6,
+ "claimCount": 5,
+ "tally": [
+ {
+ "verdict": "CORROBORATED",
+ "count": 1
+ },
+ {
+ "verdict": "PARTLY",
+ "count": 1
+ },
+ {
+ "verdict": "CONTRADICTED",
+ "count": 2
+ },
+ {
+ "verdict": "UNTESTABLE",
+ "count": 1
+ }
+ ],
+ "verdicts": {
+ "CORROBORATED": {
+ "label": "Corroborated",
+ "color": "#3fbf7f"
+ },
+ "PARTLY": {
+ "label": "Partly true",
+ "color": "#e3b23c"
+ },
+ "CONTRADICTED": {
+ "label": "Contradicted",
+ "color": "#e5534b"
+ },
+ "NOT_FOUND": {
+ "label": "Not found",
+ "color": "#8b93a7"
+ },
+ "UNTESTABLE": {
+ "label": "Untestable",
+ "color": "#7d8fd6"
+ }
+ }
+ }
+ ]
+}
diff --git a/export/fixtures/report-site/source/demo-factcheck/report.json b/export/fixtures/report-site/source/demo-factcheck/report.json
@@ -0,0 +1,131 @@
+{
+ "format": "archilyzer-report",
+ "version": 1,
+ "id": "demo-factcheck",
+ "kind": "factcheck",
+ "title": "Checking an example article",
+ "subtitle": "Four claims about a demo channel, tested against what was said on air",
+ "summary": "The article makes four claims. The recordings support one, partly support another, and contradict a third; the fourth cannot be tested. The host said the opening date out loud [on stream](cite:c01), and later [repeated it](cite:au1).",
+ "published": "2026-10-01",
+ "updated": "2026-10-04",
+ "subject": { "source": "s0" },
+ "sources": {
+ "s0": {
+ "kind": "article",
+ "title": "An example article about a demo channel",
+ "url": "https://example.org/articles/demo",
+ "publisher": "Example Gazette",
+ "author": "A. Writer",
+ "date": "2026-09-20",
+ "note": "Read in the edition published on the day; it has since been revised.",
+ "archives": [
+ { "label": "archive.org", "url": "https://web.archive.org/web/2026/https://example.org/articles/demo", "context": "as published on the day" }
+ ]
+ }
+ },
+ "citations": {
+ "c01": {
+ "kind": "video",
+ "channel": "demo-channel",
+ "id": "abc123",
+ "start": 3126,
+ "end": 3151,
+ "pad": { "before": 5, "after": 5 },
+ "quote": "The bridge opened in the spring of twenty nineteen, I was there for it.",
+ "verification": { "quoteScore": 0.97, "quoteCheckedAt": "2026-10-04T12:00:00Z", "voiceChecked": true, "method": "cue-window v1" }
+ },
+ "au1": {
+ "kind": "audio",
+ "channel": "demo-podcast",
+ "id": "ep-042",
+ "start": 610.5,
+ "end": 628,
+ "quote": "Like I said before, spring twenty nineteen.",
+ "speaker": "The guest",
+ "verification": { "quoteScore": 0.88, "quoteCheckedAt": "2026-10-04T12:00:00Z" }
+ },
+ "p01": {
+ "kind": "post",
+ "channel": "demo-social",
+ "id": "1234567890",
+ "quote": "Never said I would move. Not once.",
+ "date": "2025-03-14"
+ },
+ "a01": {
+ "kind": "source",
+ "source": "s0",
+ "quote": "He opened the bridge himself in 2018.",
+ "image": "stills/a01.png"
+ },
+ "a02": {
+ "kind": "source",
+ "source": "s0",
+ "quote": "He has said many times that he would move away."
+ },
+ "w01": {
+ "kind": "page",
+ "url": "https://example.org/city/bridge-history",
+ "title": "Bridge history — city records",
+ "archiveUrl": "https://web.archive.org/web/2026/https://example.org/city/bridge-history",
+ "quote": "Opened to traffic: April 2019.",
+ "note": "The city's own record of the opening."
+ },
+ "unused": {
+ "kind": "page",
+ "url": "https://example.org/unused",
+ "quote": "Not cited anywhere."
+ }
+ },
+ "sections": [
+ {
+ "id": "bridge",
+ "title": "The bridge",
+ "body": "The article's first chapter is about the bridge.",
+ "claims": [
+ {
+ "id": "claim-1",
+ "title": "“He opened it himself”",
+ "text": "He opened the bridge himself in 2018.",
+ "verdict": "CONTRADICTED",
+ "sourceQuote": { "citation": "a01" },
+ "findings": "He was there, but the bridge opened in 2019: he says so [on stream](cite:c01), and the [city's record](cite:w01) agrees.",
+ "citations": ["c01", "w01"]
+ },
+ {
+ "id": "claim-2",
+ "text": "He went back to the bridge every year.",
+ "verdict": "PARTLY",
+ "findings": "He mentions going back [once](cite:au1); nothing in the recordings says every year.",
+ "citations": ["au1"]
+ }
+ ]
+ },
+ {
+ "id": "moving",
+ "title": "Moving away",
+ "claims": [
+ {
+ "id": "claim-3",
+ "text": "He has said many times that he would move away.",
+ "verdict": "CONTRADICTED",
+ "sourceQuote": { "citation": "a02" },
+ "findings": "He [denies it outright](cite:p01).",
+ "citations": ["p01"]
+ },
+ {
+ "id": "claim-4",
+ "text": "He privately regrets it.",
+ "verdict": "UNTESTABLE",
+ "findings": "Nothing public speaks to a private feeling."
+ },
+ {
+ "id": "claim-5",
+ "text": "He was at the opening.",
+ "verdict": "CORROBORATED",
+ "findings": "He says [he was there](cite:c01).",
+ "citations": ["c01"]
+ }
+ ]
+ }
+ ]
+}
diff --git a/export/fixtures/report-site/write.ts b/export/fixtures/report-site/write.ts
@@ -0,0 +1,18 @@
+// Writes the fixture report site's view JSON under public/ (see fixture.ts).
+// Run after changing the fixture report or the view builder:
+//
+// pnpm --filter export exec tsx fixtures/report-site/write.ts
+//
+// The stills (public/reports/demo-factcheck/stills/*.png) are committed images,
+// not generated here.
+
+import fs from "node:fs";
+import path from "node:path";
+import { buildFixtureFiles, fixtureFilePath, serializeFixture } from "./fixture";
+
+for (const [urlPath, value] of Object.entries(buildFixtureFiles())) {
+ const file = fixtureFilePath(urlPath);
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ fs.writeFileSync(file, serializeFixture(value));
+ console.log(urlPath);
+}