commit 59f6c64adae60f045e3f226b6eedf7854778a197
parent 797bb1f21a17e3f2d2676c51b4fc32eb671de4e7
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 5 Oct 2026 16:47:26 -0400
reports: the header is the name, one date line, and the document as a card
The report page's header is now its series (else its title); one small mono
line of dates and the revision ("2026-10-04 · updated 2026-10-05 · revision
1", the revision linked to the history, "edited since revision N" when it
changed after); a card for the document under review (its title linked to
it, author · publisher · date, its archive links folded away) on the
document's rail with no label and no note; then the subtitle. The inline
byline, the "Fact-check by <site>" line and the separate "Under review"
block are gone; the card keeps the document's anchor and
data-subject-source. The HTML/PDF and Markdown exports mirror it: the
heading, the date line with the export's revision, the document as a cited
line.
reportByline, reportAttribution, reportKindLabel and reportSiteTitle are
removed with the exports' siteTitle option; reportDateParts,
reportRevisionLabel and subjectMetaParts replace them. Tests, the e2e report
spec (plus a 375 px no-sideways-scroll check), the unreleased changelog
bullets, REPORT.md's series row and the report-sites plan follow.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
18 files changed, 279 insertions(+), 284 deletions(-)
diff --git a/REPORT.md b/REPORT.md
@@ -18,7 +18,7 @@ Regenerate this file with `pnpm --filter yt-dlp-transcript-common exec tsx bin/f
| `version` | yes | `1`. |
| `id` | yes | The report's id: a lowercase slug (`[a-z0-9][a-z0-9-]*`, at most 64), its directory name under `reports/` and the last segment of its page, `/reports/<id>/`. Must match the directory. |
| `kind` | yes | `"factcheck"` — sections of claims, each with a verdict — or `"sweep"` — sections with no verdicts, or bodies with inline citations. |
-| `series` | no | The series the report belongs to: a recurring name for its kind of report. It is shown on its own line above the title, in the accent colour, in place of the kind's label, and is joined to it as `<series>: <title>` where one line names the report (a page title, a link, a cited-in entry). |
+| `series` | no | The series the report belongs to: a recurring name for its kind of report. It heads the report's page in place of its title, is shown on its own line above the title, in the accent colour, in place of the kind's label, in the report list, and is joined to it as `<series>: <title>` where one line names the report (a page title, a link, a cited-in entry). |
| `title` | yes | The report's title. |
| `subtitle` | no | A line under the title. |
| `summary` | no | The report's summary, in markdown, shown before the sections. May cite inline: `[label](cite:<id>)`. |
diff --git a/common/lib/report/export.test.ts b/common/lib/report/export.test.ts
@@ -84,7 +84,6 @@ const view = buildReportPageView(report, {
const PNG = "data:image/png;base64,iVBORw0KGgo=";
const opts: ReportExportHtmlOptions = {
siteUrl: "https://reports.example.org/",
- siteTitle: "Demo Site",
footer: { date: "2026-10-04", reportSha256: "0123456789abcdef".repeat(4) },
image: () => PNG,
clip: (c) => ({ href: `https://reports.example.org/media/clips/${c.moment}.mp4`, kind: c.kind }),
@@ -103,21 +102,29 @@ test("the HTML export is deterministic, holds no script, and every image is a da
assert.doesNotMatch(html, /<link |@import|url\(/);
});
-test("the heading: the series on its own line, the title with the document's byline, then ours", () => {
+test("the heading: the series (else the title), the dates and revision, the document as a cited line", () => {
const html = reportExportHtml(view, opts);
assert.match(
html,
- /<h1><span class="series" data-report-series="">Demo Checks<\/span><span class="title">A demo fact-check <span class="by" data-report-byline="">by A\. Writer · <a href="https:\/\/example\.org\/a">Example Gazette<\/a><\/span><\/span><\/h1>/,
+ /<header class="report-head"><h1>Demo Checks<\/h1>\n<p class="dates" data-report-dates="">2026-10-01 · updated 2026-10-04<\/p>\n<p class="subject" data-subject-source="s0" style="border-left-color:#aa3300"><a href="https:\/\/example\.org\/a">An article<\/a> <span class="meta">— A\. Writer · Example Gazette<\/span><\/p>\n<p class="subtitle">Two claims, tested<\/p><\/header>/,
);
- assert.match(html, /<p class="site-line" data-report-attribution="">Fact-check by Demo Site<\/p>\n<p class="dates">Published 2026-10-01 · Updated 2026-10-04<\/p>\n<p class="subtitle">Two claims, tested<\/p>/);
- assert.match(html, /<title>Demo Checks: A demo fact-check<\/title>/);
- assert.doesNotMatch(html, /class="eyebrow"/);
+ assert.match(html, /<title>Demo Checks: A demo fact-check<\/title>/, "the title stays the document's name");
+ assert.doesNotMatch(html, /data-report-attribution|data-report-byline|Under review|Fact-check by|class="eyebrow"/);
+ // No series: the title is the heading.
+ assert.match(reportExportHtml({ ...view, series: undefined }, opts), /<h1>A demo fact-check<\/h1>/);
+ // No subject: no cited line.
+ assert.doesNotMatch(reportExportHtml({ ...view, subject: undefined }, opts), /class="subject"/);
});
-test("the byline: the author alone takes the link when there is no publisher", () => {
- const html = reportExportHtml({ ...view, sources: { s0: { ...view.sources.s0, publisher: undefined } } }, opts);
- assert.match(html, /by <a href="https:\/\/example\.org\/a">A\. Writer<\/a>/);
- assert.doesNotMatch(reportExportHtml({ ...view, subject: undefined }, opts), /data-report-byline/);
+test("the heading's revision: this export's, linked to the history on the site", () => {
+ const history = { revision: 2, date: "2026-10-04", href: "/reports/demo/history/", current: true };
+ const withRev = { ...opts, footer: { ...opts.footer, revision: 2 } };
+ assert.match(
+ reportExportHtml({ ...view, history }, withRev),
+ /<p class="dates" data-report-dates="">2026-10-01 · updated 2026-10-04 · <a href="https:\/\/reports\.example\.org\/reports\/demo\/history\/">revision 2<\/a><\/p>/,
+ );
+ assert.match(reportExportHtml({ ...view, history }, { ...withRev, siteUrl: undefined }), /updated 2026-10-04 · revision 2<\/p>/);
+ assert.match(reportExportMarkdown({ ...view, history }, withRev), /^2026-10-01 · updated 2026-10-04 · \[revision 2\]\(https:\/\/reports\.example\.org\/reports\/demo\/history\/\)$/m);
});
test("the three tiers: the quick take, what the check found, every claim from how it was checked", () => {
@@ -145,7 +152,7 @@ test("a claim: its flag, the subject's sentence on the accent rail with no link,
assert.match(claim, /data-verdict="CONTRADICTED"[^]*<span class="flag" data-claim-flag=""><svg class="mark"[^>]*>[^]*<\/svg>No source given<\/span><h3>One<\/h3>/);
assert.match(claim, /<figure class="sentence" data-source-sentence="s1" style="border-left-color:#aa3300">/);
assert.doesNotMatch(claim, /from <a href="#source-s0">/, "the rail says whose sentence it is");
- assert.match(html, /<div class="under-review" style="border-left-color:#aa3300">/);
+ assert.match(html, /<p class="subject" data-subject-source="s0" style="border-left-color:#aa3300">/, "the heading's cited line wears the same rail");
// Added first, marked; the subject's own folded under "In the article".
assert.match(claim, /<li data-evidence="v1"><a href="#c-v1">\[1\]<\/a> <span class="added" data-citation-added=""><svg class="mark"[^]*?<\/svg>Not in the article<\/span> Video/);
assert.match(claim, /<details class="given" data-subject-evidence=""><summary>In the article \(1\)<\/summary><ul class="evidence"><li data-evidence="a1">/);
@@ -173,9 +180,8 @@ test("verdicts, inline markers [n] to the reference list, and references with ev
});
test("without a site URL nothing links to the site; the pack's clip plays in place", () => {
- const html = reportExportHtml(view, { ...opts, siteUrl: undefined, siteTitle: undefined, clip: undefined });
+ const html = reportExportHtml(view, { ...opts, siteUrl: undefined, clip: undefined });
assert.doesNotMatch(html, /reports\.example\.org/);
- assert.match(html, /<p class="site-line" data-report-attribution="">Fact-check<\/p>/);
const pack = reportExportHtml(view, { ...opts, image: () => "media/x.png", clip: () => ({ href: "media/clips/a.mp4", kind: "video", play: true }) });
assert.match(pack, /<video controls preload="none" src="media\/clips\/a\.mp4"><\/video>/);
assert.match(pack, /<img src="media\/x\.png"/);
@@ -206,8 +212,8 @@ test("the Markdown export: the heading lines, `label [n]` citations, numbered re
assert.equal(reportExportMarkdown(view, opts), md);
assert.ok(
md.startsWith(
- "**Demo Checks**\n\n# A demo fact-check\n\nby A. Writer · [Example Gazette](https://example.org/a)\n\n" +
- "Fact-check by Demo Site · Published 2026-10-01 · Updated 2026-10-04\n\n*Two claims, tested*\n",
+ "# Demo Checks\n\n2026-10-01 · updated 2026-10-04\n\n" +
+ "[An article](https://example.org/a) — A. Writer · Example Gazette\n\n*Two claims, tested*\n",
),
md.slice(0, 300),
);
diff --git a/common/lib/report/exportHtml.ts b/common/lib/report/exportHtml.ts
@@ -10,10 +10,9 @@
// answers — a data: URI for the one-file export, a relative path in the pack.
// Clips are LINKED, never inlined (the pack plays them from its media/).
//
-// What it holds, as the site's report page has it: the heading (the series
-// on its own line in the accent, the title with the reviewed document's
-// byline inline, "Fact-check by <site>", the dates, the subtitle, the
-// document under review on its colour's edge), then the three tiers, each
+// What it holds, as the site's report page has it: the heading (the series,
+// else the title; one line of dates and the revision; the document under
+// review as a cited line on its colour's rail; the subtitle), then the three tiers, each
// opened by a marker (depth dots, minutes to read): the quick take (the
// tally, the summary, jump links); what the check found (a fact-check's
// claims by verdict, a line each with its gist and flag); and every claim
@@ -36,15 +35,15 @@ import {
CITATION_KIND_LABELS,
foundGroups,
orderedCitations,
- reportAttribution,
- reportByline,
+ reportDateParts,
reportFullTitle,
reportPagePath,
+ reportRevisionLabel,
reportTierMinutes,
sourceAnchor,
spanLabel,
+ subjectMetaParts,
verdictTally,
- type BylinePart,
type CitationView,
type ClaimView,
type ReportPageView,
@@ -93,8 +92,6 @@ export type ReportExportOptions = {
// The site's public URL (site.json `siteUrl`). Absent: no link to the site,
// its moment pages or its clips (a private site has nowhere to link).
siteUrl?: string;
- // The site's title: "Fact-check by <site title>" under the title.
- siteTitle?: string;
footer: ReportExportFooter;
};
@@ -280,9 +277,7 @@ body{margin:0;background:#fff;color:var(--fg);font:16px/1.55 -apple-system,Blink
main{max-width:46rem;margin:0 auto;padding:2rem 1rem 3rem}
a{color:var(--accent);text-decoration:underline;text-decoration-thickness:1px;text-underline-offset:2px;overflow-wrap:anywhere}
h1,h2,h3,h4{line-height:1.25;margin:0}
-h1{font-size:2rem;font-weight:600;letter-spacing:-.01em}
-h1 .series{display:block;color:var(--accent);font-weight:600;font-size:.8em;margin-bottom:.15rem}
-h1 .series+.title{display:block;font-weight:400}
+h1{font-size:2rem;font-weight:600;letter-spacing:-.01em;overflow-wrap:anywhere}
h2{font-size:1.5rem;font-weight:600;margin-top:2.5rem;padding-top:1rem;border-top:1px solid var(--border)}
h3{font-size:1.15rem;font-weight:600}
h4,h5,h6{font-size:1rem;font-weight:600;margin:1rem 0 .25rem}
@@ -295,12 +290,12 @@ pre code{background:none;padding:0}
img{max-width:100%;height:auto}
video,audio{display:block;width:100%;max-width:36rem;margin:.5rem 0}
.subtitle{font-size:1.15rem;color:var(--muted);margin:.5rem 0 0}
-.site-line,.dates{margin:.35rem 0 0}
-h1 .by{font-size:.55em;font-weight:400;color:var(--muted);letter-spacing:0}
-.site-line,.dates,.label,.meta,.export-footer{font-size:.85rem;color:var(--muted)}
+.dates{margin:.35rem 0 0;font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
+.dates a{color:inherit}
+.dates,.label,.meta,.export-footer{font-size:.85rem;color:var(--muted)}
.label{font:600 .7rem/1.2 ui-monospace,SFMono-Regular,Menlo,monospace;text-transform:uppercase;letter-spacing:.14em;margin:1.25rem 0 .4rem}
header.report-head{padding-bottom:1.25rem;border-bottom:1px solid var(--border)}
-.under-review{margin-top:1rem;padding:.75rem 1rem;border:1px solid var(--border);border-left-width:4px;border-radius:6px;background:var(--surface);font-size:.95rem}
+.subject{margin:1rem 0 0;padding:.5rem .9rem;border-left:4px solid var(--border);border-radius:0 6px 6px 0;background:var(--surface);overflow-wrap:anywhere}
.tier{display:flex;align-items:center;gap:.6rem;margin:2.25rem 0 .5rem}
.tier .dots{display:inline-flex;gap:.25rem}
.tier .dots i{display:inline-block;width:.4rem;height:.4rem;border-radius:50%;border:1px solid var(--muted)}
@@ -579,18 +574,6 @@ function tierMarker(depth: 1 | 2 | 3, minutes: number): string {
// ─── The document ───
-// `Published <date>`, `Updated <date>` (when it differs).
-export function reportDates(view: Pick<ReportPageView, "published" | "updated">): string[] {
- return [
- view.published ? `Published ${dateLabel(view.published)}` : null,
- view.updated && view.updated !== view.published ? `Updated ${dateLabel(view.updated)}` : null,
- ].filter((x): x is string => !!x);
-}
-
-function bylinePart(p: BylinePart): string {
- return p.href ? link(p.href, p.text) : escapeHtml(p.text);
-}
-
export function reportExportHtml(view: ReportPageView, opts: ReportExportHtmlOptions): string {
const isFactcheck = view.kind === "factcheck";
const cite = (id: string): CiteRef | undefined => {
@@ -608,33 +591,27 @@ export function reportExportHtml(view: ReportPageView, opts: ReportExportHtmlOpt
const pageUrl = siteLink(opts.siteUrl, reportPagePath(view.id));
const ctx: ClaimContext = { view, subjectLabel, subjectNoun, cite, opts };
- // The heading, in the site's order: the series (its own line, the accent),
- // the title with the reviewed document's byline inline, then ours — what
- // the report is and who publishes it, its dates — then the subtitle and the
- // document under review.
- const head: string[] = [];
- const by = reportByline(view);
- const byHtml = by
- ? ` <span class="by" data-report-byline="">by ${[by.author ? bylinePart(by.author) : null, by.publisher ? bylinePart(by.publisher) : null]
- .filter(Boolean)
- .join(" · ")}</span>`
- : "";
- head.push(
- `<h1>${view.series ? `<span class="series" data-report-series="">${escapeHtml(view.series)}</span>` : ""}` +
- `<span class="title">${escapeHtml(view.title)}${byHtml}</span></h1>`,
- );
- head.push(`<p class="site-line" data-report-attribution="">${escapeHtml(reportAttribution(view.kind, opts.siteTitle))}</p>`);
- const dates = reportDates(view);
- if (dates.length > 0) head.push(`<p class="dates">${dates.map(escapeHtml).join(" · ")}</p>`);
- if (view.subtitle) head.push(`<p class="subtitle">${escapeHtml(view.subtitle)}</p>`);
+ // The heading, in the site's order: the series (else the title), one line
+ // of dates and this export's revision, the document under review as a
+ // cited line on its colour's rail, the subtitle.
+ const head: string[] = [`<h1>${escapeHtml(view.series ?? view.title)}</h1>`];
+ const historyUrl = view.history ? siteLink(opts.siteUrl, view.history.href) : undefined;
+ const revision =
+ opts.footer.revision !== undefined
+ ? historyUrl
+ ? link(historyUrl, reportRevisionLabel(opts.footer.revision))
+ : escapeHtml(reportRevisionLabel(opts.footer.revision))
+ : undefined;
+ const dateLine = [...reportDateParts(view).map(escapeHtml), ...(revision ? [revision] : [])];
+ if (dateLine.length > 0) head.push(`<p class="dates" data-report-dates="">${dateLine.join(" · ")}</p>`);
if (subject) {
- const sb = sourceByline(subject);
+ const meta = subjectMetaParts(subject).map(escapeHtml).join(" · ");
head.push(
- `<div class="under-review"${railStyle(subject.accent)}><p class="label" style="margin-top:0">Under review</p>` +
- `<p style="margin:0">${sourceTitleHtml(subject)}</p>${sb ? `<p class="meta" style="margin:.2rem 0 0">${sb}</p>` : ""}` +
- `${subject.archives.length > 0 ? `<p class="meta" style="margin:.2rem 0 0"><a href="#${escapeHtml(sourceAnchor(subject.id))}">${plural(subject.archives.length, "archive link")}</a></p>` : ""}</div>`,
+ `<p class="subject" data-subject-source="${escapeHtml(subject.id)}"${railStyle(subject.accent)}>${sourceTitleHtml(subject)}` +
+ `${meta ? ` <span class="meta">— ${meta}</span>` : ""}</p>`,
);
}
+ if (view.subtitle) head.push(`<p class="subtitle">${escapeHtml(view.subtitle)}</p>`);
const body: string[] = [`<header class="report-head">${head.join("\n")}</header>`];
// Tier 1, the quick take: the tally, the summary, where to go next.
diff --git a/common/lib/report/exportMarkdown.ts b/common/lib/report/exportMarkdown.ts
@@ -14,18 +14,18 @@
import { CITE_SCHEME } from "../citations/inline";
import type { SourceArchive } from "../citations/schema";
-import { dateLabel, reportDates, reportExportFooterLine, siteLink, type ReportExportOptions } from "./exportHtml";
+import { dateLabel, reportExportFooterLine, siteLink, type ReportExportOptions } from "./exportHtml";
import {
CITATION_KIND_LABELS,
foundGroups,
orderedCitations,
- reportAttribution,
- reportByline,
+ reportDateParts,
reportPagePath,
+ reportRevisionLabel,
reportTierMinutes,
spanLabel,
+ subjectMetaParts,
verdictTally,
- type BylinePart,
type CitationView,
type ClaimView,
type ReportPageView,
@@ -172,8 +172,6 @@ function claimLines(
return out;
}
-const bylinePart = (p: BylinePart) => (p.href ? mdLink(p.text, p.href) : mdText(p.text));
-
export function reportExportMarkdown(view: ReportPageView, opts: ReportExportOptions): string {
const out: string[] = [];
const subject = view.subject ? view.sources[view.subject] : undefined;
@@ -183,18 +181,23 @@ export function reportExportMarkdown(view: ReportPageView, opts: ReportExportOpt
const isFactcheck = view.kind === "factcheck";
const minutes = reportTierMinutes(view);
- // The series on its own line, the title, the reviewed document's byline;
- // then ours: what the report is, who publishes it, its dates.
- if (view.series) out.push(`**${mdText(view.series)}**`, "");
- out.push(`# ${mdText(view.title)}`, "");
- const by = reportByline(view);
- if (by) out.push(`by ${[by.author ? bylinePart(by.author) : null, by.publisher ? bylinePart(by.publisher) : null].filter(Boolean).join(" · ")}`, "");
- out.push([mdText(reportAttribution(view.kind, opts.siteTitle)), ...reportDates(view)].join(" · "), "");
- if (view.subtitle) out.push(`*${mdText(view.subtitle)}*`, "");
+ // The series (else the title), one line of dates and this export's
+ // revision, the document under review as a cited line, the subtitle.
+ out.push(`# ${mdText(view.series ?? view.title)}`, "");
+ const historyUrl = view.history ? siteLink(opts.siteUrl, view.history.href) : undefined;
+ const revision =
+ opts.footer.revision !== undefined
+ ? historyUrl
+ ? mdLink(reportRevisionLabel(opts.footer.revision), historyUrl)
+ : mdText(reportRevisionLabel(opts.footer.revision))
+ : undefined;
+ const dateLine = [...reportDateParts(view).map(mdText), ...(revision ? [revision] : [])];
+ if (dateLine.length > 0) out.push(dateLine.join(" · "), "");
if (subject) {
- const byline = [subject.publisher, subject.author, dateLabel(subject.date)].filter(Boolean).map((x) => mdText(x!));
- out.push(`Under review: ${subject.url ? mdLink(subject.title, subject.url) : mdText(subject.title)}${byline.length ? ` — ${byline.join(" · ")}` : ""}`, "");
+ const meta = subjectMetaParts(subject).map(mdText);
+ out.push(`${subject.url ? mdLink(subject.title, subject.url) : mdText(subject.title)}${meta.length ? ` — ${meta.join(" · ")}` : ""}`, "");
}
+ if (view.subtitle) out.push(`*${mdText(view.subtitle)}*`, "");
// Tier 1: the quick take.
out.push(tierLine(minutes.quick), "");
diff --git a/common/lib/report/schema.ts b/common/lib/report/schema.ts
@@ -101,7 +101,7 @@ export const REPORT_FIELD_DOCS: FieldDocs<Report> = {
version: `\`${REPORT_VERSION}\`.`,
id: "The report's id: a lowercase slug (`[a-z0-9][a-z0-9-]*`, at most 64), its directory name under `reports/` and the last segment of its page, `/reports/<id>/`. Must match the directory.",
kind: '`"factcheck"` — sections of claims, each with a verdict — or `"sweep"` — sections with no verdicts, or bodies with inline citations.',
- series: "The series the report belongs to: a recurring name for its kind of report. It is shown on its own line above the title, in the accent colour, in place of the kind's label, and is joined to it as `<series>: <title>` where one line names the report (a page title, a link, a cited-in entry).",
+ series: "The series the report belongs to: a recurring name for its kind of report. It heads the report's page in place of its title, is shown on its own line above the title, in the accent colour, in place of the kind's label, in the report list, and is joined to it as `<series>: <title>` where one line names the report (a page title, a link, a cited-in entry).",
title: "The report's title.",
subtitle: "A line under the title.",
summary: "The report's summary, in markdown, shown before the sections. May cite inline: `[label](cite:<id>)`.",
diff --git a/common/lib/report/views.test.ts b/common/lib/report/views.test.ts
@@ -22,17 +22,17 @@ import {
momentViewPath,
orderedCitations,
reportAssetPath,
- reportAttribution,
- reportByline,
+ reportDateParts,
reportIndexEntry,
reportFullTitle,
+ reportRevisionLabel,
sourceAnchor,
+ subjectMetaParts,
sectionCitations,
spanLabel,
verdictTally,
type RecordView,
type ReportPageView,
- type SourceView,
} from "./views";
const report: Report = {
@@ -316,28 +316,23 @@ test("a claim's flag rides its view; a claim without one carries none", () => {
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 header's date line: the published date, 'updated' when it differs, then the revision", () => {
+ assert.deepEqual(reportDateParts({ published: "2026-10-04", updated: "2026-10-05T09:00:00Z" }), ["2026-10-04", "updated 2026-10-05"]);
+ assert.deepEqual(reportDateParts({ published: "2026-10-04", updated: "2026-10-04" }), ["2026-10-04"], "no 'updated' when the same");
+ assert.deepEqual(reportDateParts({ updated: "2026-10" }), ["updated 2026-10"]);
+ assert.deepEqual(reportDateParts({}), []);
+ assert.equal(reportRevisionLabel(1), "revision 1");
+ assert.equal(reportRevisionLabel(2, false), "edited since revision 2");
});
-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");
+test("the document under review is named by its author, publisher and date, the parts it has", () => {
+ assert.deepEqual(subjectMetaParts({ author: "A. Writer", publisher: "Gazette", date: "2026-09-30T08:00:00Z" }), [
+ "A. Writer",
+ "Gazette",
+ "2026-09-30",
+ ]);
+ assert.deepEqual(subjectMetaParts({ publisher: " Gazette " }), ["Gazette"]);
+ assert.deepEqual(subjectMetaParts({ author: " " }), []);
});
test("a source's accent rides its view and its sentences' views; only #rrggbb is a source accent", () => {
diff --git a/common/lib/report/views.ts b/common/lib/report/views.ts
@@ -642,22 +642,31 @@ 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 date as the header shows it: `2026-10-04T12:00:00Z` → `2026-10-04`; a
+// partial date (`2026-10`) as given.
+function headerDate(v: string): string {
+ return /^\d{4}-\d{2}-\d{2}T/.test(v) ? v.slice(0, 10) : v;
+}
+
+// The header's date line, before its revision: the published date, then
+// "updated <date>" when it differs.
+export function reportDateParts(view: Pick<ReportPageView, "published" | "updated">): string[] {
+ return [
+ view.published ? headerDate(view.published) : null,
+ view.updated && view.updated !== view.published ? `updated ${headerDate(view.updated)}` : null,
+ ].filter((x): x is string => !!x);
+}
+
+// The revision on the header's date line: "revision N", or "edited since
+// revision N" when the report changed after its newest revision.
+export function reportRevisionLabel(revision: number, current = true): string {
+ return current ? `revision ${revision}` : `edited since revision ${revision}`;
+}
+
+// The document under review named under its title: "<author> · <publisher> ·
+// <date>", the parts it has.
+export function subjectMetaParts(s: Pick<SourceView, "author" | "publisher" | "date">): string[] {
+ return [s.author?.trim(), s.publisher?.trim(), s.date ? headerDate(s.date) : undefined].filter((x): x is string => !!x);
}
// ─── The report page's tiers: the quick take, what the check found, every claim ───
@@ -727,19 +736,6 @@ export function reportTierMinutes(view: ReportPageView): ReportTierMinutes {
});
}
-// 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/common/publish/reportExports.ts b/common/publish/reportExports.ts
@@ -339,7 +339,7 @@ async function buildEvidencePack(o: {
report: Report;
view: ReportPageView;
files: ReportFiles;
- site: Pick<Site, "siteId" | "siteUrl" | "siteTitle">;
+ site: Pick<Site, "siteId" | "siteUrl">;
footer: ReportExportFooter;
markdown: string;
outFile: string;
@@ -378,7 +378,6 @@ async function buildEvidencePack(o: {
}
const html = reportExportHtml(o.view, {
siteUrl: o.site.siteUrl || undefined,
- siteTitle: o.site.siteTitle || undefined,
footer: o.footer,
image: (p) => images.get(p),
clip: (c) => clips.get(c.moment),
@@ -461,7 +460,6 @@ export async function writeReportExports(o: {
}
const footer = exportFooterFor(view, sha, pending?.revision);
const siteUrl = site.siteUrl || undefined;
- const siteTitle = site.siteTitle || undefined;
// The one-file export's images, as data: URIs.
const dataUris = new Map<string, string>();
@@ -473,7 +471,6 @@ export async function writeReportExports(o: {
}
const htmlOpts = {
siteUrl,
- siteTitle,
footer,
image: (p: string) => dataUris.get(p),
clip: (c: SpanCitationView) => {
@@ -486,7 +483,7 @@ export async function writeReportExports(o: {
// The PDF prints what the page folds away (the evidence the document under
// review gave itself) open.
const printHtml = reportExportHtml(view, { ...htmlOpts, print: true });
- const markdown = reportExportMarkdown(view, { siteUrl, siteTitle, footer });
+ const markdown = reportExportMarkdown(view, { siteUrl, footer });
const dir = o.dir;
await rm(dir, { recursive: true, force: true });
diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md
@@ -1,18 +1,18 @@
# Changelog
## [Unreleased]
-- **A report shows its revision, and every edit to it can be checked.** Under a report's dates its page now reads "Revision N · <date> · history". The history page, `/reports/<id>/history/`, lists every revision, newest first: its number, date (UTC), commit hash and the sha256 of its `report.json`, what changed (claims added or removed, verdicts changed, claims edited, citations added or removed, quotes edited, title, series or subtitle changed), and each changed claim's title, text, verdict and findings with the words removed struck through and the words added marked. The same data is in `history.json` beside the page. Each report's history is its own git repository, published for cloning: `git clone <site>/reports/<id>/history/repo`. Each commit names the site as its author, with its date in UTC. The footer of the report's HTML, PDF and Markdown downloads now begins with the revision number, and its sha256 can be looked up on the history page. Needs `reports export` and a rebuild and deploy of each site with reports.
+- **A report shows its revision, and every edit to it can be checked.** A report's date line now ends with "revision N", linking to its history ("edited since revision N" when the report has changed since). The history page, `/reports/<id>/history/`, lists every revision, newest first: its number, date (UTC), commit hash and the sha256 of its `report.json`, what changed (claims added or removed, verdicts changed, claims edited, citations added or removed, quotes edited, title, series or subtitle changed), and each changed claim's title, text, verdict and findings with the words removed struck through and the words added marked. The same data is in `history.json` beside the page. Each report's history is its own git repository, published for cloning: `git clone <site>/reports/<id>/history/repo`. Each commit names the site as its author, with its date in UTC. The footer of the report's HTML, PDF and Markdown downloads now begins with the revision number, and its sha256 can be looked up on the history page. Needs `reports export` and a rebuild and deploy of each site with reports.
- **A report can be saved whole: as one HTML page, a PDF, Markdown, or an evidence pack.** A report page's download line now reads HTML · PDF · Markdown · Evidence pack · Citations JSON · CSV, each listed only when the site publishes it. The HTML is one file that opens with no network: the report with its verdicts, the document's sentences and the post screenshots inside it, numbered citations, and a reference list giving each quote's speaker, date, record, the original at its time and the moment page on the site. The PDF is that page printed. The Markdown is the same report as plain text with numbered references. The evidence pack is a zip of the page with its clips, stills and screenshots beside it, so the clips play offline. Each ends with a line naming the report's date and the start of its checksum. Needs `reports export` (or prepare) and a rebuild and deploy of each site with reports.
-- **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. A claim's sentence from the document under review no longer links up to the document's box: it sits on a rail in the document's colour, and the box's left edge wears the same colour (a source's `accent`, `"#rrggbb"`; without one, the border colour). A sentence of another document keeps its "from <title>" link. A report's citation can say where its evidence came from (`origin`: `"subject"`, the document under review gave it; `"added"`, the report's author found it). A claim lists what the report added first, each card marked with the Archilyzer mark and "Not in the article" ("Not in the source"), then evidence of unknown origin, then what the document gave itself folded under "In the article (n)"; the reference list marks an added citation with the mark alone, and a claim's flag pill wears the same mark. A fact-check's page reads in three tiers, each opened by a hairline with one, two or three dots and its reading time (at 230 words a minute): the quick take (the tally, the summary, and links to what the check found, every claim and the downloads); **What the check found**, every ruled claim grouped by verdict (contradicted, not found, partly, untestable, corroborated), one line each linking to the claim, with its `gist` (a new optional claim field, one line, at most 240 characters) and its flag; and **Every claim, with its evidence**, which opens with **How it was checked** (`method`, a new optional report field in markdown). A report of kind `sweep` has the first and last tiers only. Needs a rebuild and deploy of the site.
+- **A report's claim can carry a flag, its header names the document under review, 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 header is its series (or, with none, its title), then one small line of dates and the revision ("2026-10-04 · updated 2026-10-05 · revision 1"; "updated" only when it differs), then a card for the document under review: its title linking to the document, "<author> · <publisher> · <date>", and its archive links folded away, on a left rail in the document's colour (a source's `accent`, `"#rrggbb"`; without one, the border colour), then the subtitle. The kind's label no longer sits above the title on the report's page, and the page names no byline or site of its own. A claim's sentence from the document under review no longer links up to the document's card: it sits on the same rail. A sentence of another document keeps its "from <title>" link. A report's citation can say where its evidence came from (`origin`: `"subject"`, the document under review gave it; `"added"`, the report's author found it). A claim lists what the report added first, each card marked with the Archilyzer mark and "Not in the article" ("Not in the source"), then evidence of unknown origin, then what the document gave itself folded under "In the article (n)"; the reference list marks an added citation with the mark alone, and a claim's flag pill wears the same mark. A fact-check's page reads in three tiers, each opened by a hairline with one, two or three dots and its reading time (at 230 words a minute): the quick take (the tally, the summary, and links to what the check found, every claim and the downloads); **What the check found**, every ruled claim grouped by verdict (contradicted, not found, partly, untestable, corroborated), one line each linking to the claim, with its `gist` (a new optional claim field, one line, at most 240 characters) and its flag; and **Every claim, with its evidence**, which opens with **How it was checked** (`method`, a new optional report field in markdown). A report of kind `sweep` has the first and last tiers only. Needs a rebuild and deploy of the site.
- **Forum posts read like the other posts.** A post from a forum-thread channel shows its place in the thread (#N), an "edited" mark, the thread's title and its media as links, and opening its thread shows its conversation — the posts it quotes and the posts quoting it — rather than the whole forum thread.
- **archive.org records play and are cited with their downloads.** A record imported from archive.org plays its file in the page's own player, which seeks to a cited second and follows the transcript. A citation of one links "archive.org" (the file's page) and its "torrent"; a citation of an archive.org mirror of a YouTube upload links the original on YouTube at the cited second, then "archive.org" and "torrent", on the citation cards and the moment pages.
- **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>`.
+- **A report can belong to a series.** `report.json` `series` heads the report's page in place of its title (the header's card names the document), and in the report list is shown on its own line above the 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).
- **MCP: a site's reports can be read, and a site that publishes only reports says so instead of looking empty.** Two new tools: `list_reports` lists the reports a site publishes (id, title, kind, claim and citation counts, a fact-check's verdict tally, its page), and `get_report` reads one — its tally, then each section's claims with their verdicts and findings and, for every citation, the verbatim quote, the original (the platform at the cited second, the post, the document) and the site's moment page; `section` reads one section. On a site that publishes only its reports, `list_channels`, `list_sources` and `resolve_source` say "cited-only site: N report(s)" where they said "No channels found"; on a site with reports as well, `list_sources` and `resolve_source` say how many. The archive readers (local, remote) read `corpus.json` spec 5: a cited site is an empty corpus without an error, and a local copy of one is never read from a stale `transcripts/` folder. A hub has no reports of its own; `list_reports` says to name a member site.
- **The hub leaves out a site that publishes only its reports.** A site with search off (`search: false`) is not a searchable archive, so it is no hub member (not in federated search, the hub's `corpus.json` or `llms.txt`) and no other site's footer links to it, whatever its **List on the Archilyzer homepage and hub** setting says. A site with reports that publishes its full corpus is a member as before. Needs a hub rebuild and deploy once such a site exists.
- **`corpus.json` is spec 5: it names a site's reports, and a site that publishes only reports says so.** A site with reports adds `reports` to its `corpus.json` (`index`: `/reports/index.json`, the count, and how to read a report's page, its citations and its moment pages) and a Reports section to `llms.txt`; its sitemap lists the report and moment pages. A site that publishes only its reports has `"scope": "cited"` and its audience under `site`, no channels and zero totals, an `llms.txt` that lists its reports and how their citations and moment pages are read, and a `site.json` with no channels. A reader that does not know spec 5 sees an empty corpus there. Needs a rebuild and deploy of each site.
-- **A site can show cited reports, and every citation opens on a page of its own.** A site built with reports has a **Reports** link in its header and a page at `/reports/` listing them. A report's page has its title, subtitle, dates and the document under review, its archive links listed once under it and folded away ("N archive links in context"); a fact-check's tally of verdicts; the summary; the sections and their claims, each with its verdict, the document's own sentence as an image with a link back to the document and only the archive links that sit in that sentence (at most five), the findings and the evidence cards; a numbered reference list; and links to download its citations as JSON and CSV. A citation in the text shows as its words plus a number: hovering it, focusing the number or tapping it once shows a card of the citation (the quote, who said it and when, a picture or the post's screenshot, and how closely the quote matched the transcript when it was checked); the words open what it cites and the number jumps to its reference. A cited span of a video or audio record opens at `/m/<channel>/<id>/<start>-<end>/`: a short clip of the span with a little context either side, the quote, the transcript lines around it, the record's title, channel and date, a link to the original at that time, and every report on the site that cites it. A cited post opens at `/m/<channel>/<id>/` with its screenshot and text. A site that publishes only its reports (`site.json` `publish: "cited"`) opens on the report index and has no search, Ask AI, downloads or duplicates. A site with no reports is unchanged. Needs a rebuild and deploy of each site.
+- **A site can show cited reports, and every citation opens on a page of its own.** A site built with reports has a **Reports** link in its header and a page at `/reports/` listing them. A report's page has its title, subtitle, dates and the document under review, its archive links listed once under it and folded away ("N archive links"); a fact-check's tally of verdicts; the summary; the sections and their claims, each with its verdict, the document's own sentence as an image with a link back to the document and only the archive links that sit in that sentence (at most five), the findings and the evidence cards; a numbered reference list; and links to download its citations as JSON and CSV. A citation in the text shows as its words plus a number: hovering it, focusing the number or tapping it once shows a card of the citation (the quote, who said it and when, a picture or the post's screenshot, and how closely the quote matched the transcript when it was checked); the words open what it cites and the number jumps to its reference. A cited span of a video or audio record opens at `/m/<channel>/<id>/<start>-<end>/`: a short clip of the span with a little context either side, the quote, the transcript lines around it, the record's title, channel and date, a link to the original at that time, and every report on the site that cites it. A cited post opens at `/m/<channel>/<id>/` with its screenshot and text. A site that publishes only its reports (`site.json` `publish: "cited"`) opens on the report index and has no search, Ask AI, downloads or duplicates. A site with no reports is unchanged. Needs a rebuild and deploy of each site.
## [0.11.1] - 2026-10-01
- **Use with AI goes to the Archilyzer site's AI and MCP doc; the page on each site is gone.** The header's, the slide-out menu's, the footer's and Ask AI's **Use with AI** keep their label and open https://archilyzer.pages.dev/docs/ai-and-mcp/ in the same tab, on every site and the hub, where one block says how to run Claude Code against any archive (the source, `pnpm install`, `claude mcp add archilyzer`, `/ask`). `/use-with-ai/` is no longer built. `corpus.json`'s `useWithAi` names the doc; `llms.txt`'s Ask AI section lists the site's `/ask/` chat and the doc; the sitemap drops `/use-with-ai`. Needs a rebuild and deploy of each site and the hub.
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, reportSiteTitle } from "../lib/reports";
+import { onlyReportView, readReportIndex, reportMetadata } 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} siteTitle={reportSiteTitle()} />;
+ if (only) return <ReportArticle view={only} />;
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
@@ -8,24 +8,22 @@ import type { SourceArchive } from "yt-dlp-transcript-common/lib/citations/schem
import {
foundGroups,
orderedCitations,
- reportAttribution,
+ reportDateParts,
+ reportRevisionLabel,
reportTierMinutes,
- 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, ReportName, SourceBlock, dateLabel, textLink } from "./parts";
+import { ArchiveList, SourceBlock, SubjectCard, textLink } from "./parts";
-// 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 and the revision with its history link, the subtitle, the document under review with its archive links), a
-// fact-check's tally, the summary, the sections and their claims — each claim
+// ONE REPORT, from its view (common/lib/report/views.ts): the header (its
+// series, else its title; one line of dates and the revision, linked to its
+// history; the document under review as a card on its colour's rail, with its
+// archive links; the subtitle), 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 (what the report added first,
// marked; what the document gave itself folded away last) — then the numbered
@@ -137,35 +135,6 @@ function TierMarker({ depth, minutes, spoken }: { depth: 1 | 2 | 3; minutes: num
);
}
-// 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,
@@ -246,20 +215,14 @@ function Claim({
);
}
-// `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 }) {
+export default function ReportArticle({ view }: { view: ReportPageView }) {
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 subjectNoun = subject?.kind === "article" ? "article" : "source";
const subjectLabel = `The ${subjectNoun} says`;
- const dates = [
- view.published ? `Published ${dateLabel(view.published)}` : null,
- view.updated && view.updated !== view.published ? `Updated ${dateLabel(view.updated)}` : null,
- ].filter(Boolean);
+ const dates = reportDateParts(view);
const claimCount = view.sections.reduce((n, s) => n + s.claims.length, 0);
// The report as files (its exports), then its citations as data: only what
// compose published.
@@ -283,33 +246,30 @@ export default function ReportArticle({ view, siteTitle }: { view: ReportPageVie
// serialized once, not once per marker.
<CitationsProvider citations={view.citations}>
<article data-report={view.id} className="mx-auto flex w-full max-w-3xl flex-col gap-8">
+ {/* The report's name (its series, else its title: with a series, the
+ card below names the document), one line of dates and the
+ revision, the document under review, the subtitle. */}
<header className="flex flex-col gap-3">
- <h1 className="font-display text-3xl font-semibold leading-tight tracking-tight text-foreground sm:text-4xl">
- <ReportName series={view.series} title={view.title}>
- {byline && <Byline byline={byline} />}
- </ReportName>
+ <h1 className="font-display text-3xl font-semibold leading-tight tracking-tight text-foreground [overflow-wrap:anywhere] sm:text-4xl">
+ {view.series ?? view.title}
</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)}
+ {(dates.length > 0 || view.history) && (
+ <p data-report-dates="" className="font-mono text-xs text-muted-foreground">
+ {dates.join(" · ")}
+ {view.history && (
+ <>
+ {dates.length > 0 ? " · " : null}
+ <a href={view.history.href} data-report-revision={view.history.revision}
+ className="underline decoration-muted-foreground/40 underline-offset-2 hover:text-foreground hover:decoration-foreground"
+ >
+ {reportRevisionLabel(view.history.revision, view.history.current)}
+ </a>
+ </>
+ )}
</p>
- {dates.length > 0 && <p className="font-mono text-xs text-muted-foreground">{dates.join(" · ")}</p>}
- {view.history && (
- <p data-report-revision={view.history.revision} className="font-mono text-xs text-muted-foreground">
- {view.history.current
- ? `Revision ${view.history.revision} · ${dateLabel(view.history.date)}`
- : `Edited since revision ${view.history.revision}`}
- {" · "}
- <a href={view.history.href} className={textLink}>
- history
- </a>
- </p>
- )}
- </div>
+ )}
+ {subject && <SubjectCard source={subject} />}
{view.subtitle && <p className="text-lg text-muted-foreground">{view.subtitle}</p>}
- {subject && <SourceBlock source={subject} label="Under review" />}
</header>
{/* The quick take: the tally and the summary, then where to go next. */}
diff --git a/export/app/components/reports/parts.tsx b/export/app/components/reports/parts.tsx
@@ -1,6 +1,6 @@
import Link from "next/link";
import { ExternalLink } from "lucide-react";
-import { sourceAnchor, type SourceView } from "yt-dlp-transcript-common/lib/report/views";
+import { sourceAnchor, subjectMetaParts, type SourceView } from "yt-dlp-transcript-common/lib/report/views";
import type { SourceArchive } from "yt-dlp-transcript-common/lib/citations/schema";
import { reportIds } from "../../lib/reports";
@@ -16,34 +16,19 @@ export function Eyebrow({ children }: { children: React.ReactNode }) {
return <p className="font-mono text-xs uppercase tracking-[0.18em] text-brand">{children}</p>;
}
-// A report's name as a heading. With a series, the series is its own line in
-// 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). `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;
+// A report's name as a heading, in the index and on the history page. With a
+// series, the series is its own line in 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}</>;
return (
<>
<span data-report-series="" className="block font-semibold text-brand">
{series}
</span>{" "}
- <span className="block font-normal">{name}</span>
+ <span className="block font-normal">{title}</span>
</>
);
}
@@ -93,6 +78,49 @@ export function SourceBlock({ source, label }: { source: SourceView; label: stri
);
}
+// The document under review, under the report's heading: its title linked to
+// the document, then "<author> · <publisher> · <date>", then its archive links
+// folded away. No label: its left rail, in the document's colour (its
+// `accent`, else the border colour) as its sentences' rails below are, says
+// whose it is. It is the document's one place on the page, so it carries the
+// anchor a citation's "from <title>" links to.
+export function SubjectCard({ source }: { source: SourceView }) {
+ const meta = subjectMetaParts(source).join(" · ");
+ const n = source.archives.length;
+ return (
+ <div
+ id={sourceAnchor(source.id)}
+ data-subject-source={source.id}
+ className={`flex min-w-0 scroll-mt-20 flex-col gap-1 rounded-r-md border-l-4 bg-surface py-2.5 pl-3 pr-4 text-sm${source.accent ? "" : " border-border"}`}
+ style={source.accent ? { borderLeftColor: source.accent } : undefined}
+ >
+ <p data-subject-title="" className="font-medium leading-snug text-foreground [overflow-wrap:anywhere]">
+ {source.url ? (
+ <a
+ href={source.url}
+ target="_blank"
+ rel="noopener noreferrer"
+ className={`${textLink} after:ml-1 after:inline-block after:text-xs after:content-['↗']`}
+ >
+ {source.title}
+ </a>
+ ) : (
+ source.title
+ )}
+ </p>
+ {meta && <p className="text-xs text-muted-foreground">{meta}</p>}
+ {n > 0 && (
+ <details data-source-archives={n} className="text-xs">
+ <summary className="cursor-pointer text-muted-foreground hover:text-foreground">
+ {`${n} archive link${n === 1 ? "" : "s"}`}
+ </summary>
+ <ArchiveList archives={source.archives} className="mt-2" />
+ </details>
+ )}
+ </div>
+ );
+}
+
// Archive links, each with the words around it in the document. A document
// may carry hundreds, so a link is text with a CSS arrow, not two inline SVG
// icons apiece.
diff --git a/export/app/lib/reportSize.test.ts b/export/app/lib/reportSize.test.ts
@@ -44,5 +44,5 @@ test("the rendered report page is under 5 MB", () => {
assert.ok(Buffer.byteLength(html) < 5 * MB, `the page renders to ${Buffer.byteLength(html)} bytes`);
// the document's full archive list is on the page once, collapsed
assert.equal(html.match(/data-source-archives="300"/g)?.length, 1);
- assert.match(html, /<details data-source-archives="300"[^>]*><summary[^>]*>300 archive links in context<\/summary>/);
+ assert.match(html, /<details data-source-archives="300"[^>]*><summary[^>]*>300 archive links<\/summary>/);
});
diff --git a/export/app/lib/reports.test.ts b/export/app/lib/reports.test.ts
@@ -36,6 +36,7 @@ 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 { default: ReportArticle } = await import("../components/reports/ReportArticle");
const render = (el: React.ReactElement) => renderToStaticMarkup(el);
@@ -81,22 +82,26 @@ 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 }));
- // the title, then the byline from the document under review in the same flow,
- // the publisher linked to the document
+ // the header: the title (no series), one muted line of dates and the
+ // revision linked to its history, the document under review as a card, the subtitle
+ const header = html.slice(html.indexOf("<header"), html.indexOf("</header>"));
+ assert.match(header, /<h1[^>]*>Checking an example article<\/h1>/);
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><p data-report-revision="2"[^>]*>Revision 2 · 2026-10-04 · <a href="\/reports\/demo-factcheck\/history\/"[^>]*>history<\/a><\/p><\/div><p[^>]*>Four claims about a demo channel/,
+ header,
+ /<\/h1><p data-report-dates=""[^>]*>2026-10-01 · updated 2026-10-04 · <a href="\/reports\/demo-factcheck\/history\/" data-report-revision="2"[^>]*>revision 2<\/a><\/p><div id="source-s0" data-subject-source="s0"/,
);
+ assert.match(header, /<\/div><p[^>]*>Four claims about a demo channel[^<]*<\/p>$/, "the subtitle, under the card");
+ assert.doesNotMatch(header, /data-report-attribution|data-report-byline|Fact-check|Under review|The article/);
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
- assert.match(html, /<div id="source-s0"[^>]*>[^]*?<details data-source-archives="1"/);
+ // the card: the document's title linked to it, then author · publisher · date,
+ // its archive links folded away; on the document's rail; no label, no note
+ const card = header.slice(header.indexOf('<div id="source-s0"'));
+ assert.match(card, /^<div id="source-s0" data-subject-source="s0" class="[^"]*border-l-4[^"]*" style="border-left-color:#8a6fb0">/);
+ assert.match(card, /<a href="https:\/\/example.org\/articles\/demo" target="_blank" rel="noopener noreferrer"[^>]*>An example article about a demo channel<\/a>/);
+ assert.match(card, /<p[^>]*>A. Writer · Example Gazette · 2026-09-20<\/p>/);
+ assert.match(card, /<details data-source-archives="1"[^>]*><summary[^>]*>1 archive link<\/summary>/);
+ assert.ok(card.includes("as published on the day"));
+ assert.ok(!html.includes("Read in the edition published on the day"), "the subject's note is not in the card");
// a claim's source sentence links to the document instead of repeating them
// a sentence of the document under review sits on a rail in its colour, with
// no link to the document: the rail says whose it is; its box wears the colour too
@@ -106,7 +111,6 @@ test("the report page: header, tally, sections and claims, inline cites, referen
assert.match(html, /<div id="source-s0"[^>]*style="border-left-color:#8a6fb0"/);
assert.match(html, /data-verdict-tally=""/);
for (const v of ["CORROBORATED", "PARTLY", "CONTRADICTED", "UNTESTABLE"]) assert.match(html, new RegExp(`data-verdict="${v}"`));
- assert.ok(html.includes("Read in the edition published on the day"), "the source's note, under its byline");
assert.match(html, /<section id="bridge"/);
// a claim with a title: the title heads it, the claim's text follows as what the article says
assert.match(html, /<article id="claim-1"[^]*?<h3[^>]*><a href="#claim-1"[^>]*>“He opened it himself”<\/a><\/h3>[^]*?The article says: <q[^>]*>He opened the bridge himself in 2018.<\/q>/);
@@ -170,6 +174,31 @@ test("the report page: header, tally, sections and claims, inline cites, referen
assert.match(tier3, /<h2[^>]*>Every claim, with its evidence<\/h2><div data-report-method=""[^>]*><h3[^>]*>How it was checked<\/h3>[^]*?searched for in the channel/);
});
+test("the report header: a series heads it in place of the title; an edited report says so; no accent, the border's rail", () => {
+ const view = fixture.buildFixtureReportView();
+ const s0 = view.sources.s0;
+ const html = render(
+ React.createElement(ReportArticle, {
+ view: {
+ ...view,
+ series: "On the Record",
+ updated: view.published,
+ history: { ...view.history!, current: false },
+ sources: { ...view.sources, s0: { ...s0, accent: undefined, author: undefined, date: undefined } },
+ },
+ }),
+ );
+ const header = html.slice(html.indexOf("<header"), html.indexOf("</header>"));
+ // the series is the heading; the title is the card's document, not repeated
+ assert.match(header, /<h1[^>]*>On the Record<\/h1>/);
+ assert.ok(!header.includes(view.title), "with a series the report's title is not in the header");
+ // no 'updated' when it is the published date; the revision, edited since
+ assert.match(header, /<p data-report-dates=""[^>]*>2026-10-01 · <a [^>]*data-report-revision="2"[^>]*>edited since revision 2<\/a><\/p>/);
+ // without an accent the card's rail is the border colour; only the parts it has
+ assert.match(header, /<div id="source-s0" data-subject-source="s0" class="[^"]*border-l-4[^"]*border-border"(?! style)/);
+ assert.match(header, /<p[^>]*>Example Gazette<\/p>/);
+});
+
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"] }) }),
@@ -216,12 +245,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 <span data-report-byline=""/);
+ assert.match(html, /<h1[^>]*>Checking an example article<\/h1>/);
assert.match(html, /data-claim=/);
assert.doesNotMatch(html, /data-report-index/);
- // 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>/);
+ // the site's title is the site header's alone: the report names no attribution
+ assert.doesNotMatch(html, /Demo Reports/);
+ assert.doesNotMatch(html, /data-report-attribution/);
// 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);
@@ -241,7 +270,6 @@ 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,8 +2,6 @@ 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 {
@@ -82,13 +80,6 @@ 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, reportSiteTitle } from "../../lib/reports";
+import { readReportView, reportIds, reportMetadata } 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} siteTitle={reportSiteTitle()} />;
+ return <ReportArticle view={view} />;
}
diff --git a/export/e2e-report/report-site.spec.ts b/export/e2e-report/report-site.spec.ts
@@ -37,16 +37,19 @@ 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 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(
+ await expect(page.locator("h1")).toHaveText("Checking an example article");
+ // One line of dates and the revision; then the document under review as a
+ // card on its rail: its title linked to it, author · publisher · date, no label.
+ await expect(page.locator("[data-report-dates]")).toHaveText("2026-10-01 · updated 2026-10-04 · revision 2");
+ const card = page.locator("header [data-subject-source]");
+ await expect(card.getByRole("link", { name: "An example article about a demo channel" })).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(card).toContainText("A. Writer · Example Gazette · 2026-09-20");
+ await expect(card).not.toContainText(/Under review|The article|Read in the edition/);
+ await expect(card).toHaveCSS("border-left-color", "rgb(138, 111, 176)");
+ await expect(page.locator("[data-report-attribution], [data-report-byline]")).toHaveCount(0);
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");
@@ -54,6 +57,16 @@ test("one report: the home page IS the report, and the header links nothing", as
await expect(page.locator("header nav a")).toHaveCount(0);
});
+test("the header at 375 px: the title and the card wrap, nothing scrolls sideways", async ({ page }) => {
+ await page.setViewportSize({ width: 375, height: 800 });
+ await page.goto(REPORT);
+ const card = page.locator("header [data-subject-source]");
+ await expect(card).toBeVisible();
+ const box = await card.boundingBox();
+ expect(box!.x + box!.width).toBeLessThanOrEqual(375);
+ expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(375);
+});
+
test("one report: /reports/ is no index, it forwards to the home page", async ({ page }) => {
await page.goto("/reports/");
await expect(page).toHaveURL("/");
@@ -283,9 +296,9 @@ test("report.html opens with no network: one file, no script, every image inline
test("the report names its revision beside its dates, and links its history", async ({ page }) => {
await page.goto(REPORT);
- const line = page.locator("[data-report-revision]");
- await expect(line).toHaveText("Revision 2 · 2026-10-04 · history");
- await line.getByRole("link", { name: "history" }).click();
+ const revision = page.locator("[data-report-dates] a[data-report-revision]");
+ await expect(revision).toHaveText("revision 2");
+ await revision.click();
await expect(page).toHaveURL(HISTORY);
await expect(page.locator("h1")).toHaveText("Revision history");
});
diff --git a/plans/report-sites.md b/plans/report-sites.md
@@ -136,8 +136,8 @@ A report must survive a takedown as files anyone can save and host again (slice
`.export-index/sites/<id>/report-exports/<reportId>/`:
- `report.html` — ONE file from `common/lib/report/exportHtml.ts` (pure view → HTML): inline CSS, no script;
stills and post screenshots recompressed on the host (WebP, else JPEG, ≤ 1200 px wide) into data URIs; clips
- linked on the site, never inlined. The heading mirrors the site: the series on its own line in the accent,
- the title with the reviewed document's byline inline, "Fact-check by <site title>", the dates, the subtitle.
+ linked on the site, never inlined. The heading mirrors the site: the series (else the title), one line of
+ dates and the export's revision, the document under review as a cited line on its rail, the subtitle.
Then the tally, sections → claims (verdict chip, the document's sentence as its still, findings with `[n]`
markers), the documents quoted with their archive links, and the numbered references (quote, speaker, date,
record, the original at its time, archive links, the moment page and clip when the site has a `siteUrl`).
@@ -195,7 +195,8 @@ PER REPORT, never site-wide.
revision N"). The sitemap lists the history page.
- **Export site** — `/reports/<id>/history/` (static params as the report page's): `git clone <siteUrl>/reports/<id>/history/repo`
(the site-root path when `siteUrl` is unset), then each revision newest first with its hashes, summary and inline
- `<ins>`/`<del>` diff. The report header shows "Revision N · <date> · history" under the dates.
+ `<ins>`/`<del>` diff. The report header's date line ends with "revision N", linked to the history ("edited since revision N" when
+ the report changed after it).
- **Audit** — `reportHistoryProblem` (`lib/builtExport.ts`, asked by `builtSiteProblem` and `builtBundleProblem`
for every build) refuses any file in a `reports/<id>/history/repo/` outside the allowlist or over
`PUBLISH_MAX_FILE_BYTES`; the cited audit counts the clone's files toward Pages' 20,000.