Archilyzer · Source

archilyzer

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

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:
Mcommon/components/Markdown.tsx | 12++++++++----
Acommon/components/citations/CitationCard.tsx | 250+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/components/citations/CitedMarkdown.tsx | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/components/citations/InlineCite.tsx | 192+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/components/citations/ReferenceList.tsx | 43+++++++++++++++++++++++++++++++++++++++++++
Acommon/components/citations/VerificationBadge.tsx | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/components/citations/citations.test.ts | 202+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/components/report/VerdictChip.test.ts | 37+++++++++++++++++++++++++++++++++++++
Acommon/components/report/VerdictChip.tsx | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/report/views.test.ts | 230+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/report/views.ts | 563+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mexport/CHANGELOG.md | 3+++
Mexport/app/(workspace)/ask/page.tsx | 4++++
Mexport/app/(workspace)/layout.tsx | 7+++++--
Mexport/app/(workspace)/page.tsx | 12+++++++++++-
Mexport/app/components/Footer.tsx | 25++++++++++++++++---------
Mexport/app/components/Header.tsx | 28++++++++++++----------------
Aexport/app/components/reports/MomentArticle.tsx | 167+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aexport/app/components/reports/ReportArticle.tsx | 203+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aexport/app/components/reports/ReportIndex.tsx | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aexport/app/components/reports/parts.tsx | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mexport/app/downloads/page.tsx | 3+++
Mexport/app/duplicates/page.tsx | 3+++
Aexport/app/lib/nav.ts | 30++++++++++++++++++++++++++++++
Aexport/app/lib/reports.test.ts | 172+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aexport/app/lib/reports.ts | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aexport/app/m/[...moment]/page.tsx | 40++++++++++++++++++++++++++++++++++++++++
Mexport/app/offline/page.tsx | 3+++
Aexport/app/reports/[reportId]/page.tsx | 36++++++++++++++++++++++++++++++++++++
Aexport/app/reports/page.tsx | 11+++++++++++
Aexport/fixtures/report-site/fixture.ts | 207+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aexport/fixtures/report-site/public/m/demo-channel/abc123/3126.00-3151.00/moment.json | 112+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aexport/fixtures/report-site/public/m/demo-podcast/ep-042/610.50-628.00/moment.json | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aexport/fixtures/report-site/public/m/demo-social/1234567890/moment.json | 38++++++++++++++++++++++++++++++++++++++
Aexport/fixtures/report-site/public/m/index.json | 9+++++++++
Aexport/fixtures/report-site/public/reports/demo-factcheck/page.json | 245+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aexport/fixtures/report-site/public/reports/demo-factcheck/stills/a01.png | 0
Aexport/fixtures/report-site/public/reports/demo-factcheck/stills/poster.png | 0
Aexport/fixtures/report-site/public/reports/index.json | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aexport/fixtures/report-site/source/demo-factcheck/report.json | 131+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aexport/fixtures/report-site/write.ts | 18++++++++++++++++++
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&#x27;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); +}