Archilyzer · Source

archilyzer

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

commit 0d905bb76fbf805a83c68479711c61aa2858a8ef
parent af2e7c2f1dcc973965ee5b817ea6c8ae0541c2d9
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon,  5 Oct 2026 13:55:59 -0400

reports: the title carries the reviewed document's byline; ours ("Fact-check by <site>") sits under it

- views.ts: reportByline (subject source's author · publisher, the URL on the
  publisher else the author), reportKindLabel, reportAttribution — pure, for
  report.html to reuse
- ReportName takes children that flow after the title; ReportArticle puts the
  byline (data-report-byline) in the h1, then data-report-attribution and the
  dates a step apart, then the subtitle; the kind eyebrow leaves the report
  header (index cards unchanged)
- export lib/reports.ts: reportSiteTitle (none on the hub), passed by both pages
- unit + report-site e2e assertions; changelog

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

Diffstat:
Mcommon/lib/report/views.test.ts | 28++++++++++++++++++++++++++++
Mcommon/lib/report/views.ts | 31+++++++++++++++++++++++++++++++
Mexport/CHANGELOG.md | 2+-
Mexport/app/(workspace)/page.tsx | 4++--
Mexport/app/components/reports/ReportArticle.tsx | 59++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mexport/app/components/reports/parts.tsx | 23+++++++++++++++++++----
Mexport/app/lib/reports.test.ts | 21+++++++++++++++++----
Mexport/app/lib/reports.ts | 9+++++++++
Mexport/app/reports/[reportId]/page.tsx | 4++--
Mexport/e2e-report/report-site.spec.ts | 13+++++++++++--
10 files changed, 172 insertions(+), 22 deletions(-)

