Archilyzer · Source

archilyzer

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

commit c51e68ee1b4b04d43fffe32a8675a2ac6cfeddbc
parent 3b89d6b8919da1921df3864ab7486b5d9dfa4ce8
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon,  5 Oct 2026 03:01:06 -0400

export: /reports/, /reports/<id>/ and /m/<key>/ from the views; Reports header link; a cited site's shell (report index home, no search, ask, downloads or duplicates); fixture report site

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Mexport/app/(workspace)/ask/page.tsx | 4++++
Mexport/app/(workspace)/layout.tsx | 7+++++--
Mexport/app/(workspace)/page.tsx | 10++++++++++
Mexport/app/components/Footer.tsx | 25++++++++++++++++---------
Mexport/app/components/Header.tsx | 28++++++++++++----------------
Aexport/app/components/reports/MomentArticle.tsx | 164+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aexport/app/components/reports/ReportArticle.tsx | 202+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aexport/app/components/reports/ReportIndex.tsx | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aexport/app/components/reports/parts.tsx | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mexport/app/downloads/page.tsx | 3+++
Mexport/app/duplicates/page.tsx | 3+++
Aexport/app/lib/nav.ts | 30++++++++++++++++++++++++++++++
Aexport/app/lib/reports.test.ts | 165+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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 | 111+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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 | 241+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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 | 129+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aexport/fixtures/report-site/write.ts | 18++++++++++++++++++
29 files changed, 1801 insertions(+), 27 deletions(-)

