commit c51e68ee1b4b04d43fffe32a8675a2ac6cfeddbc
parent 3b89d6b8919da1921df3864ab7486b5d9dfa4ce8
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 5 Oct 2026 03:01:06 -0400
export: /reports/, /reports/<id>/ and /m/<key>/ from the views; Reports header link; a cited site's shell (report index home, no search, ask, downloads or duplicates); fixture report site
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
29 files changed, 1801 insertions(+), 27 deletions(-)
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,11 @@
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 +17,13 @@ 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.
+ if (isCitedSite(settings)) {
+ return <ReportIndex index={readReportIndex()} heading={settings.siteTitle} tagline={settings.homeTagline} />;
+ }
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,164 @@
+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 && (
+ <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 && (
+ <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}>
+ <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.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,202 @@
+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 ? (
+ <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,80 @@
+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.{" "}
+ <a href="/reports/" className={textLink}>
+ Read the reports
+ </a>
+ .
+ </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,165 @@
+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.match(html, /<section id="bridge"/);
+ 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.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,111 @@
+{
+ "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",
+ "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,241 @@
+{
+ "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",
+ "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",
+ "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",
+ "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",
+ "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,129 @@
+{
+ "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",
+ "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",
+ "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);
+}