diff --git a/common/lib/report/views.test.ts b/common/lib/report/views.test.ts @@ -18,6 +18,8 @@ import { momentViewPath, orderedCitations, reportAssetPath, + reportAttribution, + reportByline, reportIndexEntry, reportFullTitle, sourceAnchor, @@ -25,6 +27,8 @@ import { spanLabel, verdictTally, type RecordView, + type ReportPageView, + type SourceView, } from "./views"; const report: Report = { @@ -307,3 +311,27 @@ test("a claim's flag rides its view; a claim without one carries none", () => { assert.equal(v.sections[0].claims[0].flag, "No source given"); assert.equal("flag" in view.sections[0].claims[0], false); }); + +test("a report's byline comes from its subject: the URL links the publisher, else the author", () => { + const src = (s: Partial<SourceView>): Pick<ReportPageView, "subject" | "sources"> => ({ + subject: "s0", + sources: { s0: { id: "s0", kind: "article", title: "T", archives: [], ...s } }, + }); + const url = "https://example.test/a"; + assert.deepEqual(reportByline(src({ author: "A. Writer", publisher: "Gazette", url })), { + author: { text: "A. Writer" }, + publisher: { text: "Gazette", href: url }, + }); + assert.deepEqual(reportByline(src({ author: "A. Writer", url })), { author: { text: "A. Writer", href: url } }); + assert.deepEqual(reportByline(src({ author: "A. Writer" })), { author: { text: "A. Writer" } }); + assert.deepEqual(reportByline(src({ publisher: "Gazette", url })), { publisher: { text: "Gazette", href: url } }); + assert.equal(reportByline(src({ url })), undefined, "neither: no byline"); + assert.equal(reportByline({ sources: {} }), undefined, "no subject: no byline"); +}); + +test("the page's own attribution names the kind and the site; with no site title, the kind alone", () => { + assert.equal(reportAttribution("factcheck", "Demo Reports"), "Fact-check by Demo Reports"); + assert.equal(reportAttribution("sweep", "Demo Reports"), "Report by Demo Reports"); + assert.equal(reportAttribution("factcheck"), "Fact-check"); + assert.equal(reportAttribution("sweep", " "), "Report"); +}); diff --git a/common/lib/report/views.ts b/common/lib/report/views.ts @@ -587,6 +587,37 @@ export function reportFullTitle(r: { series?: string; title: string }): string { return r.series ? `${r.series}: ${r.title}` : r.title; } +// A report's byline, from the document under review (its `subject` source): +// "by <author> · <publisher>". The source's URL links the publisher, else the +// author. Neither an author nor a publisher → no byline. +export type BylinePart = { text: string; href?: string }; +export type ReportByline = { author?: BylinePart; publisher?: BylinePart }; + +export function reportByline(view: Pick<ReportPageView, "subject" | "sources">): ReportByline | undefined { + const source = view.subject ? view.sources[view.subject] : undefined; + const author = source?.author?.trim(); + const publisher = source?.publisher?.trim(); + if (!source || (!author && !publisher)) return undefined; + const href = source.url || undefined; + return defined({ + author: author ? defined({ text: author, href: publisher ? undefined : href }) : undefined, + publisher: publisher ? defined({ text: publisher, href }) : undefined, + }); +} + +// A report kind's label: "Fact-check" or "Report". +export function reportKindLabel(kind: ReportKind): string { + return kind === "factcheck" ? "Fact-check" : "Report"; +} + +// The page's own attribution, set apart from the reviewed document's byline: +// "Fact-check by <site title>" ("Report by …"); with no site title (the hub), +// the kind's label alone. +export function reportAttribution(kind: ReportKind, siteTitle?: string): string { + const by = siteTitle?.trim(); + return by ? `${reportKindLabel(kind)} by ${by}` : reportKindLabel(kind); +} + // The report's entry in the index. export function reportIndexEntry(view: ReportPageView): ReportIndexEntry { const tally = view.kind === "factcheck" ? verdictTally(view) : undefined; diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md @@ -1,7 +1,7 @@ # Changelog ## [Unreleased] -- **A report's claim can carry a flag, and a site with one report names it in the browser tab.** `report.json` claim `flag` (one line, at most 60 characters) shows as a small pill in the accent colour beside the claim's verdict, e.g. "No source given". On a report-only site with one report, the home page's tab title is the report's, as on the report's own page, where it was the site's title alone. Needs a rebuild and deploy of the site. +- **A report's claim can carry a flag, its title a byline, and a site with one report names it in the browser tab.** `report.json` claim `flag` (one line, at most 60 characters) shows as a small pill in the accent colour beside the claim's verdict, e.g. "No source given". On a report-only site with one report, the home page's tab title is the report's, as on the report's own page, where it was the site's title alone. A report's page follows its title with a byline from the document under review, "by <author> · <publisher>", in the same line when it fits; the publisher (or, with none, the author) links to the document. Under it, a step apart, the page names its own: "Fact-check by <site title>" ("Report by …"), then the dates, then the subtitle; the kind's label no longer sits above the title on the report's page. Needs a rebuild and deploy of the site. - **A report-only site with one report opens on that report.** Its home page is the report itself, its header links nothing, and `/reports/` forwards home: there is no index of one. With more reports the home page is the list, without repeating the site's title under the header; a list entry is the report's name, subtitle and dates (its counts and tally are on its page). Pages a report-only site does not have link home. - **A report can belong to a series.** `report.json` `series` is shown on its own line above the report's title, in the accent colour, in place of the kind's label ("Fact-check"); a page title, a cited-in link, `llms.txt` and the MCP name it `<series>: <title>`. - **`pnpm start:export` serves a built site's moment pages.** It used `serve`, which listed a video or audio moment's directory (`3126.00-3151.00`) instead of serving its page; it now runs `export/scripts/serve-out.mjs`, which serves directories as Cloudflare Pages does, on `EXPORT_DEV_PORT` (3000). diff --git a/export/app/(workspace)/page.tsx b/export/app/(workspace)/page.tsx @@ -2,7 +2,7 @@ import type { Metadata } from "next"; import { isCitedSite } from "yt-dlp-transcript-common/lib/siteSchema"; import { currentSite } from "../lib/site"; import { instanceMode } from "../lib/mode"; -import { onlyReportView, readReportIndex, reportMetadata } from "../lib/reports"; +import { onlyReportView, readReportIndex, reportMetadata, reportSiteTitle } from "../lib/reports"; import NewSinceLastVisit from "../components/NewSinceLastVisit"; import HubHome from "../components/hub/HubHome"; import ReportIndex from "../components/reports/ReportIndex"; @@ -37,7 +37,7 @@ export default async function Home() { // heading of its own: the header already names the site. if (isCitedSite(settings)) { const only = onlyReportView(); - if (only) return <ReportArticle view={only} />; + if (only) return <ReportArticle view={only} siteTitle={reportSiteTitle()} />; return <ReportIndex index={readReportIndex()} heading={null} tagline={settings.homeTagline} />; } const { countTranscripts } = await import("yt-dlp-transcript-common/lib/transcripts"); diff --git a/export/app/components/reports/ReportArticle.tsx b/export/app/components/reports/ReportArticle.tsx @@ -7,17 +7,22 @@ import { VerdictChip, VerdictTally } from "yt-dlp-transcript-common/components/r import type { SourceArchive } from "yt-dlp-transcript-common/lib/citations/schema"; import { orderedCitations, + reportAttribution, + reportByline, sourceAnchor, verdictTally, + type BylinePart, type CitationView, type ClaimView, + type ReportByline, type ReportPageView, type SourceCitationView, } from "yt-dlp-transcript-common/lib/report/views"; -import { ArchiveList, Eyebrow, ReportName, SourceBlock, dateLabel, textLink } from "./parts"; +import { ArchiveList, ReportName, 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 +// ONE REPORT, from its view (common/lib/report/views.ts): the header (title and +// the reviewed document's byline, then ours — "Fact-check by <site>" — and the +// dates, the subtitle, the document under review with its archive links), a // fact-check's tally, the summary, the sections and their claims — each claim // its verdict and flag, the document's own sentence (its still), the findings with // their inline citations, and the evidence cards — then the numbered @@ -53,6 +58,35 @@ function SourceSentence({ c, archives }: { c: SourceCitationView; archives: read ); } +// The byline after the report's title, from the document under review: +// "by <author> · <publisher>", the document's URL on the publisher (else the +// author). Each part keeps its words together; the line breaks between them. +function Byline({ byline }: { byline: ReportByline }) { + const part = (p: BylinePart) => + p.href ? ( + <a href={p.href} target="_blank" rel="noopener noreferrer" className={textLink}> + {p.text} + </a> + ) : ( + p.text + ); + return ( + <span + data-report-byline="" + className="font-sans text-base font-normal tracking-normal text-muted-foreground sm:text-lg" + > + {byline.author && ( + <span className="whitespace-nowrap"> + by {part(byline.author)} + {byline.publisher ? " ·" : ""} + </span> + )} + {byline.author && byline.publisher ? " " : null} + {byline.publisher && <span className="whitespace-nowrap">{part(byline.publisher)}</span>} + </span> + ); +} + function Claim({ claim, view, @@ -114,10 +148,13 @@ function Claim({ ); } -export default function ReportArticle({ view }: { view: ReportPageView }) { +// `siteTitle` names whose report it is ("Fact-check by <site>"): the site's +// title, absent on the hub. +export default function ReportArticle({ view, siteTitle }: { view: ReportPageView; siteTitle?: string }) { const isFactcheck = view.kind === "factcheck"; const tally = isFactcheck ? verdictTally(view) : []; const references = orderedCitations(view); + const byline = reportByline(view); const subject = view.subject ? view.sources[view.subject] : undefined; const subjectLabel = subject?.kind === "article" ? "The article says" : "The source says"; const dates = [ @@ -135,12 +172,20 @@ export default function ReportArticle({ view }: { view: ReportPageView }) { <CitationsProvider citations={view.citations}> <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"> - {!view.series && <Eyebrow>{isFactcheck ? "Fact-check" : "Report"}</Eyebrow>} <h1 className="font-display text-3xl font-semibold leading-tight tracking-tight text-foreground sm:text-4xl"> - <ReportName series={view.series} title={view.title} /> + <ReportName series={view.series} title={view.title}> + {byline && <Byline byline={byline} />} + </ReportName> </h1> + {/* Ours, set a step apart from the reviewed document's byline above; + the kind is named here, so the header carries no kind eyebrow. */} + <div className="mt-1 flex flex-col gap-1"> + <p data-report-attribution="" className="text-sm text-foreground sm:text-base"> + {reportAttribution(view.kind, siteTitle)} + </p> + {dates.length > 0 && <p className="font-mono text-xs text-muted-foreground">{dates.join(" · ")}</p>} + </div> {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>} {subject && <SourceBlock source={subject} label="Under review" />} {tally.length > 0 && ( <div className="flex flex-col gap-2"> diff --git a/export/app/components/reports/parts.tsx b/export/app/components/reports/parts.tsx @@ -20,15 +20,30 @@ export function Eyebrow({ children }: { children: React.ReactNode }) { // the accent (the tag, like a wordmark's lead) and the title the next, in // regular weight — both inside the heading, so its name reads "<series> // <title>"; without one, the title alone (the kind's label is then the -// eyebrow above). -export function ReportName({ series, title }: { series?: string; title: string }) { - if (!series) return <>{title}</>; +// eyebrow above). `children` (the report page's byline) follow the title in +// its own text flow: on its line when they fit, wrapping under it when not. +export function ReportName({ + series, + title, + children, +}: { + series?: string; + title: string; + children?: React.ReactNode; +}) { + const name = ( + <> + {title} + {children ? <> {children}</> : null} + </> + ); + if (!series) return name; return ( <> <span data-report-series="" className="block font-semibold text-brand"> {series} </span>{" "} - <span className="block font-normal">{title}</span> + <span className="block font-normal">{name}</span> </> ); } diff --git a/export/app/lib/reports.test.ts b/export/app/lib/reports.test.ts @@ -81,8 +81,18 @@ test("params: every report and every moment, as the index files list them", () = 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")); + // the title, then the byline from the document under review in the same flow, + // the publisher linked to the document + assert.match( + html, + /<h1[^>]*>Checking an example article <span data-report-byline=""[^>]*><span class="whitespace-nowrap">by A. Writer ·<\/span> <span class="whitespace-nowrap"><a href="https:\/\/example.org\/articles\/demo"[^>]*>Example Gazette<\/a><\/span><\/span><\/h1>/, + ); + // then ours, a step apart: the kind by the site, the dates under it, then the subtitle + assert.match( + html, + /<\/h1><div[^>]*><p data-report-attribution=""[^>]*>Fact-check by Demo Reports<\/p><p[^>]*>Published 2026-10-01 · Updated 2026-10-04<\/p><\/div><p[^>]*>Four claims about a demo channel/, + ); + assert.doesNotMatch(html.slice(0, html.indexOf("<h1")), /Fact-check/, "no kind eyebrow above the title"); assert.match(html, /data-subject-source="s0"/); assert.ok(html.includes("as published on the day")); // the document's archive links are listed once, collapsed, under its anchor @@ -161,10 +171,12 @@ test("one report: /reports/ is no index, it forwards to the home page (the repor test("a cited site with one report: its home IS the report, and never counts transcripts", async () => { // There is no summaries manifest here: countTranscripts() would throw. const html = render(await home.default()); - assert.match(html, /<h1[^>]*>Checking an example article<\/h1>/); + assert.match(html, /<h1[^>]*>Checking an example article <span data-report-byline=""/); assert.match(html, /data-claim=/); assert.doesNotMatch(html, /data-report-index/); - assert.doesNotMatch(html, /Demo Reports/, "the site title is the header's, not the page's"); + // the site title is the header's; the page names it only as whose report it is + assert.deepEqual([...html.matchAll(/Demo Reports/g)].length, 1); + assert.match(html, /<p data-report-attribution=""[^>]*>Fact-check by Demo Reports<\/p>/); // its tab names the report as the report's own page does const reportMeta = await reportPage.generateMetadata({ params: Promise.resolve({ reportId: "demo-factcheck" }) }); assert.deepEqual(home.generateMetadata(), reportMeta); @@ -184,6 +196,7 @@ test("a cited site with more than one report: its home is the index, with no hea assert.match(html, /data-report-entry="demo-second"/); assert.doesNotMatch(html, /<h1/, "no heading: the header already names the site"); assert.deepEqual(home.generateMetadata(), {}, "the index keeps the site's own title"); + assert.doesNotMatch(html, /data-report-byline/, "an index card is a link: no byline"); // With more than one, /reports/ is the index, tally and all. const idx = render(reportsIndexPage.default()); assert.match(idx, /data-report-entry="demo-factcheck"/); diff --git a/export/app/lib/reports.ts b/export/app/lib/reports.ts @@ -2,6 +2,8 @@ import fs from "node:fs"; import path from "node:path"; import type { Metadata } from "next"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; +import { instanceMode } from "./mode"; +import { currentSite } from "./site"; import { parseMomentKey } from "yt-dlp-transcript-common/lib/citations/moments"; import { isReportId } from "yt-dlp-transcript-common/lib/report/schema"; import { @@ -67,6 +69,13 @@ export function onlyReportView(): ReportPageView | null { return ids.length === 1 ? readReportView(ids[0]) : null; } +// Whose report it is: the site's title, as its tab names it. The hub has no +// site of its own to name. +export function reportSiteTitle(): string | undefined { + if (instanceMode() === "hub") return undefined; + return currentSite().siteTitle.trim() || undefined; +} + // A report's page metadata — its page's, and a one-report cited site's home's, // so the tab reads the same on both: the title as one line names it // (`<series>: <title>`, the layout's template adds the site), the subtitle as diff --git a/export/app/reports/[reportId]/page.tsx b/export/app/reports/[reportId]/page.tsx @@ -2,7 +2,7 @@ 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, reportMetadata } from "../../lib/reports"; +import { readReportView, reportIds, reportMetadata, reportSiteTitle } from "../../lib/reports"; // /reports/<reportId>/: one report, from /reports/<reportId>/page.json. // Static: every published report is listed, nothing else resolves. A site with @@ -32,5 +32,5 @@ export default async function ReportPage({ params }: Params) { </EmptyState> ); } - return <ReportArticle view={view} />; + return <ReportArticle view={view} siteTitle={reportSiteTitle()} />; } diff --git a/export/e2e-report/report-site.spec.ts b/export/e2e-report/report-site.spec.ts @@ -27,7 +27,16 @@ async function expectImageLoaded(img: Locator): Promise<void> { test("one report: the home page IS the report, and the header links nothing", async ({ page }) => { await page.goto("/"); - await expect(page.locator("h1")).toHaveText("Checking an example article"); + await expect(page.locator("h1")).toHaveText("Checking an example article by A. Writer · Example Gazette"); + // The byline, in the title's flow: the publisher links the document under review. + const byline = page.locator("h1 [data-report-byline]"); + await expect(byline).toHaveText("by A. Writer · Example Gazette"); + await expect(byline.getByRole("link", { name: "Example Gazette" })).toHaveAttribute( + "href", + "https://example.org/articles/demo", + ); + // Ours, under it: the kind by this site. + await expect(page.locator("[data-report-attribution]")).toHaveText("Fact-check by Demo Reports"); await expect(page.locator("article[data-claim]")).toHaveCount(5); // The tab names the report, as the report's own page does. await expect(page).toHaveTitle("Checking an example article — Demo Reports"); @@ -38,7 +47,7 @@ test("one report: the home page IS the report, and the header links nothing", as test("one report: /reports/ is no index, it forwards to the home page", async ({ page }) => { await page.goto("/reports/"); await expect(page).toHaveURL("/"); - await expect(page.locator("h1")).toHaveText("Checking an example article"); + await expect(page.locator("h1")).toContainText("Checking an example article"); }); test("the report: verdict chips, the tally, the source's sentence as a still, numbered inline citations", async ({ page }) => {