diff --git a/export/app/(workspace)/ask/page.tsx b/export/app/(workspace)/ask/page.tsx @@ -3,6 +3,8 @@ import { AI_DOC_URL } from "yt-dlp-transcript-common/lib/project"; import { currentSite } from "../../lib/site"; import { instanceMode } from "../../lib/mode"; import AskHub from "../../ask/AskHub"; +import { isCitedSite } from "yt-dlp-transcript-common/lib/siteSchema"; +import { NotOnCitedSite } from "../../components/reports/parts"; export const metadata: Metadata = { title: "Ask AI" }; @@ -19,6 +21,8 @@ export default function AskPage() { const site = currentSite(); const isHub = instanceMode() === "hub"; const scope = isHub ? "the federation" : "the transcripts"; + // A cited site has no corpus to ask about. + if (!isHub && isCitedSite(site)) return <NotOnCitedSite title="Ask AI" />; return ( <div className="mx-auto flex max-w-3xl flex-col gap-6"> diff --git a/export/app/(workspace)/layout.tsx b/export/app/(workspace)/layout.tsx @@ -1,3 +1,4 @@ +import { isCitedSite } from "yt-dlp-transcript-common/lib/siteSchema"; import { instanceMode } from "../lib/mode"; import { currentSite } from "../lib/site"; import SiteWorkspace from "./SiteWorkspace"; @@ -8,13 +9,15 @@ import SiteWorkspace from "./SiteWorkspace"; // survives navigation between them. // // Hub mode keeps its own self-contained pages (<HubHome/>, <AskHub/>), so here -// it's a straight pass-through — the fork is preserved exactly as before. +// it's a straight pass-through — the fork is preserved exactly as before. So +// is a CITED site (site.json `publish: "cited"`): it has no corpus to search, +// and its home is the report index. export default function WorkspaceLayout({ children, }: { children: React.ReactNode; }) { - if (instanceMode() === "hub") return <>{children}</>; + if (instanceMode() === "hub" || isCitedSite(currentSite())) return <>{children}</>; // site.json `transcriptDownloads: false` hides the transcript modal's // per-video export controls (absent = on). return ( diff --git a/export/app/(workspace)/page.tsx b/export/app/(workspace)/page.tsx @@ -1,8 +1,11 @@ import { countTranscripts } from "yt-dlp-transcript-common/lib/transcripts"; +import { isCitedSite } from "yt-dlp-transcript-common/lib/siteSchema"; import { currentSite } from "../lib/site"; import { instanceMode } from "../lib/mode"; +import { readReportIndex } from "../lib/reports"; import NewSinceLastVisit from "../components/NewSinceLastVisit"; import HubHome from "../components/hub/HubHome"; +import ReportIndex from "../components/reports/ReportIndex"; export default async function Home() { // Hub mode renders the federated shelf + cross-site search and reads no local @@ -14,6 +17,13 @@ export default async function Home() { } const settings = currentSite(); + // A cited site publishes only its reports and their moments: its home is the + // report index (a static export has no redirect to send it there), and it + // has no summaries manifest, so countTranscripts() — which throws without + // one — is never reached. + if (isCitedSite(settings)) { + return <ReportIndex index={readReportIndex()} heading={settings.siteTitle} tagline={settings.homeTagline} />; + } const count = await countTranscripts(); // The corpus data source, SearchSession, the persistent search bar, AND the diff --git a/export/app/components/Footer.tsx b/export/app/components/Footer.tsx @@ -15,9 +15,14 @@ import { SocialLinks } from "yt-dlp-transcript-common/components/SocialLinks"; import { currentSite } from "../lib/site"; import { instanceMode } from "../lib/mode"; import { hasArchives } from "../lib/archives"; +import { isCitedSite } from "yt-dlp-transcript-common/lib/siteSchema"; export default function Footer() { const site = currentSite(); + // A cited site (site.json `publish: "cited"`) has no archives to download + // and nothing for an AI to read; its footer keeps the rest. + const cited = instanceMode() === "site" && isCitedSite(site); + const showArchives = !cited && hasArchives(); const socialLinks = resolveSocialLinks(site, getSettings()); // The whole pool is present at build time, so siblings (other sites with a // siteUrl) resolve here; single-site installs get an empty list and the @@ -36,14 +41,14 @@ export default function Footer() { project site's job (one snapshot, one place, one checksum), and leaving the label behind would caption an empty space on a site with neither archives nor a PWA. */} - {(hasArchives() || site.pwa) && ( + {(showArchives || site.pwa) && ( <span className="font-mono text-xs uppercase tracking-[0.14em]"> Downloads </span> )} {/* Transcript & live-chat archive zips, generated per build. Shown only when this build actually produced servable archives. */} - {hasArchives() && ( + {showArchives && ( <a href="/downloads" className="underline underline-offset-2 hover:text-foreground transition-colors" @@ -64,18 +69,20 @@ export default function Footer() { )} {/* The dot parts the downloads from the rest, so it shows only when there is something before it. */} - {(hasArchives() || site.pwa) && ( + {(showArchives || site.pwa) && ( <span aria-hidden="true" className="text-muted-foreground/50"> · </span> )} {/* The homepage's AI and MCP doc, in the same tab (release 16). */} - <a - href={AI_DOC_URL} - className="underline underline-offset-2 hover:text-foreground transition-colors" - > - Use with AI - </a> + {!cited && ( + <a + href={AI_DOC_URL} + className="underline underline-offset-2 hover:text-foreground transition-colors" + > + Use with AI + </a> + )} {/* Changelog lives here, not in the header (release 14). */} <a href="/changelog" diff --git a/export/app/components/Header.tsx b/export/app/components/Header.tsx @@ -2,7 +2,7 @@ import Link from "next/link"; import { getSettings } from "yt-dlp-transcript-common/lib/settings"; import { resolveSocialLinks } from "yt-dlp-transcript-common/lib/site"; import { headerSocialLinks } from "yt-dlp-transcript-common/lib/socialLinks"; -import { AI_DOC_URL, INSTANCES_URL } from "yt-dlp-transcript-common/lib/project"; +import { INSTANCES_URL } from "yt-dlp-transcript-common/lib/project"; import { ThemeToggle } from "yt-dlp-transcript-common/components/ThemeToggle"; import { SocialLinks } from "yt-dlp-transcript-common/components/SocialLinks"; import { SocialScroll } from "yt-dlp-transcript-common/components/SocialScroll"; @@ -14,6 +14,9 @@ import { headerMarkPalette } from "../lib/brand"; import { instanceMode } from "../lib/mode"; import { hasArchives } from "../lib/archives"; import { hasDuplicates } from "../lib/duplicates"; +import { hasReports } from "../lib/reports"; +import { headerNavLinks } from "../lib/nav"; +import { isCitedSite } from "yt-dlp-transcript-common/lib/siteSchema"; import MobileMenu from "./MobileMenu"; // The export site's masthead (every site, and the hub): the Found-line mark + @@ -76,21 +79,14 @@ export default function Header() { const socialLinks = resolveSocialLinks(site, getSettings()); const narrow = headerSocialLinks(socialLinks, "narrow").length; const wide = headerSocialLinks(socialLinks, "wide").length; - const showDownloads = hasArchives(); - const showDuplicates = hasDuplicates(); - - // The inline nav from lg. Ask AI is new: the chat was only reachable from - // the workspace control or from Use with AI, which is not where anyone looks - // for it. - // Use with AI is the homepage's AI and MCP doc (release 16 slice DX): off - // this site, so a plain <a> in the same tab, not a client navigation. - const navLinks: { href: string; label: string; external?: boolean }[] = [ - { href: "/", label: "Search" }, - { href: "/ask/", label: "Ask AI" }, - ...(showDuplicates ? [{ href: "/duplicates", label: "Duplicates" }] : []), - ...(showDownloads ? [{ href: "/downloads", label: "Downloads" }] : []), - { href: AI_DOC_URL, label: "Use with AI", external: true }, - ]; + // The inline nav from lg: lib/nav.ts says which links this build has. + const cited = isSite && isCitedSite(site); + const navLinks = headerNavLinks({ + cited, + reports: isSite && hasReports(), + duplicates: !cited && hasDuplicates(), + downloads: !cited && hasArchives(), + }); // The sheet also takes Offline on a PWA-shipping site — gated exactly as // Footer.tsx gates it, so the two never disagree about whether this instance // has an offline mode. diff --git a/export/app/components/reports/MomentArticle.tsx b/export/app/components/reports/MomentArticle.tsx @@ -0,0 +1,164 @@ +import { VerificationBadge } from "yt-dlp-transcript-common/components/citations/VerificationBadge"; +import { VerdictChip } from "yt-dlp-transcript-common/components/report/VerdictChip"; +import { formatTimestamp } from "yt-dlp-transcript-common/lib/vtt"; +import { CITATION_KIND_LABELS, spanLabel, type MomentPageView } from "yt-dlp-transcript-common/lib/report/views"; +import { ExternalLinkText, Eyebrow, dateLabel, textLink } from "./parts"; + +// ONE CITED MOMENT, from its view (common/lib/report/views.ts): a span's +// self-hosted evidence clip (video, or audio with its poster) or a post's +// captured shot and text; the quote and what checking it found; the transcript +// lines around the span; the record's metadata and a link to the original at +// its time (and into the corpus on a full site); and every place on the site +// that cites it. + +export default function MomentArticle({ view }: { view: MomentPageView }) { + const r = view.record; + const isSpan = view.kind === "video" || view.kind === "audio"; + const span = isSpan && view.start !== undefined && view.end !== undefined ? spanLabel(view.start, view.end) : null; + const meta = [r.channelTitle ?? r.channel, dateLabel(view.date ?? r.date), span].filter(Boolean).join(" · "); + const title = r.title ?? (view.kind === "post" ? (view.post?.author ?? r.channelTitle ?? r.channel) : `${r.channel}/${r.id}`); + const clip = view.clip; + const padded = + clip && view.start !== undefined && view.end !== undefined && (clip.start < view.start || clip.end > view.end); + + return ( + <article data-moment={view.key} data-moment-kind={view.kind} className="mx-auto flex w-full max-w-3xl flex-col gap-6"> + <header className="flex flex-col gap-2"> + <Eyebrow>Cited {CITATION_KIND_LABELS[view.kind].toLowerCase()}</Eyebrow> + <h1 className="font-display text-2xl font-semibold leading-tight tracking-tight text-foreground sm:text-3xl"> + {title} + </h1> + {meta && <p className="font-mono text-xs text-muted-foreground">{meta}</p>} + </header> + + {clip && view.kind === "video" && ( + <video + data-evidence-clip="" + controls + playsInline + preload="metadata" + poster={clip.poster} + src={clip.src} + className="aspect-video w-full rounded-lg border border-border bg-black" + /> + )} + {clip && view.kind === "audio" && ( + <div className="flex flex-col gap-3"> + {clip.poster && ( + <img src={clip.poster} alt="" className="aspect-video w-full rounded-lg border border-border object-cover" /> + )} + <audio data-evidence-clip="" controls preload="metadata" src={clip.src} className="w-full" /> + </div> + )} + {clip && padded && ( + <p className="-mt-3 text-xs text-muted-foreground"> + The clip runs {spanLabel(clip.start, clip.end)}, a little either side of the cited {span} for context. + </p> + )} + {view.post?.shot && ( + <img + src={view.post.shot} + alt={`Screenshot of the post${view.post.author ? ` by ${view.post.author}` : ""}`} + className="w-full max-w-xl rounded-lg border border-border" + /> + )} + + <div className="flex flex-col gap-2"> + <blockquote className="border-l-4 border-brand pl-4 text-lg leading-relaxed text-foreground"> + {view.speaker && <span className="font-medium text-muted-foreground">{view.speaker}: </span>} + <q className="italic">{view.quote}</q> + </blockquote> + <VerificationBadge verification={view.verification} /> + </div> + + {view.post && view.post.text.trim() !== view.quote.trim() && ( + <div className="flex flex-col gap-1"> + <p className="font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground">The post</p> + <p className="whitespace-pre-line text-sm leading-relaxed text-foreground">{view.post.text}</p> + </div> + )} + {view.post?.media && view.post.media.length > 0 && ( + <ul className="flex flex-col gap-3"> + {view.post.media.map((m) => + m.kind === "image" ? ( + <li key={m.src}> + <img src={m.src} alt="" className="max-w-full rounded-md border border-border" /> + </li> + ) : ( + <li key={m.src}> + <video src={m.src} controls playsInline preload="metadata" className="w-full rounded-md border border-border" /> + </li> + ), + )} + </ul> + )} + + {view.cues && view.cues.length > 0 && ( + <section aria-labelledby="cues-heading" className="flex flex-col gap-2"> + <h2 id="cues-heading" className="font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground"> + Transcript + </h2> + <ol data-cue-lines="" className="flex flex-col rounded-lg border border-border bg-card text-sm"> + {view.cues.map((c, i) => ( + <li + key={`${c.start}-${i}`} + data-in-span={c.inSpan ? "" : undefined} + className={ + c.inSpan + ? "flex gap-3 bg-brand-soft px-3 py-1.5 text-foreground" + : "flex gap-3 px-3 py-1.5 text-muted-foreground" + } + > + <span className="w-14 shrink-0 font-mono text-xs leading-6 tabular-nums">{formatTimestamp(c.start)}</span> + <span className="leading-6">{c.text}</span> + </li> + ))} + </ol> + </section> + )} + + {(r.originalUrl || r.corpusUrl) && ( + <p className="flex flex-wrap gap-x-4 gap-y-1 text-sm"> + {r.originalUrl && ( + <ExternalLinkText href={r.originalUrl}> + {isSpan && view.start !== undefined ? `Original at ${formatTimestamp(view.start)}` : "Original post"} + </ExternalLinkText> + )} + {r.corpusUrl && ( + <a href={r.corpusUrl} className={textLink}> + Open in the archive + </a> + )} + </p> + )} + + {view.citedIn.length > 0 && ( + <section aria-labelledby="cited-in-heading" className="flex flex-col gap-2 border-t border-border pt-5"> + <h2 id="cited-in-heading" className="font-display text-xl font-semibold text-foreground"> + Cited in + </h2> + <ul data-cited-in="" className="flex flex-col gap-2"> + {view.citedIn.map((e) => ( + <li + key={`${e.reportId}/${e.sectionId}/${e.claimId}/${e.citationId}`} + className="flex flex-col gap-1 rounded-lg border border-border bg-card p-3 text-sm" + > + <a href={e.href} className={`font-medium ${textLink}`}> + {e.reportTitle} + </a> + <div className="flex flex-wrap items-center gap-2 text-muted-foreground"> + {e.verdict && e.verdictStyle && <VerdictChip verdict={e.verdict} style={e.verdictStyle} />} + {e.sectionTitle && <span>{e.sectionTitle}</span>} + {(e.claimTitle ?? e.claimText) && ( + <span className="text-foreground">— {e.claimTitle ?? e.claimText}</span> + )} + {e.number !== undefined && <span className="font-mono text-xs">[{e.number}]</span>} + </div> + </li> + ))} + </ul> + </section> + )} + </article> + ); +} diff --git a/export/app/components/reports/ReportArticle.tsx b/export/app/components/reports/ReportArticle.tsx @@ -0,0 +1,202 @@ +import { ArrowDownToLine } from "lucide-react"; +import { CitationCard } from "yt-dlp-transcript-common/components/citations/CitationCard"; +import { CitedMarkdown } from "yt-dlp-transcript-common/components/citations/CitedMarkdown"; +import { ReferenceList } from "yt-dlp-transcript-common/components/citations/ReferenceList"; +import { VerdictChip, VerdictTally } from "yt-dlp-transcript-common/components/report/VerdictChip"; +import { + orderedCitations, + verdictTally, + type CitationView, + type ClaimView, + type ReportPageView, + type SourceCitationView, +} from "yt-dlp-transcript-common/lib/report/views"; +import { ExternalLinkText, Eyebrow, SourceBlock, dateLabel, textLink } from "./parts"; + +// ONE REPORT, from its view (common/lib/report/views.ts): the header (title, +// subtitle, dates, the document under review with its archive links), a +// fact-check's tally, the summary, the sections and their claims — each claim +// its verdict, the document's own sentence (its still), the findings with +// their inline citations, and the evidence cards — then the numbered +// reference list the inline markers jump to, and the citations as files. + +const proseClass = "text-[0.95rem] leading-relaxed text-foreground"; + +function SourceSentence({ c }: { c: SourceCitationView }) { + return ( + <figure data-source-sentence={c.id} className="flex flex-col gap-1.5"> + {c.image ? ( + <img + src={c.image} + alt={c.quote} + loading="lazy" + className="w-full max-w-xl rounded-md border border-border bg-white object-contain object-left-top" + /> + ) : ( + <blockquote className="border-l-2 border-border pl-3 italic text-muted-foreground"> + <q>{c.quote}</q> + </blockquote> + )} + <figcaption className="flex flex-wrap items-baseline gap-x-3 gap-y-1 text-xs text-muted-foreground"> + <span>{c.source.title}</span> + {c.source.archives.map((a) => ( + <ExternalLinkText key={a.url} href={a.url} title={a.context}> + {a.label} + </ExternalLinkText> + ))} + </figcaption> + </figure> + ); +} + +function Claim({ + claim, + view, + subjectLabel, +}: { + claim: ClaimView; + view: ReportPageView; + subjectLabel: string; +}) { + const sentence = claim.sourceQuote ? view.citations[claim.sourceQuote] : undefined; + const evidence = claim.citations + .filter((id) => id !== claim.sourceQuote) + .map((id) => view.citations[id]) + .filter((c): c is CitationView => !!c); + return ( + <article + id={claim.id} + data-claim={claim.id} + className="flex scroll-mt-20 flex-col gap-3 rounded-lg border border-border bg-surface p-4 sm:p-5" + > + <div className="flex flex-wrap items-start gap-x-3 gap-y-2"> + {claim.verdict && <VerdictChip verdict={claim.verdict} style={view.verdicts[claim.verdict]} className="mt-0.5" />} + <h3 className="min-w-0 flex-1 basis-60 font-display text-lg font-semibold leading-snug text-foreground"> + <a href={`#${claim.id}`} className="hover:text-brand transition-colors"> + {claim.title ?? claim.text} + </a> + </h3> + </div> + {claim.title && ( + <p className="text-sm text-muted-foreground"> + {subjectLabel}: <q className="text-foreground">{claim.text}</q> + </p> + )} + {sentence?.kind === "source" && <SourceSentence c={sentence} />} + {claim.findings && ( + <CitedMarkdown citations={view.citations} className={proseClass}> + {claim.findings} + </CitedMarkdown> + )} + {evidence.length > 0 && ( + <div className="flex flex-col gap-2"> + <p className="font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground">Evidence</p> + <div className="grid gap-3 sm:grid-cols-2"> + {evidence.map((c) => ( + <CitationCard key={c.id} citation={c} /> + ))} + </div> + </div> + )} + </article> + ); +} + +export default function ReportArticle({ view }: { view: ReportPageView }) { + const isFactcheck = view.kind === "factcheck"; + const tally = isFactcheck ? verdictTally(view) : []; + const references = orderedCitations(view); + const subjectLabel = view.subject?.kind === "article" ? "The article says" : "The source says"; + const dates = [ + view.published ? `Published ${dateLabel(view.published)}` : null, + view.updated && view.updated !== view.published ? `Updated ${dateLabel(view.updated)}` : null, + ].filter(Boolean); + const claimCount = view.sections.reduce((n, s) => n + s.claims.length, 0); + + return ( + <article data-report={view.id} className="mx-auto flex w-full max-w-3xl flex-col gap-8"> + <header className="flex flex-col gap-3 border-b border-border pb-6"> + <Eyebrow>{isFactcheck ? "Fact-check" : "Report"}</Eyebrow> + <h1 className="font-display text-3xl font-semibold leading-tight tracking-tight text-foreground sm:text-4xl"> + {view.title} + </h1> + {view.subtitle && <p className="text-lg text-muted-foreground">{view.subtitle}</p>} + {dates.length > 0 && <p className="font-mono text-xs text-muted-foreground">{dates.join(" · ")}</p>} + {view.subject && <SourceBlock source={view.subject} label="Under review" />} + {tally.length > 0 && ( + <div className="flex flex-col gap-2"> + <p className="font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground"> + {claimCount} claim{claimCount === 1 ? "" : "s"} checked + </p> + <VerdictTally tally={tally} styles={view.verdicts} /> + </div> + )} + </header> + + {view.summary && ( + <CitedMarkdown citations={view.citations} className={proseClass}> + {view.summary} + </CitedMarkdown> + )} + + {view.sections.length > 1 && ( + <nav aria-label="Sections" className="rounded-lg border border-border p-4 text-sm"> + <ol className="flex list-decimal flex-col gap-1 pl-5 marker:text-muted-foreground"> + {view.sections.map((s) => ( + <li key={s.id}> + <a href={`#${s.id}`} className={textLink}> + {s.title} + </a> + {s.claims.length > 0 && ( + <span className="ml-2 font-mono text-xs text-muted-foreground"> + {s.claims.length} claim{s.claims.length === 1 ? "" : "s"} + </span> + )} + </li> + ))} + </ol> + </nav> + )} + + {view.sections.map((s) => ( + <section key={s.id} id={s.id} data-section={s.id} className="flex scroll-mt-20 flex-col gap-4"> + <h2 className="font-display text-2xl font-semibold tracking-tight text-foreground">{s.title}</h2> + {s.body && ( + <CitedMarkdown citations={view.citations} className={proseClass}> + {s.body} + </CitedMarkdown> + )} + {s.claims.map((c) => ( + <Claim key={c.id} claim={c} view={view} subjectLabel={subjectLabel} /> + ))} + </section> + ))} + + {references.length > 0 && ( + <section id="references" aria-labelledby="references-heading" className="flex scroll-mt-20 flex-col gap-2 border-t border-border pt-6"> + <h2 id="references-heading" className="font-display text-2xl font-semibold tracking-tight text-foreground"> + References + </h2> + <ReferenceList citations={references} /> + </section> + )} + + {(view.downloads?.json || view.downloads?.csv) && ( + <p data-citation-downloads="" className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground"> + <ArrowDownToLine className="size-4" aria-hidden /> + Download citations: + {view.downloads.json && ( + <a href={view.downloads.json} download className={textLink}> + JSON + </a> + )} + {view.downloads.csv && ( + <a href={view.downloads.csv} download className={textLink}> + CSV + </a> + )} + </p> + )} + </article> + ); +} diff --git a/export/app/components/reports/ReportIndex.tsx b/export/app/components/reports/ReportIndex.tsx @@ -0,0 +1,61 @@ +import Link from "next/link"; +import { VerdictTally } from "yt-dlp-transcript-common/components/report/VerdictChip"; +import type { ReportIndexView } from "yt-dlp-transcript-common/lib/report/views"; +import { EmptyState, Eyebrow, dateLabel } from "./parts"; + +// The report index: `/reports/`, and a cited site's home page. One card per +// published report, in the site's order (site.json `reports`). + +const plural = (n: number, noun: string) => `${n.toLocaleString()} ${noun}${n === 1 ? "" : "s"}`; + +export default function ReportIndex({ + index, + heading = "Reports", + tagline, +}: { + index: ReportIndexView | null; + heading?: string; + tagline?: string; +}) { + const reports = index?.reports ?? []; + if (reports.length === 0) { + return <EmptyState title="No reports">This site has not published a report yet.</EmptyState>; + } + return ( + <div className="mx-auto flex w-full max-w-3xl flex-col gap-6"> + <header className="flex flex-col gap-1"> + <h1 className="font-display text-3xl font-semibold tracking-tight text-foreground">{heading}</h1> + {tagline && <p className="text-sm text-muted-foreground">{tagline}</p>} + </header> + <ul data-report-index="" className="flex flex-col gap-4"> + {reports.map((r) => { + const dates = [ + r.published ? `Published ${dateLabel(r.published)}` : null, + r.updated && r.updated !== r.published ? `updated ${dateLabel(r.updated)}` : null, + ] + .filter(Boolean) + .join(" · "); + return ( + <li key={r.id} data-report-entry={r.id}> + <article className="flex flex-col gap-2 rounded-lg border border-border bg-card p-4 sm:p-5"> + <Eyebrow>{r.kind === "factcheck" ? "Fact-check" : "Report"}</Eyebrow> + <h2 className="font-display text-xl font-semibold leading-snug text-foreground"> + <Link href={r.href} className="hover:text-brand transition-colors"> + {r.title} + </Link> + </h2> + {r.subtitle && <p className="text-sm text-muted-foreground">{r.subtitle}</p>} + {r.tally && r.verdicts && <VerdictTally tally={r.tally} styles={r.verdicts} className="mt-1" />} + <p className="font-mono text-xs text-muted-foreground"> + {[dates, r.claimCount > 0 ? plural(r.claimCount, "claim") : null, plural(r.citationCount, "citation")] + .filter(Boolean) + .join(" · ")} + </p> + </article> + </li> + ); + })} + </ul> + </div> + ); +} diff --git a/export/app/components/reports/parts.tsx b/export/app/components/reports/parts.tsx @@ -0,0 +1,80 @@ +import { Archive, ExternalLink } from "lucide-react"; +import type { SourceView } from "yt-dlp-transcript-common/lib/report/views"; + +// Small pieces the report, index and moment pages share. + +// `2026-10-04T12:00:00Z` → `2026-10-04`; a partial date (`2026-10`) as given. +export function dateLabel(v: string | undefined): string | undefined { + if (!v) return undefined; + return /^\d{4}-\d{2}-\d{2}T/.test(v) ? v.slice(0, 10) : v; +} + +export function Eyebrow({ children }: { children: React.ReactNode }) { + return <p className="font-mono text-xs uppercase tracking-[0.18em] text-brand">{children}</p>; +} + +export const textLink = + "text-brand underline decoration-brand/40 underline-offset-2 hover:decoration-brand"; + +export function ExternalLinkText({ href, children, title }: { href: string; children: React.ReactNode; title?: string }) { + return ( + <a href={href} target="_blank" rel="noopener noreferrer" title={title} className={`inline-flex items-center gap-1 ${textLink}`}> + {children} + <ExternalLink className="size-3 shrink-0" aria-hidden /> + </a> + ); +} + +// A document under review: its title (linked), byline, note, and its archive +// links in the context the document gave them. +export function SourceBlock({ source, label }: { source: SourceView; label: string }) { + const byline = [source.publisher, source.author, dateLabel(source.date)].filter(Boolean).join(" · "); + return ( + <div data-subject-source={source.id} className="flex flex-col gap-1.5 rounded-lg border border-border bg-card p-4 text-sm"> + <p className="font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground">{label}</p> + <p className="font-medium leading-snug text-foreground"> + {source.url ? <ExternalLinkText href={source.url}>{source.title}</ExternalLinkText> : source.title} + </p> + {byline && <p className="font-mono text-xs text-muted-foreground">{byline}</p>} + {source.note && <p className="text-xs text-muted-foreground">{source.note}</p>} + {source.archives.length > 0 && ( + <ul className="mt-1 flex flex-col gap-1 text-xs"> + {source.archives.map((a) => ( + <li key={a.url} className="flex flex-wrap items-baseline gap-x-2"> + <ExternalLinkText href={a.url}> + <Archive className="size-3 shrink-0" aria-hidden /> + {a.label} + </ExternalLinkText> + {a.context && <span className="text-muted-foreground">— {a.context}</span>} + </li> + ))} + </ul> + )} + </div> + ); +} + +export function EmptyState({ title, children }: { title: string; children: React.ReactNode }) { + return ( + <div data-reports-empty="" className="mx-auto flex max-w-2xl flex-col gap-3"> + <h1 className="font-display text-2xl font-semibold text-foreground">{title}</h1> + <p className="text-sm text-muted-foreground">{children}</p> + </div> + ); +} + +// What a corpus-shaped route (search's chat, downloads, duplicates, offline) +// renders on a CITED site, which publishes only its reports and their moments. +// The route still exists in the static build (every route does); it shows +// nothing of the corpus and points at the reports. +export function NotOnCitedSite({ title }: { title: string }) { + return ( + <EmptyState title={title}> + This site publishes its reports only.{" "} + <a href="/reports/" className={textLink}> + Read the reports + </a> + . + </EmptyState> + ); +} diff --git a/export/app/downloads/page.tsx b/export/app/downloads/page.tsx @@ -3,6 +3,8 @@ import type { ArchiveManifestEntry } from "yt-dlp-transcript-common/lib/archiveO import { ArrowDownToLine, FileText, MessagesSquare } from "lucide-react"; import { currentSite } from "../lib/site"; import { readArchiveManifest } from "../lib/archives"; +import { isCitedSite } from "yt-dlp-transcript-common/lib/siteSchema"; +import { NotOnCitedSite } from "../components/reports/parts"; export const metadata: Metadata = { title: "Downloads" }; @@ -176,6 +178,7 @@ function ChannelRecord({ export default function DownloadsPage() { const site = currentSite(); + if (isCitedSite(site)) return <NotOnCitedSite title="Downloads" />; const manifest = readArchiveManifest(); const entries = manifest?.entries ?? []; diff --git a/export/app/duplicates/page.tsx b/export/app/duplicates/page.tsx @@ -4,10 +4,13 @@ import TranscriptModal from "yt-dlp-transcript-common/components/TranscriptModal import PostModal from "yt-dlp-transcript-common/components/PostModal"; import { currentSite } from "../lib/site"; import { DuplicatesClient } from "./DuplicatesClient"; +import { isCitedSite } from "yt-dlp-transcript-common/lib/siteSchema"; +import { NotOnCitedSite } from "../components/reports/parts"; export const metadata: Metadata = { title: "Duplicates" }; export default function DuplicatesPage() { + if (isCitedSite(currentSite())) return <NotOnCitedSite title="Duplicates" />; return ( <PlayerProvider features={{ transcriptDownloads: currentSite().transcriptDownloads !== false }} diff --git a/export/app/lib/nav.ts b/export/app/lib/nav.ts @@ -0,0 +1,30 @@ +import { AI_DOC_URL } from "yt-dlp-transcript-common/lib/project"; + +export type NavLink = { href: string; label: string; external?: boolean }; + +// The header's inline nav (and the mobile sheet's first links), from what this +// build has. Ask AI is new: the chat was only reachable from the workspace +// control or from Use with AI, which is not where anyone looks for it. Use +// with AI is the homepage's AI and MCP doc (release 16 slice DX): off this +// site, so a plain <a> in the same tab, not a client navigation. Reports +// shows when this build has a report (public/reports/index.json). +// +// A CITED site (site.json `publish: "cited"`) publishes only its reports and +// their moments: no search, no chat, no downloads or duplicates, and nothing +// for an AI to read — its nav is Reports alone. +export function headerNavLinks(has: { + cited: boolean; + reports: boolean; + duplicates: boolean; + downloads: boolean; +}): NavLink[] { + if (has.cited) return [{ href: "/reports/", label: "Reports" }]; + return [ + { href: "/", label: "Search" }, + { href: "/ask/", label: "Ask AI" }, + ...(has.reports ? [{ href: "/reports/", label: "Reports" }] : []), + ...(has.duplicates ? [{ href: "/duplicates", label: "Duplicates" }] : []), + ...(has.downloads ? [{ href: "/downloads", label: "Downloads" }] : []), + { href: AI_DOC_URL, label: "Use with AI", external: true }, + ]; +} diff --git a/export/app/lib/reports.test.ts b/export/app/lib/reports.test.ts @@ -0,0 +1,165 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import * as React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +// The report routes, rendered from the fixture report site's view JSON +// (export/fixtures/report-site): the never-empty params, the report page, a +// moment page per kind, the index, a cited site's home, and the header's nav. +// +// getPaths() is cached on first use, so the environment is set before any +// module that reads it is imported — every import below is dynamic. + +(globalThis as { React?: typeof React }).React = React; + +const root = fs.mkdtempSync(path.join(os.tmpdir(), "export-reports-")); +const publicDir = path.join(root, "public"); +const sitesDir = path.join(root, "transcripts", "sites"); +fs.mkdirSync(publicDir, { recursive: true }); +fs.mkdirSync(path.join(sitesDir, "demo-cited"), { recursive: true }); +fs.writeFileSync( + path.join(sitesDir, "demo-cited", "site.json"), + JSON.stringify({ siteTitle: "Demo Reports", publish: "cited", reports: ["demo-factcheck"] }), +); +process.env.EXPORT_PUBLIC_DIR = publicDir; +process.env.TRANSCRIPTS_DIR = path.join(root, "transcripts"); +process.env.SITES_DIR = sitesDir; +process.env.SITE_ID = "demo-cited"; +delete process.env.INSTANCE_MODE; + +const fixture = await import("../../fixtures/report-site/fixture"); +const reportPage = await import("../reports/[reportId]/page"); +const momentPage = await import("../m/[...moment]/page"); +const reportsIndexPage = await import("../reports/page"); +const home = await import("../(workspace)/page"); +const { headerNavLinks } = await import("./nav"); + +const render = (el: React.ReactElement) => renderToStaticMarkup(el); + +test("with no reports, each dynamic route builds its one placeholder page, which says so", async () => { + assert.deepEqual(reportPage.generateStaticParams(), [{ reportId: "_none" }]); + assert.deepEqual(momentPage.generateStaticParams(), [{ moment: ["_none"] }]); + const r = render(await reportPage.default({ params: Promise.resolve({ reportId: "_none" }) })); + assert.match(r, /No reports/); + const m = render(await momentPage.default({ params: Promise.resolve({ moment: ["_none"] }) })); + assert.match(m, /No moments/); + assert.match(render(reportsIndexPage.default()), /No reports/); +}); + +test("the fixture's view JSON is exactly what the builder makes of its report", () => { + for (const [urlPath, value] of Object.entries(fixture.buildFixtureFiles())) { + const onDisk = fs.readFileSync(fixture.fixtureFilePath(urlPath), "utf8"); + assert.equal(onDisk, fixture.serializeFixture(value), `${urlPath} is stale: run fixtures/report-site/write.ts`); + } +}); + +test("the fixture report cites all five kinds", () => { + const view = fixture.buildFixtureReportView(); + assert.deepEqual( + [...new Set(Object.values(view.citations).map((c) => c.kind))].sort(), + ["audio", "page", "post", "source", "video"], + ); +}); + +// From here on the public dir holds the fixture. +test("the fixture public dir is in place", () => { + fs.cpSync(fixture.FIXTURE_PUBLIC_DIR, publicDir, { recursive: true }); +}); + +test("params: every report and every moment, as the index files list them", () => { + assert.deepEqual(reportPage.generateStaticParams(), [{ reportId: "demo-factcheck" }]); + assert.deepEqual(momentPage.generateStaticParams(), [ + { moment: ["demo-channel", "abc123", "3126.00-3151.00"] }, + { moment: ["demo-podcast", "ep-042", "610.50-628.00"] }, + { moment: ["demo-social", "1234567890"] }, + ]); +}); + +test("the report page: header, tally, sections and claims, inline cites, references, downloads", async () => { + const params = Promise.resolve({ reportId: "demo-factcheck" }); + const html = render(await reportPage.default({ params })); + assert.match(html, /<h1[^>]*>Checking an example article<\/h1>/); + assert.ok(html.includes("Published 2026-10-01 · Updated 2026-10-04")); + assert.match(html, /data-subject-source="s0"/); + assert.ok(html.includes("as published on the day")); + assert.match(html, /data-verdict-tally=""/); + for (const v of ["CORROBORATED", "PARTLY", "CONTRADICTED", "UNTESTABLE"]) assert.match(html, new RegExp(`data-verdict="${v}"`)); + assert.match(html, /<section id="bridge"/); + assert.match(html, /<article id="claim-1"/); + // the source sentence's still, under the claim + assert.match(html, /data-source-sentence="a01"[^]*?<img src="\/reports\/demo-factcheck\/stills\/a01.png"/); + // inline citations carry their report numbers and jump to the reference list + assert.match(html, /data-inline-cite="c01"[^]*?href="#c-c01" data-cite-number="1"/); + assert.match(html, /data-inline-cite="w01"[^]*?data-cite-number="4"/); + // every numbered citation has its reference entry, in number order + const refs = [...html.matchAll(/<li id="c-([^"]+)" value="(\d+)"/g)].map((m) => `${m[2]}:${m[1]}`); + assert.deepEqual(refs, ["1:c01", "2:au1", "3:a01", "4:w01", "5:a02", "6:p01"]); + assert.match(html, /href="\/reports\/demo-factcheck\/citations.json" download=""/); + assert.match(html, /href="\/reports\/demo-factcheck\/citations.csv" download=""/); + const meta = await reportPage.generateMetadata({ params }); + assert.equal(meta.title, "Checking an example article"); +}); + +test("a video moment: the clip, quote, verification, cue lines, original link, cited in", async () => { + const html = render( + await momentPage.default({ params: Promise.resolve({ moment: ["demo-channel", "abc123", "3126.00-3151.00"] }) }), + ); + assert.match(html, /<video data-evidence-clip=""[^>]*poster="\/reports\/demo-factcheck\/stills\/poster.png"[^>]*src="\/media\/clips\/demo-channel\/abc123\/3126.00-3151.00.mp4"/); + assert.ok(html.includes("52:01–52:36"), "the padded clip's own span is stated"); + assert.ok(html.includes("The bridge opened in the spring of twenty nineteen, I was there for it.")); + assert.match(html, /data-quote-score="97"/); + assert.equal([...html.matchAll(/data-in-span=""/g)].length, 2); + assert.match(html, /href="https:\/\/media.example.org\/demo-channel\/abc123\?t=3126"[^>]*>Original at 52:06/); + assert.match(html, /data-cited-in=""/); + assert.match(html, /href="\/reports\/demo-factcheck\/"[^>]*>Checking an example article/); + assert.match(html, /href="\/reports\/demo-factcheck\/#claim-1"/); + assert.match(html, /href="\/reports\/demo-factcheck\/#claim-5"/); +}); + +test("an audio moment plays audio; a post moment shows its text and author", async () => { + const audio = render( + await momentPage.default({ params: Promise.resolve({ moment: ["demo-podcast", "ep-042", "610.50-628.00"] }) }), + ); + assert.match(audio, /<audio data-evidence-clip=""/); + assert.ok(audio.includes("The guest")); + const post = render(await momentPage.default({ params: Promise.resolve({ moment: ["demo-social", "1234567890"] }) })); + assert.match(post, /data-moment-kind="post"/); + assert.ok(post.includes("@demo_account")); + assert.ok(post.includes("Posted to settle it.")); + assert.ok(!post.includes("<video")); +}); + +test("an unknown moment or report under a listed route renders the empty state, never throws", async () => { + assert.match(render(await reportPage.default({ params: Promise.resolve({ reportId: "nope" }) })), /No reports/); + assert.match(render(await momentPage.default({ params: Promise.resolve({ moment: ["..", "x"] }) })), /No moments/); +}); + +test("the index lists the report with its tally", () => { + const html = render(reportsIndexPage.default()); + assert.match(html, /data-report-entry="demo-factcheck"/); + assert.ok(html.includes("5 claims · 6 citations")); +}); + +test("a cited site's home is the report index and never counts transcripts", async () => { + // There is no summaries manifest here: countTranscripts() would throw. + const html = render(await home.default()); + assert.match(html, /<h1[^>]*>Demo Reports<\/h1>/); + assert.match(html, /data-report-entry="demo-factcheck"/); +}); + +test("the header nav: Reports when the build has one; a cited site has Reports alone", () => { + const full = headerNavLinks({ cited: false, reports: true, duplicates: false, downloads: true }); + assert.deepEqual( + full.map((l) => l.label), + ["Search", "Ask AI", "Reports", "Downloads", "Use with AI"], + ); + assert.ok(!headerNavLinks({ cited: false, reports: false, duplicates: false, downloads: false }).some((l) => l.label === "Reports")); + assert.deepEqual(headerNavLinks({ cited: true, reports: true, duplicates: true, downloads: true }), [ + { href: "/reports/", label: "Reports" }, + ]); +}); + +test.after(() => fs.rmSync(root, { recursive: true, force: true })); diff --git a/export/app/lib/reports.ts b/export/app/lib/reports.ts @@ -0,0 +1,72 @@ +import fs from "node:fs"; +import path from "node:path"; +import { getPaths } from "yt-dlp-transcript-common/lib/paths"; +import { parseMomentKey } from "yt-dlp-transcript-common/lib/citations/moments"; +import { isReportId } from "yt-dlp-transcript-common/lib/report/schema"; +import { + MOMENT_INDEX_FORMAT, + MOMENT_PAGE_FORMAT, + MOMENTS_INDEX_PATH, + REPORT_INDEX_FORMAT, + REPORT_PAGE_FORMAT, + REPORTS_INDEX_PATH, + momentViewPath, + reportViewPath, + type MomentIndexView, + type MomentPageView, + type ReportIndexView, + type ReportPageView, +} from "yt-dlp-transcript-common/lib/report/views"; + +// The report and moment views compose's reports stage writes into public/ +// (common/lib/report/views.ts names every file), read at build time like +// lib/archives.ts reads the archive manifest. Every reader is tolerant: a file +// that is absent, unreadable or of another format reads as "none", so a site +// without reports builds exactly as before and a cited site with none renders +// its empty state. Read synchronously — a static export, evaluated once per +// build. + +function readView<T extends { format: string }>(urlPath: string, format: T["format"]): T | null { + try { + const file = path.join(getPaths().exportPublicDir, ...urlPath.split("/").filter(Boolean)); + const parsed = JSON.parse(fs.readFileSync(file, "utf8")) as T; + return parsed && parsed.format === format ? parsed : null; + } catch { + return null; + } +} + +export function readReportIndex(): ReportIndexView | null { + const v = readView<ReportIndexView>(REPORTS_INDEX_PATH, REPORT_INDEX_FORMAT); + return v && Array.isArray(v.reports) ? v : null; +} + +// Whether this build has a report to link to: drives the header's Reports link. +export function hasReports(): boolean { + return (readReportIndex()?.reports.length ?? 0) > 0; +} + +// The report ids to build pages for, in index order (only well-formed ids: +// each is a path segment). +export function reportIds(): string[] { + return (readReportIndex()?.reports ?? []).map((r) => r.id).filter(isReportId); +} + +export function readReportView(reportId: string): ReportPageView | null { + if (!isReportId(reportId)) return null; + const v = readView<ReportPageView>(reportViewPath(reportId), REPORT_PAGE_FORMAT); + return v && v.id === reportId && Array.isArray(v.sections) ? v : null; +} + +// The moment keys to build pages for (only keys exactly as momentKey spells +// them: each segment is a path segment of a page written to disk). +export function momentKeys(): string[] { + const v = readView<MomentIndexView>(MOMENTS_INDEX_PATH, MOMENT_INDEX_FORMAT); + return (Array.isArray(v?.moments) ? v.moments : []).filter((k) => parseMomentKey(k) !== null); +} + +export function readMomentView(key: string): MomentPageView | null { + if (!parseMomentKey(key)) return null; + const v = readView<MomentPageView>(momentViewPath(key), MOMENT_PAGE_FORMAT); + return v && v.key === key ? v : null; +} diff --git a/export/app/m/[...moment]/page.tsx b/export/app/m/[...moment]/page.tsx @@ -0,0 +1,40 @@ +import type { Metadata } from "next"; +import { MOMENT_PLACEHOLDER_KEY } from "yt-dlp-transcript-common/lib/report/views"; +import MomentArticle from "../../components/reports/MomentArticle"; +import { EmptyState } from "../../components/reports/parts"; +import { momentKeys, readMomentView } from "../../lib/reports"; + +// /m/<channel>/<id>/<start>-<end>/ (a span) and /m/<channel>/<id>/ (a post): +// one cited moment, from /m/<key>/moment.json. The key's segments are the +// route's (common/lib/citations/moments.ts). Static: every moment the site's +// reports cite is listed, nothing else resolves; a site with none builds the +// one placeholder page. +export const dynamicParams = false; + +export function generateStaticParams(): { moment: string[] }[] { + const keys = momentKeys(); + return (keys.length > 0 ? keys : [MOMENT_PLACEHOLDER_KEY]).map((k) => ({ moment: k.split("/") })); +} + +type Params = { params: Promise<{ moment: string[] }> }; + +// Next hands a catch-all's segments back URL-encoded on some paths; a key's +// segments are plain ASCII, so decoding is lossless. +async function keyOf(params: Params["params"]): Promise<string> { + return (await params).moment.map((s) => decodeURIComponent(s)).join("/"); +} + +export async function generateMetadata({ params }: Params): Promise<Metadata> { + const view = readMomentView(await keyOf(params)); + if (!view) return { title: "No moments" }; + const who = view.record.title ?? view.record.channelTitle ?? view.record.channel; + return { title: `“${view.quote.length > 60 ? `${view.quote.slice(0, 57)}…` : view.quote}” — ${who}` }; +} + +export default async function MomentPage({ params }: Params) { + const view = readMomentView(await keyOf(params)); + if (!view) { + return <EmptyState title="No moments">No report on this site cites a moment yet.</EmptyState>; + } + return <MomentArticle view={view} />; +} diff --git a/export/app/offline/page.tsx b/export/app/offline/page.tsx @@ -5,6 +5,8 @@ import { getSettings } from "yt-dlp-transcript-common/lib/settings"; import { postsVisibleTo } from "yt-dlp-transcript-common/lib/postsVisibility"; import { currentSite } from "../lib/site"; import { OfflineManager, type OfflineChannel } from "../components/OfflineManager"; +import { isCitedSite } from "yt-dlp-transcript-common/lib/siteSchema"; +import { NotOnCitedSite } from "../components/reports/parts"; export const metadata: Metadata = { title: "Offline", @@ -16,6 +18,7 @@ export const metadata: Metadata = { // transcript shards through the service worker. export default async function OfflinePage() { const site = currentSite(); + if (isCitedSite(site)) return <NotOnCitedSite title="Offline" />; const paths = getPaths(); const settings = getSettings(); // The members this build publishes: an X channel is left out of a public diff --git a/export/app/reports/[reportId]/page.tsx b/export/app/reports/[reportId]/page.tsx @@ -0,0 +1,36 @@ +import type { Metadata } from "next"; +import { REPORT_PLACEHOLDER_ID } from "yt-dlp-transcript-common/lib/report/views"; +import ReportArticle from "../../components/reports/ReportArticle"; +import { EmptyState } from "../../components/reports/parts"; +import { readReportView, reportIds } from "../../lib/reports"; + +// /reports/<reportId>/: one report, from /reports/<reportId>/page.json. +// Static: every published report is listed, nothing else resolves. A site with +// none builds the one placeholder page (a static export refuses an empty +// generateStaticParams), which says so. +export const dynamicParams = false; + +export function generateStaticParams(): { reportId: string }[] { + const ids = reportIds(); + return (ids.length > 0 ? ids : [REPORT_PLACEHOLDER_ID]).map((reportId) => ({ reportId })); +} + +type Params = { params: Promise<{ reportId: string }> }; + +export async function generateMetadata({ params }: Params): Promise<Metadata> { + const view = readReportView((await params).reportId); + if (!view) return { title: "No reports" }; + return { title: view.title, ...(view.subtitle ? { description: view.subtitle } : {}) }; +} + +export default async function ReportPage({ params }: Params) { + const view = readReportView((await params).reportId); + if (!view) { + return ( + <EmptyState title="No reports"> + This site has not published a report yet. + </EmptyState> + ); + } + return <ReportArticle view={view} />; +} diff --git a/export/app/reports/page.tsx b/export/app/reports/page.tsx @@ -0,0 +1,11 @@ +import type { Metadata } from "next"; +import ReportIndex from "../components/reports/ReportIndex"; +import { readReportIndex } from "../lib/reports"; + +export const metadata: Metadata = { title: "Reports" }; + +// The report index, from /reports/index.json (common/lib/report/views.ts). A +// cited site renders the same index as its home page. +export default function ReportsPage() { + return <ReportIndex index={readReportIndex()} />; +} diff --git a/export/fixtures/report-site/fixture.ts b/export/fixtures/report-site/fixture.ts @@ -0,0 +1,207 @@ +// A fixture report site: one fact-check report citing all five citation kinds +// (video, audio, post, source, page), and the views compose's reports stage +// would write for it (common/lib/report/views.ts) — the report index, the +// report's page view, the moment index and one moment view per cited moment. +// +// The views are BUILT from source/demo-factcheck/report.json by the same +// builder compose uses, with a fixed resolver standing in for the corpus; +// `write.ts` writes them under public/, and fixture.test.ts holds the written +// files equal to a fresh build, so the JSON can never drift from the builder. +// +// Everything here is neutral demo data. + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { buildCitedIn } from "yt-dlp-transcript-common/lib/report/citedIn"; +import { parseReport } from "yt-dlp-transcript-common/lib/report/validate"; +import type { Report } from "yt-dlp-transcript-common/lib/report/schema"; +import { momentKeyOf, parseMomentKey, type SpanMoment } from "yt-dlp-transcript-common/lib/citations/moments"; +import { + MOMENT_INDEX_FORMAT, + MOMENT_PAGE_FORMAT, + MOMENTS_INDEX_PATH, + REPORT_INDEX_FORMAT, + REPORT_VIEWS_VERSION, + REPORTS_INDEX_PATH, + buildReportPageView, + citedInViews, + evidenceClipPath, + momentViewPath, + reportCitationsDownloadPath, + reportIndexEntry, + reportViewPath, + type MomentPageView, + type RecordView, + type ReportIndexView, + type ReportPageView, +} from "yt-dlp-transcript-common/lib/report/views"; + +export const FIXTURE_DIR = path.dirname(fileURLToPath(import.meta.url)); +export const FIXTURE_PUBLIC_DIR = path.join(FIXTURE_DIR, "public"); +export const FIXTURE_REPORT_ID = "demo-factcheck"; + +const RECORDS: Record<string, RecordView> = { + "demo-channel/abc123": { + channel: "demo-channel", + channelTitle: "Demo Channel", + id: "abc123", + title: "Demo stream: the bridge, the move, and the mail", + date: "2026-01-10", + platform: "youtube", + }, + "demo-podcast/ep-042": { + channel: "demo-podcast", + channelTitle: "Demo Podcast", + id: "ep-042", + title: "Episode 42: a guest looks back", + date: "2026-02-02", + }, + "demo-social/1234567890": { + channel: "demo-social", + channelTitle: "Demo Social", + id: "1234567890", + date: "2025-03-14", + platform: "x", + }, +}; + +const POSTER = "/reports/demo-factcheck/stills/poster.png"; + +export function readFixtureReport(): Report { + const raw = JSON.parse( + fs.readFileSync(path.join(FIXTURE_DIR, "source", FIXTURE_REPORT_ID, "report.json"), "utf8"), + ); + const parsed = parseReport(raw); + if (!parsed.ok || parsed.problems.length > 0) { + throw new Error(`fixture report is not sound: ${JSON.stringify(parsed.problems)}`); + } + return parsed.value; +} + +function recordFor(channel: string, id: string, seconds?: number): RecordView { + const r = RECORDS[`${channel}/${id}`]; + if (!r) throw new Error(`fixture: no record ${channel}/${id}`); + const base = `https://media.example.org/${channel}/${id}`; + return { ...r, originalUrl: seconds === undefined ? base : `${base}?t=${Math.floor(seconds)}` }; +} + +export function buildFixtureReportView(report = readFixtureReport()): ReportPageView { + return buildReportPageView(report, { + record: (c) => recordFor(c.channel, c.id, c.kind === "post" ? undefined : c.start), + poster: (c) => (c.kind === "video" ? POSTER : undefined), + post: (c) => ({ + author: "@demo_account", + text: `${c.quote}\n\nPosted to settle it.`, + }), + downloads: { + json: reportCitationsDownloadPath(report.id, "json"), + csv: reportCitationsDownloadPath(report.id, "csv"), + }, + }); +} + +// The cue lines around each fixture span: two lines either side. +const CUES: Record<string, { start: number; end: number; text: string }[]> = { + "demo-channel/abc123": [ + { start: 3110, end: 3118, text: "Okay so somebody asked about the bridge again." }, + { start: 3118, end: 3126, text: "Let me be really clear about this one." }, + { start: 3126, end: 3138, text: "The bridge opened in the spring of twenty nineteen," }, + { start: 3138, end: 3151, text: "I was there for it." }, + { start: 3151, end: 3160, text: "Anyway, back to the mail." }, + { start: 3160, end: 3170, text: "Next letter is from a long-time viewer." }, + ], + "demo-podcast/ep-042": [ + { start: 600, end: 610.5, text: "So when did you first go back there?" }, + { start: 610.5, end: 628, text: "Like I said before, spring twenty nineteen." }, + { start: 628, end: 640, text: "And I have been back once since." }, + ], +}; + +export function buildFixtureMomentViews( + report = readFixtureReport(), + view = buildFixtureReportView(report), +): MomentPageView[] { + const citedIn = buildCitedIn([report]); + const out: MomentPageView[] = []; + const seen = new Set<string>(); + for (const [id, c] of Object.entries(report.citations ?? {})) { + const key = momentKeyOf(c); + if (!key || seen.has(key) || !view.citations[id]) continue; + seen.add(key); + const m = parseMomentKey(key)!; + const entries = citedInViews(citedIn[key] ?? [], [view]); + if (c.kind === "video" || c.kind === "audio") { + const span = m as SpanMoment; + const before = c.pad?.before ?? 0; + const after = c.pad?.after ?? 0; + out.push({ + format: MOMENT_PAGE_FORMAT, + version: REPORT_VIEWS_VERSION, + key, + kind: c.kind, + record: recordFor(c.channel, c.id, span.start), + quote: c.quote, + ...(c.speaker ? { speaker: c.speaker } : {}), + ...(c.verification ? { verification: c.verification } : {}), + start: span.start, + end: span.end, + clip: { + src: evidenceClipPath(span), + start: Math.max(0, span.start - before), + end: span.end + after, + ...(c.kind === "video" ? { poster: POSTER } : {}), + }, + cues: (CUES[`${c.channel}/${c.id}`] ?? []).map((q) => ({ + ...q, + inSpan: q.end > span.start && q.start < span.end, + })), + citedIn: entries, + }); + } else if (c.kind === "post") { + out.push({ + format: MOMENT_PAGE_FORMAT, + version: REPORT_VIEWS_VERSION, + key, + kind: "post", + record: recordFor(c.channel, c.id), + quote: c.quote, + ...(c.date ? { date: c.date } : {}), + post: { author: "@demo_account", text: `${c.quote}\n\nPosted to settle it.` }, + citedIn: entries, + }); + } + } + return out; +} + +// Every file of the fixture's public dir, by site-root path, as JSON values. +export function buildFixtureFiles(): Record<string, unknown> { + const report = readFixtureReport(); + const view = buildFixtureReportView(report); + const moments = buildFixtureMomentViews(report, view); + const index: ReportIndexView = { + format: REPORT_INDEX_FORMAT, + version: REPORT_VIEWS_VERSION, + reports: [reportIndexEntry(view)], + }; + const files: Record<string, unknown> = { + [REPORTS_INDEX_PATH]: index, + [reportViewPath(view.id)]: view, + [MOMENTS_INDEX_PATH]: { + format: MOMENT_INDEX_FORMAT, + version: REPORT_VIEWS_VERSION, + moments: moments.map((m) => m.key), + }, + }; + for (const m of moments) files[momentViewPath(m.key)] = m; + return files; +} + +export function fixtureFilePath(urlPath: string): string { + return path.join(FIXTURE_PUBLIC_DIR, ...urlPath.split("/").filter(Boolean)); +} + +export function serializeFixture(value: unknown): string { + return `${JSON.stringify(value, null, 2)}\n`; +} diff --git a/export/fixtures/report-site/public/m/demo-channel/abc123/3126.00-3151.00/moment.json b/export/fixtures/report-site/public/m/demo-channel/abc123/3126.00-3151.00/moment.json @@ -0,0 +1,111 @@ +{ + "format": "archilyzer-moment-page", + "version": 1, + "key": "demo-channel/abc123/3126.00-3151.00", + "kind": "video", + "record": { + "channel": "demo-channel", + "channelTitle": "Demo Channel", + "id": "abc123", + "title": "Demo stream: the bridge, the move, and the mail", + "date": "2026-01-10", + "platform": "youtube", + "originalUrl": "https://media.example.org/demo-channel/abc123?t=3126" + }, + "quote": "The bridge opened in the spring of twenty nineteen, I was there for it.", + "verification": { + "quoteScore": 0.97, + "quoteCheckedAt": "2026-10-04T12:00:00Z", + "voiceChecked": true, + "method": "cue-window v1" + }, + "start": 3126, + "end": 3151, + "clip": { + "src": "/media/clips/demo-channel/abc123/3126.00-3151.00.mp4", + "start": 3121, + "end": 3156, + "poster": "/reports/demo-factcheck/stills/poster.png" + }, + "cues": [ + { + "start": 3110, + "end": 3118, + "text": "Okay so somebody asked about the bridge again.", + "inSpan": false + }, + { + "start": 3118, + "end": 3126, + "text": "Let me be really clear about this one.", + "inSpan": false + }, + { + "start": 3126, + "end": 3138, + "text": "The bridge opened in the spring of twenty nineteen,", + "inSpan": true + }, + { + "start": 3138, + "end": 3151, + "text": "I was there for it.", + "inSpan": true + }, + { + "start": 3151, + "end": 3160, + "text": "Anyway, back to the mail.", + "inSpan": false + }, + { + "start": 3160, + "end": 3170, + "text": "Next letter is from a long-time viewer.", + "inSpan": false + } + ], + "citedIn": [ + { + "reportId": "demo-factcheck", + "sectionId": null, + "claimId": null, + "citationId": "c01", + "href": "/reports/demo-factcheck/", + "reportTitle": "Checking an example article", + "number": 1 + }, + { + "reportId": "demo-factcheck", + "sectionId": "bridge", + "claimId": "claim-1", + "citationId": "c01", + "href": "/reports/demo-factcheck/#claim-1", + "reportTitle": "Checking an example article", + "sectionTitle": "The bridge", + "claimText": "He opened the bridge himself in 2018.", + "verdict": "CONTRADICTED", + "verdictStyle": { + "label": "Contradicted", + "color": "#e5534b" + }, + "number": 1 + }, + { + "reportId": "demo-factcheck", + "sectionId": "moving", + "claimId": "claim-5", + "citationId": "c01", + "href": "/reports/demo-factcheck/#claim-5", + "reportTitle": "Checking an example article", + "sectionTitle": "Moving away", + "claimText": "He was at the opening.", + "verdict": "CORROBORATED", + "verdictStyle": { + "label": "Corroborated", + "color": "#3fbf7f" + }, + "number": 1 + } + ] +} diff --git a/export/fixtures/report-site/public/m/demo-podcast/ep-042/610.50-628.00/moment.json b/export/fixtures/report-site/public/m/demo-podcast/ep-042/610.50-628.00/moment.json @@ -0,0 +1,74 @@ +{ + "format": "archilyzer-moment-page", + "version": 1, + "key": "demo-podcast/ep-042/610.50-628.00", + "kind": "audio", + "record": { + "channel": "demo-podcast", + "channelTitle": "Demo Podcast", + "id": "ep-042", + "title": "Episode 42: a guest looks back", + "date": "2026-02-02", + "originalUrl": "https://media.example.org/demo-podcast/ep-042?t=610" + }, + "quote": "Like I said before, spring twenty nineteen.", + "speaker": "The guest", + "verification": { + "quoteScore": 0.88, + "quoteCheckedAt": "2026-10-04T12:00:00Z" + }, + "start": 610.5, + "end": 628, + "clip": { + "src": "/media/clips/demo-podcast/ep-042/610.50-628.00.mp4", + "start": 610.5, + "end": 628 + }, + "cues": [ + { + "start": 600, + "end": 610.5, + "text": "So when did you first go back there?", + "inSpan": false + }, + { + "start": 610.5, + "end": 628, + "text": "Like I said before, spring twenty nineteen.", + "inSpan": true + }, + { + "start": 628, + "end": 640, + "text": "And I have been back once since.", + "inSpan": false + } + ], + "citedIn": [ + { + "reportId": "demo-factcheck", + "sectionId": null, + "claimId": null, + "citationId": "au1", + "href": "/reports/demo-factcheck/", + "reportTitle": "Checking an example article", + "number": 2 + }, + { + "reportId": "demo-factcheck", + "sectionId": "bridge", + "claimId": "claim-2", + "citationId": "au1", + "href": "/reports/demo-factcheck/#claim-2", + "reportTitle": "Checking an example article", + "sectionTitle": "The bridge", + "claimText": "He went back to the bridge every year.", + "verdict": "PARTLY", + "verdictStyle": { + "label": "Partly true", + "color": "#e3b23c" + }, + "number": 2 + } + ] +} diff --git a/export/fixtures/report-site/public/m/demo-social/1234567890/moment.json b/export/fixtures/report-site/public/m/demo-social/1234567890/moment.json @@ -0,0 +1,38 @@ +{ + "format": "archilyzer-moment-page", + "version": 1, + "key": "demo-social/1234567890", + "kind": "post", + "record": { + "channel": "demo-social", + "channelTitle": "Demo Social", + "id": "1234567890", + "date": "2025-03-14", + "platform": "x", + "originalUrl": "https://media.example.org/demo-social/1234567890" + }, + "quote": "Never said I would move. Not once.", + "date": "2025-03-14", + "post": { + "author": "@demo_account", + "text": "Never said I would move. Not once.\n\nPosted to settle it." + }, + "citedIn": [ + { + "reportId": "demo-factcheck", + "sectionId": "moving", + "claimId": "claim-3", + "citationId": "p01", + "href": "/reports/demo-factcheck/#claim-3", + "reportTitle": "Checking an example article", + "sectionTitle": "Moving away", + "claimText": "He has said many times that he would move away.", + "verdict": "CONTRADICTED", + "verdictStyle": { + "label": "Contradicted", + "color": "#e5534b" + }, + "number": 6 + } + ] +} diff --git a/export/fixtures/report-site/public/m/index.json b/export/fixtures/report-site/public/m/index.json @@ -0,0 +1,9 @@ +{ + "format": "archilyzer-moment-index", + "version": 1, + "moments": [ + "demo-channel/abc123/3126.00-3151.00", + "demo-podcast/ep-042/610.50-628.00", + "demo-social/1234567890" + ] +} diff --git a/export/fixtures/report-site/public/reports/demo-factcheck/page.json b/export/fixtures/report-site/public/reports/demo-factcheck/page.json @@ -0,0 +1,241 @@ +{ + "format": "archilyzer-report-page", + "version": 1, + "id": "demo-factcheck", + "kind": "factcheck", + "title": "Checking an example article", + "subtitle": "Four claims about a demo channel, tested against what was said on air", + "summary": "The article makes four claims. The recordings support one, partly support another, and contradict a third; the fourth cannot be tested. The host said the opening date out loud [on stream](cite:c01), and later [repeated it](cite:au1).", + "published": "2026-10-01", + "updated": "2026-10-04", + "subject": { + "id": "s0", + "kind": "article", + "title": "An example article about a demo channel", + "url": "https://example.org/articles/demo", + "publisher": "Example Gazette", + "author": "A. Writer", + "date": "2026-09-20", + "archives": [ + { + "label": "archive.org", + "url": "https://web.archive.org/web/2026/https://example.org/articles/demo", + "context": "as published on the day" + } + ] + }, + "verdicts": { + "CORROBORATED": { + "label": "Corroborated", + "color": "#3fbf7f" + }, + "PARTLY": { + "label": "Partly true", + "color": "#e3b23c" + }, + "CONTRADICTED": { + "label": "Contradicted", + "color": "#e5534b" + }, + "NOT_FOUND": { + "label": "Not found", + "color": "#8b93a7" + }, + "UNTESTABLE": { + "label": "Untestable", + "color": "#7d8fd6" + } + }, + "citations": { + "c01": { + "kind": "video", + "id": "c01", + "number": 1, + "quote": "The bridge opened in the spring of twenty nineteen, I was there for it.", + "verification": { + "quoteScore": 0.97, + "quoteCheckedAt": "2026-10-04T12:00:00Z", + "voiceChecked": true, + "method": "cue-window v1" + }, + "moment": "demo-channel/abc123/3126.00-3151.00", + "href": "/m/demo-channel/abc123/3126.00-3151.00/", + "start": 3126, + "end": 3151, + "record": { + "channel": "demo-channel", + "channelTitle": "Demo Channel", + "id": "abc123", + "title": "Demo stream: the bridge, the move, and the mail", + "date": "2026-01-10", + "platform": "youtube", + "originalUrl": "https://media.example.org/demo-channel/abc123?t=3126" + }, + "poster": "/reports/demo-factcheck/stills/poster.png" + }, + "au1": { + "kind": "audio", + "id": "au1", + "number": 2, + "quote": "Like I said before, spring twenty nineteen.", + "speaker": "The guest", + "verification": { + "quoteScore": 0.88, + "quoteCheckedAt": "2026-10-04T12:00:00Z" + }, + "moment": "demo-podcast/ep-042/610.50-628.00", + "href": "/m/demo-podcast/ep-042/610.50-628.00/", + "start": 610.5, + "end": 628, + "record": { + "channel": "demo-podcast", + "channelTitle": "Demo Podcast", + "id": "ep-042", + "title": "Episode 42: a guest looks back", + "date": "2026-02-02", + "originalUrl": "https://media.example.org/demo-podcast/ep-042?t=610" + } + }, + "a01": { + "kind": "source", + "id": "a01", + "number": 3, + "quote": "He opened the bridge himself in 2018.", + "source": { + "id": "s0", + "kind": "article", + "title": "An example article about a demo channel", + "url": "https://example.org/articles/demo", + "publisher": "Example Gazette", + "author": "A. Writer", + "date": "2026-09-20", + "archives": [ + { + "label": "archive.org", + "url": "https://web.archive.org/web/2026/https://example.org/articles/demo", + "context": "as published on the day" + } + ] + }, + "image": "/reports/demo-factcheck/stills/a01.png", + "href": "https://example.org/articles/demo" + }, + "w01": { + "kind": "page", + "id": "w01", + "number": 4, + "quote": "Opened to traffic: April 2019.", + "note": "The city's own record of the opening.", + "href": "https://example.org/city/bridge-history", + "title": "Bridge history — city records", + "archiveUrl": "https://web.archive.org/web/2026/https://example.org/city/bridge-history" + }, + "a02": { + "kind": "source", + "id": "a02", + "number": 5, + "quote": "He has said many times that he would move away.", + "source": { + "id": "s0", + "kind": "article", + "title": "An example article about a demo channel", + "url": "https://example.org/articles/demo", + "publisher": "Example Gazette", + "author": "A. Writer", + "date": "2026-09-20", + "archives": [ + { + "label": "archive.org", + "url": "https://web.archive.org/web/2026/https://example.org/articles/demo", + "context": "as published on the day" + } + ] + }, + "href": "https://example.org/articles/demo" + }, + "p01": { + "kind": "post", + "id": "p01", + "number": 6, + "quote": "Never said I would move. Not once.", + "date": "2025-03-14", + "moment": "demo-social/1234567890", + "href": "/m/demo-social/1234567890/", + "record": { + "channel": "demo-social", + "channelTitle": "Demo Social", + "id": "1234567890", + "date": "2025-03-14", + "platform": "x", + "originalUrl": "https://media.example.org/demo-social/1234567890" + }, + "author": "@demo_account", + "text": "Never said I would move. Not once.\n\nPosted to settle it." + } + }, + "sections": [ + { + "id": "bridge", + "title": "The bridge", + "body": "The article's first chapter is about the bridge.", + "claims": [ + { + "id": "claim-1", + "text": "He opened the bridge himself in 2018.", + "verdict": "CONTRADICTED", + "sourceQuote": "a01", + "findings": "He was there, but the bridge opened in 2019: he says so [on stream](cite:c01), and the [city's record](cite:w01) agrees.", + "citations": [ + "c01", + "w01" + ] + }, + { + "id": "claim-2", + "text": "He went back to the bridge every year.", + "verdict": "PARTLY", + "findings": "He mentions going back [once](cite:au1); nothing in the recordings says every year.", + "citations": [ + "au1" + ] + } + ] + }, + { + "id": "moving", + "title": "Moving away", + "claims": [ + { + "id": "claim-3", + "text": "He has said many times that he would move away.", + "verdict": "CONTRADICTED", + "sourceQuote": "a02", + "findings": "He [denies it outright](cite:p01).", + "citations": [ + "p01" + ] + }, + { + "id": "claim-4", + "text": "He privately regrets it.", + "verdict": "UNTESTABLE", + "findings": "Nothing public speaks to a private feeling.", + "citations": [] + }, + { + "id": "claim-5", + "text": "He was at the opening.", + "verdict": "CORROBORATED", + "findings": "He says [he was there](cite:c01).", + "citations": [ + "c01" + ] + } + ] + } + ], + "downloads": { + "json": "/reports/demo-factcheck/citations.json", + "csv": "/reports/demo-factcheck/citations.csv" + } +} diff --git a/export/fixtures/report-site/public/reports/demo-factcheck/stills/a01.png b/export/fixtures/report-site/public/reports/demo-factcheck/stills/a01.png Binary files differ. diff --git a/export/fixtures/report-site/public/reports/demo-factcheck/stills/poster.png b/export/fixtures/report-site/public/reports/demo-factcheck/stills/poster.png Binary files differ. diff --git a/export/fixtures/report-site/public/reports/index.json b/export/fixtures/report-site/public/reports/index.json @@ -0,0 +1,57 @@ +{ + "format": "archilyzer-report-index", + "version": 1, + "reports": [ + { + "id": "demo-factcheck", + "kind": "factcheck", + "title": "Checking an example article", + "subtitle": "Four claims about a demo channel, tested against what was said on air", + "published": "2026-10-01", + "updated": "2026-10-04", + "href": "/reports/demo-factcheck/", + "citationCount": 6, + "claimCount": 5, + "tally": [ + { + "verdict": "CORROBORATED", + "count": 1 + }, + { + "verdict": "PARTLY", + "count": 1 + }, + { + "verdict": "CONTRADICTED", + "count": 2 + }, + { + "verdict": "UNTESTABLE", + "count": 1 + } + ], + "verdicts": { + "CORROBORATED": { + "label": "Corroborated", + "color": "#3fbf7f" + }, + "PARTLY": { + "label": "Partly true", + "color": "#e3b23c" + }, + "CONTRADICTED": { + "label": "Contradicted", + "color": "#e5534b" + }, + "NOT_FOUND": { + "label": "Not found", + "color": "#8b93a7" + }, + "UNTESTABLE": { + "label": "Untestable", + "color": "#7d8fd6" + } + } + } + ] +} diff --git a/export/fixtures/report-site/source/demo-factcheck/report.json b/export/fixtures/report-site/source/demo-factcheck/report.json @@ -0,0 +1,129 @@ +{ + "format": "archilyzer-report", + "version": 1, + "id": "demo-factcheck", + "kind": "factcheck", + "title": "Checking an example article", + "subtitle": "Four claims about a demo channel, tested against what was said on air", + "summary": "The article makes four claims. The recordings support one, partly support another, and contradict a third; the fourth cannot be tested. The host said the opening date out loud [on stream](cite:c01), and later [repeated it](cite:au1).", + "published": "2026-10-01", + "updated": "2026-10-04", + "subject": { "source": "s0" }, + "sources": { + "s0": { + "kind": "article", + "title": "An example article about a demo channel", + "url": "https://example.org/articles/demo", + "publisher": "Example Gazette", + "author": "A. Writer", + "date": "2026-09-20", + "archives": [ + { "label": "archive.org", "url": "https://web.archive.org/web/2026/https://example.org/articles/demo", "context": "as published on the day" } + ] + } + }, + "citations": { + "c01": { + "kind": "video", + "channel": "demo-channel", + "id": "abc123", + "start": 3126, + "end": 3151, + "pad": { "before": 5, "after": 5 }, + "quote": "The bridge opened in the spring of twenty nineteen, I was there for it.", + "verification": { "quoteScore": 0.97, "quoteCheckedAt": "2026-10-04T12:00:00Z", "voiceChecked": true, "method": "cue-window v1" } + }, + "au1": { + "kind": "audio", + "channel": "demo-podcast", + "id": "ep-042", + "start": 610.5, + "end": 628, + "quote": "Like I said before, spring twenty nineteen.", + "speaker": "The guest", + "verification": { "quoteScore": 0.88, "quoteCheckedAt": "2026-10-04T12:00:00Z" } + }, + "p01": { + "kind": "post", + "channel": "demo-social", + "id": "1234567890", + "quote": "Never said I would move. Not once.", + "date": "2025-03-14" + }, + "a01": { + "kind": "source", + "source": "s0", + "quote": "He opened the bridge himself in 2018.", + "image": "stills/a01.png" + }, + "a02": { + "kind": "source", + "source": "s0", + "quote": "He has said many times that he would move away." + }, + "w01": { + "kind": "page", + "url": "https://example.org/city/bridge-history", + "title": "Bridge history — city records", + "archiveUrl": "https://web.archive.org/web/2026/https://example.org/city/bridge-history", + "quote": "Opened to traffic: April 2019.", + "note": "The city's own record of the opening." + }, + "unused": { + "kind": "page", + "url": "https://example.org/unused", + "quote": "Not cited anywhere." + } + }, + "sections": [ + { + "id": "bridge", + "title": "The bridge", + "body": "The article's first chapter is about the bridge.", + "claims": [ + { + "id": "claim-1", + "text": "He opened the bridge himself in 2018.", + "verdict": "CONTRADICTED", + "sourceQuote": { "citation": "a01" }, + "findings": "He was there, but the bridge opened in 2019: he says so [on stream](cite:c01), and the [city's record](cite:w01) agrees.", + "citations": ["c01", "w01"] + }, + { + "id": "claim-2", + "text": "He went back to the bridge every year.", + "verdict": "PARTLY", + "findings": "He mentions going back [once](cite:au1); nothing in the recordings says every year.", + "citations": ["au1"] + } + ] + }, + { + "id": "moving", + "title": "Moving away", + "claims": [ + { + "id": "claim-3", + "text": "He has said many times that he would move away.", + "verdict": "CONTRADICTED", + "sourceQuote": { "citation": "a02" }, + "findings": "He [denies it outright](cite:p01).", + "citations": ["p01"] + }, + { + "id": "claim-4", + "text": "He privately regrets it.", + "verdict": "UNTESTABLE", + "findings": "Nothing public speaks to a private feeling." + }, + { + "id": "claim-5", + "text": "He was at the opening.", + "verdict": "CORROBORATED", + "findings": "He says [he was there](cite:c01).", + "citations": ["c01"] + } + ] + } + ] +} diff --git a/export/fixtures/report-site/write.ts b/export/fixtures/report-site/write.ts @@ -0,0 +1,18 @@ +// Writes the fixture report site's view JSON under public/ (see fixture.ts). +// Run after changing the fixture report or the view builder: +// +// pnpm --filter export exec tsx fixtures/report-site/write.ts +// +// The stills (public/reports/demo-factcheck/stills/*.png) are committed images, +// not generated here. + +import fs from "node:fs"; +import path from "node:path"; +import { buildFixtureFiles, fixtureFilePath, serializeFixture } from "./fixture"; + +for (const [urlPath, value] of Object.entries(buildFixtureFiles())) { + const file = fixtureFilePath(urlPath); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, serializeFixture(value)); + console.log(urlPath); +}