import { ArrowDownToLine, Rss } from "lucide-react"; import { AddedMark, CitationCard } from "yt-dlp-transcript-common/components/citations/CitationCard"; import { CitationsProvider } from "yt-dlp-transcript-common/components/citations/CitationsContext"; 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 { ReportReader } from "yt-dlp-transcript-common/components/report/slides/ReportReader"; import { REPORT_PAGE_ANCHOR_IDS } from "yt-dlp-transcript-common/lib/report/slideRules"; import { hasSlides } from "yt-dlp-transcript-common/lib/report/slides"; import type { SourceArchive } from "yt-dlp-transcript-common/lib/citations/schema"; import { entryDateLabel, foundGroups, orderedCitations, reportDateParts, reportRevisionLabel, reportViewPath, sourceAnchor, verdictTally, type CitationView, type ClaimView, type EntryView, type ReportPageView, type SourceCitationView, } from "yt-dlp-transcript-common/lib/report/views"; import { ArchiveList, ReportName, SourceBlock, SubjectCard, textLink } from "./parts"; // ONE REPORT, from its view (common/lib/report/views.ts): the header (its // series and its title, or the title alone; one line of dates and the // revision, linked to its history; the document under review as a card on its // colour's rail, with its archive links; the subtitle), the three tiers each // opened by a depth marker — the quick take (a fact-check's tally, the // summary), the timeline when the report keeps one (dated entries, newest // first, each its own anchor), what the check found, and every claim with its evidence: each // claim its verdict and flag, the document's own sentence (its still), the // findings with their inline citations, and the evidence cards (what the // report added first, marked; what the document gave itself folded away // last) — then the numbered reference list the inline markers jump to, and // the downloads: the report as files (HTML, PDF, Markdown, the evidence pack) // and its citations as data. // // A report with a timeline on a site with a public URL publishes feeds of it // (common/lib/report/feeds.ts): the header links them ("Subscribe"), and the // page's metadata names them (lib/reports.ts reportMetadata). // // ONE ARTICLE, THREE SHAPES (release 22): the view switch (Article | Slides | // Overview — common/components/report/slides/ReportReader) follows the // header and sticks under the site's header while the article scrolls, so a // switch keeps the place the reader is at; every anchor in the article lands // below the two bars (`scroll-mt-32`: the site header's 57 px and the // switch's 54). Every place a slide stands for has its id here: the header, the quick // take, what the check found, each section and claim, and the end matter // (lib/report/slideRules.ts REPORT_PAGE_ANCHOR_IDS). The article itself stays // this static page; the other views are built in the browser from page.json. const proseClass = "text-[0.95rem] leading-relaxed text-foreground"; const DOWNLOADS: readonly (readonly [keyof NonNullable, string])[] = [ ["html", "HTML"], ["pdf", "PDF"], ["md", "Markdown"], ["slides", "Slides"], ["slides-pdf", "Slides PDF"], ["zip", "Evidence pack"], ["json", "Citations JSON"], ["csv", "CSV"], ]; // A claim's sentence in a quoted document: its still (or its words), on a rail // in the document's colour (its `accent`, else the border colour) — the same // colour as the document's box, so the sentences and the box read as one // source. A sentence of the document under review needs no link to it: the // rail says whose it is. Another document's sentence links to its box. function SourceSentence({ c, archives, isSubject, }: { c: SourceCitationView; archives: readonly SourceArchive[] | undefined; isSubject: boolean; }) { const hasArchives = !!archives && archives.length > 0; return (
{c.image ? ( // eslint-disable-next-line @next/next/no-img-element -- a published file of unknown size; the export serves images unoptimized {c.quote} ) : (
{c.quote}
)} {(!isSubject || hasArchives) && (
{!isSubject && ( from {c.sourceTitle} )} {/* Only the document's archive links that sit in this sentence; the full list is with the document, once. */} {hasArchives && }
)}
); } // A claim's flag: a quiet pill in the accent, behind the project's mark (the // mark added evidence wears: the two read as the report's own marks). function FlagPill({ flag, className }: { flag: string; className?: string }) { return ( {flag} ); } // Where a tier of the page begins: a hairline with, at its left, how deep it // goes (one, two or three dots filled in the accent). Decoration only: the // tier's own label or heading names it. function TierMarker({ depth }: { depth: 1 | 2 | 3 }) { return ( ); } // A timeline entry: its date first and largest (what a timeline is read by), // linking to its own anchor, then its title and its body. A dot on the // timeline's rail marks it. function Entry({ entry }: { entry: EntryView }) { return (
  • ); } // A claim: its verdict, flag and title; the document's own sentence (its // still, else its verbatim words) when the claim cites one, else the claim as // the report states it, plain; the findings; the evidence. function Claim({ claim, view, subjectNoun, }: { claim: ClaimView; view: ReportPageView; // What the document under review is called: "article" or "source". subjectNoun: string; }) { const cited = claim.sourceQuote ? view.citations[claim.sourceQuote] : undefined; const sentence = cited?.kind === "source" ? cited : undefined; const evidence = claim.citations .filter((id) => id !== claim.sourceQuote) .map((id) => view.citations[id]) .filter((c): c is CitationView => !!c); // What the report adds comes first, marked; then evidence of unknown origin; // what the document under review gave itself is folded away at the end. const added = evidence.filter((c) => c.origin === "added"); const unknown = evidence.filter((c) => c.origin === undefined); const given = evidence.filter((c) => c.origin === "subject"); const shown = [...added, ...unknown]; const addedLabel = `Not in the ${subjectNoun}`; return (
    {claim.verdict && } {claim.flag && }

    {claim.title ?? claim.text}

    {sentence ? ( ) : ( claim.title && (

    {claim.text}

    ) )} {claim.findings && ( {claim.findings} )} {evidence.length > 0 && (

    Evidence

    {shown.length > 0 && (
    {shown.map((c) => ( ))}
    )} {given.length > 0 && (
    {`In the ${subjectNoun} (${given.length})`}
    {given.map((c) => ( ))}
    )}
    )}
    ); } export default function ReportArticle({ view }: { view: ReportPageView }) { const isFactcheck = view.kind === "factcheck"; const tally = isFactcheck ? verdictTally(view) : []; const references = orderedCitations(view); const subject = view.subject ? view.sources[view.subject] : undefined; const subjectNoun = subject?.kind === "article" ? "article" : "source"; const dates = reportDateParts(view); const claimCount = view.sections.reduce((n, s) => n + s.claims.length, 0); // The report as files (its exports), then its citations as data: only what // compose published. const downloads = DOWNLOADS.flatMap(([key, label]) => { const href = view.downloads?.[key]; return href ? [[key, label, href] as const] : []; }); const groups = isFactcheck ? foundGroups(view) : []; const slides = hasSlides(view); const jumps = [ ...(groups.length > 0 ? [{ href: "#found", label: "What the check found" }] : []), { href: "#claims", label: "Every claim" }, ...(downloads.length > 0 ? [{ href: "#downloads", label: "Downloads" }] : []), ]; // Documents quoted that are not the subject, each shown once before the // references. const otherSources = Object.values(view.sources).filter((s) => s.id !== view.subject); return ( // One citation map for every inline marker on the page (CitationsContext): // serialized once, not once per marker.
    {/* The report's name (its series on one line, its title on the next; without a series, the title alone), one line of dates and the revision, the document under review, the subtitle. */}

    {(dates.length > 0 || view.history) && (

    {dates.join(" · ")} {view.history && ( <> {dates.length > 0 ? " · " : null} {reportRevisionLabel(view.history.revision, view.history.current)} )}

    )} {view.feeds && (

    Subscribe: RSS JSON

    )} {subject && } {view.subtitle &&

    {view.subtitle}

    }
    {/* The view switch: sticky just under the site's header (Header.tsx, `sticky top-0`, 56 px and its border), within the article's box. */} {slides && (
    )} {/* The report as a video, when it has one. */} {view.video && (
    )} {/* The quick take: the tally and the summary, then where to go next. */}
    {tally.length > 0 && (

    {claimCount} claim{claimCount === 1 ? "" : "s"} checked

    )} {view.summary && ( {view.summary} )}

    {jumps.map((j, i) => ( {i > 0 && } {j.label} ))}

    {/* The timeline: dated entries, newest first, on a rail. */} {view.entries && view.entries.length > 0 && (

    Timeline

      {view.entries.map((e) => ( ))}
    )} {/* What the check found: every ruled claim by verdict, one line each. */} {groups.length > 0 && (

    What the check found

    {groups.map((g) => (

    ))}
    )} {/* Every claim, with its evidence. */}

    Every claim, with its evidence

    {view.method && (

    How it was checked

    {view.method}
    )}
    {view.sections.length > 1 && ( )} {view.sections.map((s) => (

    {s.title}

    {s.body && ( {s.body} )} {s.claims.map((c) => ( ))}
    ))}
    {/* The end matter: the documents quoted, the references, the downloads — the sources slide's place. */}
    {otherSources.length > 0 && (

    Sources

    {otherSources.map((s) => ( ))}
    )} {references.length > 0 && (

    References

    )} {downloads.length > 0 && (

    Download: {downloads.map(([key, label, href]) => ( {label} ))}

    )}
    ); }