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:
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 }) => {