Archilyzer · Source

archilyzer

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

commit 30bee1bc780e1dc7d24df74be93c250e64be4bd1
parent d9c3827cbaf5a635a3f8815ab297e3c99103b1eb
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon,  5 Oct 2026 15:10:09 -0400

reports: the exports mirror the report page — tiers, flags, evidence by origin, the source rail

report.html (and the PDF) and report.md now follow the merged page: the
shared reportByline / reportAttribution replace the exports' own heading
helpers; the quick take (tally, summary, jump links), "What the check found"
(foundGroups, a row per claim with its gist and flag) for a fact-check, and
"Every claim, with its evidence" opening with "How it was checked", each
tier opened by a marker of depth dots and reading minutes (reportTierMinutes;
a plain `— N min —` line in Markdown). A claim's flag is an accent pill
(`*[flag]*` in Markdown); evidence the report added comes first, tagged with
the Archilyzer mark (a static SVG from markSvg) and "Not in the article";
what the document gave itself folds under "In the article (n)", a <details>
the PDF prints open. A source's accent is the rail of its sentences (the
subject's carry no "from" link) and the edge of its box.

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

Diffstat:
Mcommon/lib/report/export.test.ts | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Mcommon/lib/report/exportHtml.ts | 263+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
Mcommon/lib/report/exportMarkdown.ts | 99++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Mcommon/publish/reportExports.ts | 17+++++++++++------
4 files changed, 329 insertions(+), 125 deletions(-)

diff --git a/common/lib/report/export.test.ts b/common/lib/report/export.test.ts @@ -7,13 +7,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import type { Report } from "./schema"; import { buildReportPageView, type RecordView } from "./views"; -import { - markdownToHtml, - reportExportFooterLine, - reportExportHtml, - subjectByline, - type ReportExportHtmlOptions, -} from "./exportHtml"; +import { markdownToHtml, reportExportFooterLine, reportExportHtml, type ReportExportHtmlOptions } from "./exportHtml"; import { citedMarkdownToPlain, mdText, reportExportMarkdown } from "./exportMarkdown"; const report: Report = { @@ -25,6 +19,7 @@ const report: Report = { title: "A demo fact-check", subtitle: "Two claims, tested", summary: "It starts [here](cite:v1). <script>alert(1)</script> and [a link](javascript:alert(1)).", + method: "Each quote was checked against the recording.", published: "2026-10-01", updated: "2026-10-04T10:00:00Z", subject: { source: "s0" }, @@ -36,13 +31,14 @@ const report: Report = { url: "https://example.org/a", author: "A. Writer", publisher: "Example Gazette", + accent: "#aa3300", archives: [{ label: "archive", url: "https://archive.example.org/a", context: "the first edition" }], saved: "sources/s0/page.html", }, }, citations: { - v1: { kind: "video", channel: "demo-channel", id: "abc123", start: 3126, end: 3151, quote: "one * two", verification: { quoteScore: 0.97, quoteCheckedAt: "2026-10-04T12:00:00Z" } }, - a1: { kind: "audio", channel: "demo-podcast", id: "ep-1", start: 10.5, end: 20, quote: "two", speaker: "Guest" }, + v1: { kind: "video", channel: "demo-channel", id: "abc123", start: 3126, end: 3151, quote: "one * two", origin: "added", verification: { quoteScore: 0.97, quoteCheckedAt: "2026-10-04T12:00:00Z" } }, + a1: { kind: "audio", channel: "demo-podcast", id: "ep-1", start: 10.5, end: 20, quote: "two", speaker: "Guest", origin: "subject" }, p1: { kind: "post", channel: "demo-social", id: "123", quote: "three" }, s1: { kind: "source", source: "s0", quote: "four", image: "stills/s1.png" }, w1: { kind: "page", url: "https://example.org/p", title: "A page", quote: "five", archiveUrl: "https://archive.example.org/p" }, @@ -57,6 +53,8 @@ const report: Report = { title: "One", text: "Claim one.", verdict: "CONTRADICTED", + gist: "The recording says otherwise.", + flag: "No source given", sourceQuote: { citation: "s1" }, findings: "See [this](cite:w1), **bold**, and `[code](cite:never)`.\n\n- a list item\n- another", citations: ["v1", "a1"], @@ -111,17 +109,49 @@ test("the heading: the series on its own line, the title with the document's byl 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>/, ); - assert.match(html, /<p class="site-line" data-report-kind="">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, /<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"/); }); -test("subjectByline: author and publisher; the author alone takes the link; neither is none", () => { - assert.deepEqual(subjectByline(view), { author: "A. Writer", publisher: "Example Gazette", url: "https://example.org/a" }); - const only = (s: object) => subjectByline({ subject: "s0", sources: { s0: { id: "s0", kind: "article", title: "t", archives: [], ...s } } }); - assert.deepEqual(only({ author: "A", url: "https://x.example" }), { author: "A", url: "https://x.example" }); - assert.equal(only({ url: "https://x.example" }), undefined); - assert.match(reportExportHtml({ ...view, sources: { s0: { ...view.sources.s0, publisher: undefined } } }, opts), /by <a href="https:\/\/example\.org\/a">A\. Writer<\/a>/); +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 three tiers: the quick take, what the check found, every claim from how it was checked", () => { + const html = reportExportHtml(view, opts); + const order = ['data-report-tier="1"', 'data-verdict-tally', 'data-report-jumps', 'id="found" data-report-tier="2"', 'id="claims" data-report-tier="3"', "data-report-method", 'id="references"']; + const at = order.map((m) => html.indexOf(m)); + assert.ok(at.every((i) => i >= 0), JSON.stringify(at)); + assert.deepEqual([...at].sort((a, b) => a - b), at, "in the page's order"); + assert.match(html, /<div class="tier" data-tier-marker="1"><span class="dots" aria-hidden="true"><i class="on"><\/i><i class=""><\/i><i class=""><\/i><\/span><span class="min">1 min<\/span>/); + assert.match(html, /<a href="#found">What the check found<\/a> · <a href="#claims">Every claim<\/a> · <a href="#references">References<\/a>/); + // Grouped in FOUND_VERDICT_ORDER, each row its title, gist and flag, to the claim. + const groups = [...html.matchAll(/data-found-group="([A-Z_]+)"/g)].map((m) => m[1]); + assert.deepEqual(groups, ["CONTRADICTED", "PARTLY"]); + assert.match(html, /<li data-found-row="c1"><a href="#c1">One<\/a> <span class="gist">— The recording says otherwise\.<\/span> <span class="flag" data-claim-flag=""><svg class="mark"/); + assert.match(html, /<h3 class="label">How it was checked<\/h3><p>Each quote was checked against the recording\.<\/p>/); + // A sweep has tiers 1 and 3. + const sweep = reportExportHtml({ ...view, kind: "sweep" }, opts); + assert.doesNotMatch(sweep, /data-report-tier="2"/); + assert.match(sweep, /data-report-tier="3"/); +}); + +test("a claim: its flag, the subject's sentence on the accent rail with no link, evidence by origin", () => { + const html = reportExportHtml(view, opts); + const claim = html.slice(html.indexOf('<article class="claim" id="c1"'), html.indexOf("</article>", html.indexOf('id="c1"'))); + 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">/); + // 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">/); + assert.match(reportExportHtml(view, { ...opts, print: true }), /<details class="given" data-subject-evidence="" open>/, "printed open"); + // The reference list marks added evidence too. + assert.match(html, /<li id="c-v1"[^>]*><p class="quote"><q>one \* two<\/q><\/p><p class="meta"><span class="added"/); }); test("verdicts, inline markers [n] to the reference list, and references with every link", () => { @@ -145,7 +175,7 @@ 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 }); assert.doesNotMatch(html, /reports\.example\.org/); - assert.match(html, /<p class="site-line" data-report-kind="">Fact-check<\/p>/); + 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"/); @@ -184,7 +214,16 @@ test("the Markdown export: the heading lines, `label [n]` citations, numbered re assert.match(md, /It starts here \[1\]\./); assert.match(md, /See this \[3\], \*\*bold\*\*/); assert.match(md, /^### One$/m); - assert.match(md, /^\*\*Verdict: Contradicted\*\*$/m); + assert.match(md, /^\*\*Verdict: Contradicted\*\* \*\[No source given\]\*$/m); + // The tiers as plain lines; what the check found by verdict; how it was checked. + assert.deepEqual([...md.matchAll(/^— (\d+) min —$/gm)].map((m) => m[1]), ["1", "1", "1"]); + assert.match(md, /## What the check found\n\n### Contradicted \(1\)\n\n- One — The recording says otherwise\. \*\[No source given\]\*\n/); + assert.match(md, /## Every claim, with its evidence\n\n\*\*How it was checked\*\*\n\nEach quote was checked against the recording\.\n/); + // Evidence by origin: the added marked in words, the subject's under its own line. + assert.match(md, /Evidence:\n\n- \[1\] Not in the article: Video, /); + assert.match(md, /In the article:\n\n- \[4\] Audio, /); + assert.match(md, /^1\. “one \\\* two” \n Not in the article \n/m); + assert.doesNotMatch(reportExportMarkdown({ ...view, kind: "sweep" }, opts), /What the check found/); const refs = [...md.matchAll(/^(\d+)\. “(.*)”/gm)].map((m) => `${m[1]}:${m[2]}`); assert.deepEqual(refs, ["1:one \\* two", "2:four", "3:five", "4:two", "5:three"]); assert.match(md, /Moment page: <https:\/\/reports\.example\.org\/m\/demo-channel\/abc123\/3126\.00-3151\.00\/>/); diff --git a/common/lib/report/exportHtml.ts b/common/lib/report/exportHtml.ts @@ -10,28 +10,41 @@ // 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: the series on its own line in the accent, the title with the -// reviewed document's author and publisher inline after it, what the report -// is and which site publishes it, the dates, the subtitle, a fact-check's verdict tally, the summary, -// the sections and their claims — each its verdict, the document's own -// sentence, the findings with numbered citation markers, the evidence — the -// documents quoted with their archive links, and the numbered reference list: -// each quote with its speaker, date, record, the original at its time, and the -// moment page and clip on the site when the site has a public URL. Then the +// 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 +// 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 +// with its evidence, opening with how it was checked — each claim its +// verdict and flag pill, the document's sentence on its rail, the findings +// with numbered markers, and the evidence: what the report added first +// (the project's mark, "Not in the article"), then the rest, then what the +// document gave itself under "In the article (n)" (a <details>, printed +// open). Then the documents quoted with their archive links, the numbered +// references (quote, speaker, date, record, the original at its time, the +// moment page and clip on the site when it has a public URL), and the // footer naming exactly which document this is (ReportExportFooter). // // PURE: no I/O, deterministic for its input. Imports only pure helpers. import { citationAnchor, CITE_SCHEME } from "../citations/inline"; import type { SourceArchive } from "../citations/schema"; +import { ICON_PALETTES, markSvg } from "../brand"; import { CITATION_KIND_LABELS, + foundGroups, orderedCitations, + reportAttribution, + reportByline, reportFullTitle, reportPagePath, + reportTierMinutes, sourceAnchor, spanLabel, verdictTally, + type BylinePart, type CitationView, type ClaimView, type ReportPageView, @@ -92,6 +105,9 @@ export type ReportExportHtmlOptions = ReportExportOptions & { image: (sitePath: string) => string | undefined; // A span citation's clip, or undefined for none. clip?: (c: SpanCitationView) => ExportClip | undefined; + // For print (the PDF): what the page folds away — the evidence the + // document under review gave itself — is printed open. + print?: boolean; }; // ─── Small helpers ─── @@ -284,7 +300,29 @@ 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)} .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-radius:6px;background:var(--surface);font-size:.95rem} +.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} +.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)} +.tier .dots i.on{background:var(--accent);border-color:var(--accent)} +.tier .min{font:.75rem/1 ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--muted)} +.tier .rule{flex:1;height:1px;background:var(--border)} +.tier+h2{margin-top:.5rem;padding-top:0;border-top:0} +h2.section{font-size:1.3rem} +.jumps{font-size:.92rem} +.found h3{margin:1rem 0 .4rem} +.found ul{margin:0;padding-left:1.1rem;font-size:.95rem} +.found li{margin:.3rem 0} +.found li>a{color:var(--fg);font-weight:600;text-decoration:none} +.gist{color:var(--muted)} +.method{margin:.75rem 0} +.flag,.added{display:inline-flex;align-items:center;gap:.35rem;white-space:nowrap} +.flag{border:1px solid color-mix(in srgb,var(--accent) 40%,#fff);background:color-mix(in srgb,var(--accent) 8%,#fff);color:var(--accent);border-radius:999px;padding:.05rem .6rem;font-size:.78rem;font-weight:500} +.added{font:.7rem/1.2 ui-monospace,SFMono-Regular,Menlo,monospace;text-transform:uppercase;letter-spacing:.12em;color:var(--muted)} +svg.mark{flex:none;border-radius:22%} +details.given{margin:.4rem 0 0} +details.given summary{cursor:pointer;font:.8rem/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--muted)} +.source{padding-left:.75rem;border-left:4px solid var(--border)} .tally{display:flex;flex-wrap:wrap;gap:.4rem;list-style:none;padding:0;margin:.4rem 0 0} .verdict{display:inline-flex;align-items:center;gap:.4rem;border:1px solid var(--v);border-radius:999px;padding:.1rem .65rem;font-size:.8rem;font-weight:600;background:color-mix(in srgb,var(--v) 14%,#fff);white-space:nowrap} .verdict::before{content:"";width:.5rem;height:.5rem;border-radius:50%;background:var(--v)} @@ -295,7 +333,7 @@ nav.toc ol{margin:.5rem 0;padding-left:1.4rem} .claim-head h3{flex:1 1 15rem} .says{color:var(--muted);font-size:.95rem} .says q{color:var(--fg)} -figure.sentence{margin:.75rem 0} +figure.sentence{margin:.75rem 0;padding-left:.75rem;border-left:4px solid var(--border)} figure.sentence img{display:block;border:1px solid var(--border);border-radius:4px;background:#fff;break-inside:avoid} figure.sentence figcaption{font-size:.85rem;color:var(--muted);margin-top:.3rem} .evidence{margin:.4rem 0 0;padding-left:1.2rem;font-size:.92rem} @@ -381,7 +419,22 @@ function link(href: string, text?: string): string { return `<a href="${escapeHtml(href)}">${escapeHtml(text ?? href)}</a>`; } -function reference(c: CitationView, opts: ReportExportHtmlOptions): string { +// Evidence the report's author found (`origin: "added"`): the project's mark +// (the site's AddedMark, as a static SVG) and the words. +const ADDED_MARK = markSvg(ICON_PALETTES.archilyzer).replace( + "<svg ", + '<svg class="mark" width="12" height="12" aria-hidden="true" focusable="false" ', +); + +function addedTag(label: string): string { + return `<span class="added" data-citation-added="">${ADDED_MARK}${escapeHtml(label)}</span>`; +} + +function flagPill(flag: string): string { + return `<span class="flag" data-claim-flag="">${ADDED_MARK}${escapeHtml(flag)}</span>`; +} + +function reference(c: CitationView, opts: ReportExportHtmlOptions, addedLabel: string): string { const meta = [ `<span class="kind">${CITATION_KIND_LABELS[c.kind]}</span>`, c.speaker ? escapeHtml(c.speaker) : null, @@ -429,6 +482,7 @@ function reference(c: CitationView, opts: ReportExportHtmlOptions): string { return ( `<li id="${escapeHtml(citationAnchor(c.id))}" value="${c.number ?? ""}" data-reference="${escapeHtml(c.id)}">` + `<p class="quote"><q>${escapeHtml(c.quote)}</q></p>` + + (c.origin === "added" ? `<p class="meta">${addedTag(addedLabel)}</p>` : "") + `<p class="meta">${meta.join(" · ")}</p>` + extra + (links.length > 0 ? `<p class="links">${links.join("<br>")}</p>` : "") + @@ -437,59 +491,94 @@ function reference(c: CitationView, opts: ReportExportHtmlOptions): string { ); } -function claimHtml( - claim: ClaimView, - view: ReportPageView, - subjectLabel: string, - cite: (id: string) => CiteRef | undefined, - opts: ReportExportHtmlOptions, -): string { +// One line of a claim's evidence, numbered to its reference. +function evidenceItem(c: CitationView, addedLabel: string): string { + return ( + `<li data-evidence="${escapeHtml(c.id)}"><a href="#${escapeHtml(citationAnchor(c.id))}">[${c.number ?? "?"}]</a> ` + + `${c.origin === "added" ? `${addedTag(addedLabel)} ` : ""}` + + `${CITATION_KIND_LABELS[c.kind]} · ${citationSourceLine(c)} — <q>${escapeHtml(c.quote)}</q></li>` + ); +} + +type ClaimContext = { + view: ReportPageView; + subjectLabel: string; + subjectNoun: string; + cite: (id: string) => CiteRef | undefined; + opts: ReportExportHtmlOptions; +}; + +function claimHtml(claim: ClaimView, x: ClaimContext): string { + const { view, opts } = x; + const addedLabel = `Not in the ${x.subjectNoun}`; const parts: string[] = []; parts.push( `<div class="claim-head">${claim.verdict ? verdictChip(view, claim.verdict) : ""}` + - `<h3>${escapeHtml(claim.title ?? claim.text)}</h3></div>`, + `${claim.flag ? flagPill(claim.flag) : ""}<h3>${escapeHtml(claim.title ?? claim.text)}</h3></div>`, ); - if (claim.title) parts.push(`<p class="says">${escapeHtml(subjectLabel)}: <q>${escapeHtml(claim.text)}</q></p>`); + if (claim.title) parts.push(`<p class="says">${escapeHtml(x.subjectLabel)}: <q>${escapeHtml(claim.text)}</q></p>`); const sentence = claim.sourceQuote ? view.citations[claim.sourceQuote] : undefined; if (sentence?.kind === "source") { + // On a rail in the document's colour; a sentence of the document under + // review needs no link to it (the rail says whose it is). + const isSubject = sentence.sourceId === view.subject; const src = sentence.image ? opts.image(sentence.image) : undefined; const shown = src ? `<img src="${escapeHtml(src)}" alt="${escapeHtml(sentence.quote)}">` : `<blockquote><q>${escapeHtml(sentence.quote)}</q></blockquote>`; + const caption = [ + isSubject + ? citeMarker(x.cite(sentence.id), sentence.id) + : `from <a href="#${escapeHtml(sourceAnchor(sentence.sourceId))}">${escapeHtml(sentence.sourceTitle)}</a>${citeMarker(x.cite(sentence.id), sentence.id)}`, + claim.archives ? archiveList(claim.archives) : "", + ].join(""); parts.push( - `<figure class="sentence" data-source-sentence="${escapeHtml(sentence.id)}">${shown}` + - `<figcaption>from <a href="#${escapeHtml(sourceAnchor(sentence.sourceId))}">${escapeHtml(sentence.sourceTitle)}</a>` + - `${citeMarker(cite(sentence.id), sentence.id)}${claim.archives ? archiveList(claim.archives) : ""}</figcaption></figure>`, + `<figure class="sentence" data-source-sentence="${escapeHtml(sentence.id)}"${railStyle(sentence.sourceAccent)}>${shown}` + + `<figcaption>${caption}</figcaption></figure>`, ); } - if (claim.findings) parts.push(`<div class="findings">${markdownToHtml(claim.findings, cite, { siteUrl: opts.siteUrl, headingBase: 4 })}</div>`); + if (claim.findings) parts.push(`<div class="findings">${markdownToHtml(claim.findings, x.cite, { siteUrl: opts.siteUrl, headingBase: 4 })}</div>`); const evidence = claim.citations .filter((id) => id !== claim.sourceQuote) .map((id) => view.citations[id]) .filter((c): c is CitationView => !!c); + // What the report adds first, marked; then evidence of unknown origin; what + // the document under review gave itself folded away at the end (printed + // open). + const shown = [...evidence.filter((c) => c.origin === "added"), ...evidence.filter((c) => c.origin === undefined)]; + const given = evidence.filter((c) => c.origin === "subject"); if (evidence.length > 0) { - parts.push( - `<p class="label">Evidence</p><ul class="evidence">${evidence - .map( - (c) => - `<li><a href="#${escapeHtml(citationAnchor(c.id))}">[${c.number ?? "?"}]</a> ` + - `${CITATION_KIND_LABELS[c.kind]} · ${citationSourceLine(c)} — <q>${escapeHtml(c.quote)}</q></li>`, - ) - .join("")}</ul>`, - ); + parts.push(`<p class="label">Evidence</p>`); + if (shown.length > 0) parts.push(`<ul class="evidence">${shown.map((c) => evidenceItem(c, addedLabel)).join("")}</ul>`); + if (given.length > 0) { + parts.push( + `<details class="given" data-subject-evidence=""${opts.print ? " open" : ""}>` + + `<summary>${escapeHtml(`In the ${x.subjectNoun} (${given.length})`)}</summary>` + + `<ul class="evidence">${given.map((c) => evidenceItem(c, addedLabel)).join("")}</ul></details>`, + ); + } } return `<article class="claim" id="${escapeHtml(claim.id)}" data-claim="${escapeHtml(claim.id)}">${parts.join("\n")}</article>`; } -// ─── The document ─── +// A left rail in a document's colour (its `accent`, `#rrggbb` by validation), +// else the border colour the stylesheet gives. +function railStyle(accent: string | undefined): string { + return accent && /^#[0-9a-fA-F]{6}$/.test(accent) ? ` style="border-left-color:${accent}"` : ""; +} -// "Fact-check by <site title>" ("Report by …"), or the kind alone with no -// site title: our line under the title. -export function reportKindLine(view: Pick<ReportPageView, "kind">, siteTitle?: string): string { - const kind = view.kind === "factcheck" ? "Fact-check" : "Report"; - return siteTitle ? `${kind} by ${siteTitle}` : kind; +// Where a tier of the page begins: a hairline with, at its left, how deep it +// goes (one, two or three dots) and how long it takes to read. +function tierMarker(depth: 1 | 2 | 3, minutes: number): string { + const dots = [1, 2, 3].map((i) => `<i class="${i <= depth ? "on" : ""}"></i>`).join(""); + return ( + `<div class="tier" data-tier-marker="${depth}"><span class="dots" aria-hidden="true">${dots}</span>` + + `<span class="min">${Math.max(1, minutes)} min</span><span class="rule" aria-hidden="true"></span></div>` + ); } +// ─── The document ─── + // `Published <date>`, `Updated <date>` (when it differs). export function reportDates(view: Pick<ReportPageView, "published" | "updated">): string[] { return [ @@ -498,17 +587,8 @@ export function reportDates(view: Pick<ReportPageView, "published" | "updated">) ].filter((x): x is string => !!x); } -// The byline after the title: the author and publisher of the document under -// review (the subject source), the publisher linked to the document — or, with -// no publisher, the author. Neither: none. -export function subjectByline(view: Pick<ReportPageView, "subject" | "sources">): { author?: string; publisher?: string; url?: string } | undefined { - const s = view.subject ? view.sources[view.subject] : undefined; - if (!s || (!s.author && !s.publisher)) return undefined; - return { - ...(s.author ? { author: s.author } : {}), - ...(s.publisher ? { publisher: s.publisher } : {}), - ...(s.url ? { url: s.url } : {}), - }; +function bylinePart(p: BylinePart): string { + return p.href ? link(p.href, p.text) : escapeHtml(p.text); } export function reportExportHtml(view: ReportPageView, opts: ReportExportHtmlOptions): string { @@ -519,21 +599,23 @@ export function reportExportHtml(view: ReportPageView, opts: ReportExportHtmlOpt }; const md = (text: string, headingBase = 3) => markdownToHtml(text, cite, { siteUrl: opts.siteUrl, headingBase }); const subject = view.subject ? view.sources[view.subject] : undefined; - const subjectLabel = subject?.kind === "article" ? "The article says" : "The source says"; + const subjectNoun = subject?.kind === "article" ? "article" : "source"; + const subjectLabel = `The ${subjectNoun} says`; const tally = isFactcheck ? verdictTally(view) : []; + const groups = isFactcheck ? foundGroups(view) : []; + const minutes = reportTierMinutes(view); const claimCount = view.sections.reduce((n, s) => n + s.claims.length, 0); 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. + // the report is and who publishes it, its dates — then the subtitle and the + // document under review. const head: string[] = []; - const by = subjectByline(view); + const by = reportByline(view); const byHtml = by - ? ` <span class="by" data-report-byline="">by ${[ - by.author ? (by.url && !by.publisher ? link(by.url, by.author) : escapeHtml(by.author)) : null, - by.publisher ? (by.url ? link(by.url, by.publisher) : escapeHtml(by.publisher)) : null, - ] + ? ` <span class="by" data-report-byline="">by ${[by.author ? bylinePart(by.author) : null, by.publisher ? bylinePart(by.publisher) : null] .filter(Boolean) .join(" · ")}</span>` : ""; @@ -541,29 +623,64 @@ export function reportExportHtml(view: ReportPageView, opts: ReportExportHtmlOpt `<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-kind="">${escapeHtml(reportKindLine(view, opts.siteTitle))}</p>`); + 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>`); if (subject) { - const by = sourceByline(subject); + const sb = sourceByline(subject); head.push( - `<div class="under-review"><p class="label" style="margin-top:0">Under review</p>` + - `<p style="margin:0">${sourceTitleHtml(subject)}</p>${by ? `<p class="meta" style="margin:.2rem 0 0">${by}</p>` : ""}` + + `<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>`, ); } + const body: string[] = [`<header class="report-head">${head.join("\n")}</header>`]; + + // Tier 1, the quick take: the tally, the summary, where to go next. + const quick: string[] = [tierMarker(1, minutes.quick)]; if (tally.length > 0) { - head.push( + quick.push( `<p class="label">${plural(claimCount, "claim")} checked</p>` + `<ul class="tally" data-verdict-tally="">${tally.map((t) => `<li>${verdictChip(view, t.verdict, t.count)}</li>`).join("")}</ul>`, ); } + if (view.summary) quick.push(`<div class="summary">${md(view.summary)}</div>`); + const jumps = [ + ...(groups.length > 0 ? [["#found", "What the check found"]] : []), + ["#claims", "Every claim"], + ...(orderedCitations(view).length > 0 ? [["#references", "References"]] : []), + ]; + quick.push(`<p class="jumps" data-report-jumps="">${jumps.map(([h, l]) => `<a href="${h}">${l}</a>`).join(" · ")}</p>`); + body.push(`<section class="tier-1" data-report-tier="1" aria-label="In brief">${quick.join("\n")}</section>`); + + // Tier 2, what the check found: every ruled claim by verdict, one line each. + if (groups.length > 0) { + body.push( + `<section id="found" data-report-tier="2">${tierMarker(2, minutes.found ?? 0)}<h2>What the check found</h2>` + + groups + .map( + (g) => + `<div class="found" data-found-group="${escapeHtml(g.verdict)}"><h3>${verdictChip(view, g.verdict, g.claims.length)}</h3><ul>` + + g.claims + .map( + (c) => + `<li data-found-row="${escapeHtml(c.id)}"><a href="#${escapeHtml(c.id)}">${escapeHtml(c.title ?? c.text)}</a>` + + `${c.gist ? ` <span class="gist">— ${escapeHtml(c.gist)}</span>` : ""}${c.flag ? ` ${flagPill(c.flag)}` : ""}</li>`, + ) + .join("") + + `</ul></div>`, + ) + .join("\n") + + `</section>`, + ); + } - const body: string[] = [`<header class="report-head">${head.join("\n")}</header>`]; - if (view.summary) body.push(`<section class="summary">${md(view.summary)}</section>`); + // Tier 3, every claim with its evidence, opening with how it was checked. + const full: string[] = [tierMarker(3, minutes.claims), `<h2>Every claim, with its evidence</h2>`]; + if (view.method) full.push(`<div class="method" data-report-method=""><h3 class="label">How it was checked</h3>${md(view.method)}</div>`); if (view.sections.length > 1) { - body.push( + full.push( `<nav class="toc" aria-label="Sections"><ol>${view.sections .map( (s) => @@ -574,11 +691,13 @@ export function reportExportHtml(view: ReportPageView, opts: ReportExportHtmlOpt ); } for (const s of view.sections) { - body.push( - `<section id="${escapeHtml(s.id)}" data-section="${escapeHtml(s.id)}"><h2>${escapeHtml(s.title)}</h2>` + - `${s.body ? md(s.body) : ""}${s.claims.map((c) => claimHtml(c, view, subjectLabel, cite, opts)).join("\n")}</section>`, + full.push( + `<section id="${escapeHtml(s.id)}" data-section="${escapeHtml(s.id)}"><h2 class="section">${escapeHtml(s.title)}</h2>` + + `${s.body ? md(s.body) : ""}${s.claims.map((c) => claimHtml(c, ctx)).join("\n")}</section>`, ); } + body.push(`<div id="claims" data-report-tier="3">${full.join("\n")}</div>`); + // Every document the report quotes, the subject first, each once with its // archive links in context. const sources = Object.values(view.sources); @@ -586,12 +705,12 @@ export function reportExportHtml(view: ReportPageView, opts: ReportExportHtmlOpt body.push( `<section id="sources"><h2>Sources</h2>${sources .map((s) => { - const by = sourceByline(s); + const sb = sourceByline(s); return ( - `<div class="source" id="${escapeHtml(sourceAnchor(s.id))}"><p style="margin:0"><strong>${sourceTitleHtml(s)}</strong>` + + `<div class="source" id="${escapeHtml(sourceAnchor(s.id))}"${railStyle(s.accent)}><p style="margin:0"><strong>${sourceTitleHtml(s)}</strong>` + `${s.id === view.subject ? ` <span class="meta">(under review)</span>` : ""}</p>` + `${s.url ? `<p class="meta" style="margin:.1rem 0">${escapeHtml(s.url)}</p>` : ""}` + - `${by ? `<p class="meta" style="margin:.1rem 0">${by}</p>` : ""}` + + `${sb ? `<p class="meta" style="margin:.1rem 0">${sb}</p>` : ""}` + `${s.note ? `<p class="meta" style="margin:.1rem 0">${escapeHtml(s.note)}</p>` : ""}` + `${s.archives.length > 0 ? `<p class="label">${plural(s.archives.length, "archive link")} in context</p>${archiveList(s.archives)}` : ""}</div>` ); @@ -603,7 +722,7 @@ export function reportExportHtml(view: ReportPageView, opts: ReportExportHtmlOpt if (refs.length > 0) { body.push( `<section id="references"><h2>References</h2><ol class="references" data-reference-list="">${refs - .map((c) => reference(c, opts)) + .map((c) => reference(c, opts, `Not in the ${subjectNoun}`)) .join("\n")}</ol></section>`, ); } diff --git a/common/lib/report/exportMarkdown.ts b/common/lib/report/exportMarkdown.ts @@ -14,21 +14,18 @@ import { CITE_SCHEME } from "../citations/inline"; import type { SourceArchive } from "../citations/schema"; -import { - dateLabel, - reportDates, - reportExportFooterLine, - reportKindLine, - siteLink, - subjectByline, - type ReportExportOptions, -} from "./exportHtml"; +import { dateLabel, reportDates, reportExportFooterLine, siteLink, type ReportExportOptions } from "./exportHtml"; import { CITATION_KIND_LABELS, + foundGroups, orderedCitations, + reportAttribution, + reportByline, reportPagePath, + reportTierMinutes, spanLabel, verdictTally, + type BylinePart, type CitationView, type ClaimView, type ReportPageView, @@ -90,7 +87,7 @@ function citationDate(c: CitationView): string | undefined { return dateLabel(c.date ?? (c.kind === "video" || c.kind === "audio" || c.kind === "post" ? c.record.date : undefined)); } -function referenceLines(c: CitationView, siteUrl: string | undefined): string[] { +function referenceLines(c: CitationView, siteUrl: string | undefined, addedLabel: string): string[] { const meta = [ CITATION_KIND_LABELS[c.kind], c.speaker ? mdText(c.speaker) : null, @@ -98,7 +95,9 @@ function referenceLines(c: CitationView, siteUrl: string | undefined): string[] citationWhere(c), c.verification?.quoteScore !== undefined ? `quote match ${Math.round(c.verification.quoteScore * 100)}%` : null, ].filter(Boolean); - const out = [`${c.number ?? "-"}. “${mdText(c.quote)}”`, ` ${meta.join(" · ")}`]; + const out = [`${c.number ?? "-"}. “${mdText(c.quote)}”`]; + if (c.origin === "added") out.push(` ${addedLabel}`); + out.push(` ${meta.join(" · ")}`); for (const x of [c.label, c.note]) if (x) out.push(` ${mdText(x)}`); const link = (label: string, url: string) => out.push(` ${label}: <${url}>`); switch (c.kind) { @@ -128,13 +127,35 @@ function referenceLines(c: CitationView, siteUrl: string | undefined): string[] return out.map((l, i, a) => (i < a.length - 1 ? `${l} ` : l)); } -function claimLines(claim: ClaimView, view: ReportPageView, subjectLabel: string, siteUrl: string | undefined): string[] { +// A tier's start, as a plain line: `— 6 min —`. +function tierLine(minutes: number): string { + return `— ${Math.max(1, minutes)} min —`; +} + +function evidenceLine(c: CitationView, addedLabel: string): string { + return `- [${c.number ?? "?"}] ${c.origin === "added" ? `${addedLabel}: ` : ""}${CITATION_KIND_LABELS[c.kind]}, ${citationWhere(c)}: “${mdText(c.quote)}”`; +} + +function claimLines( + claim: ClaimView, + view: ReportPageView, + subjectLabel: string, + subjectNoun: string, + siteUrl: string | undefined, +): string[] { + const addedLabel = `Not in the ${subjectNoun}`; const out: string[] = [`### ${mdText(claim.title ?? claim.text)}`, ""]; - if (claim.verdict) out.push(`**Verdict: ${mdText(view.verdicts[claim.verdict].label)}**`, ""); + const marks = [ + claim.verdict ? `**Verdict: ${mdText(view.verdicts[claim.verdict].label)}**` : null, + claim.flag ? `*[${mdText(claim.flag)}]*` : null, + ].filter(Boolean); + if (marks.length > 0) out.push(marks.join(" "), ""); if (claim.title) out.push(`${subjectLabel}: “${mdText(claim.text)}”`, ""); const sentence = claim.sourceQuote ? view.citations[claim.sourceQuote] : undefined; if (sentence?.kind === "source") { - out.push(`> “${mdText(sentence.quote)}” [${sentence.number ?? "?"}]`, ">", `> — from *${mdText(sentence.sourceTitle)}*`, ""); + out.push(`> “${mdText(sentence.quote)}” [${sentence.number ?? "?"}]`); + if (sentence.sourceId !== view.subject) out.push(">", `> — from *${mdText(sentence.sourceTitle)}*`); + out.push(""); if (claim.archives?.length) out.push(...archiveLines(claim.archives), ""); } if (claim.findings) out.push(citedMarkdownToPlain(claim.findings, view, siteUrl).trim(), ""); @@ -142,37 +163,41 @@ function claimLines(claim: ClaimView, view: ReportPageView, subjectLabel: string .filter((id) => id !== claim.sourceQuote) .map((id) => view.citations[id]) .filter((c): c is CitationView => !!c); - if (evidence.length > 0) { - out.push("Evidence:", ""); - for (const c of evidence) out.push(`- [${c.number ?? "?"}] ${CITATION_KIND_LABELS[c.kind]}, ${citationWhere(c)}: “${mdText(c.quote)}”`); - out.push(""); - } + // What the report adds first, then evidence of unknown origin, then what + // the document under review gave itself. + const shown = [...evidence.filter((c) => c.origin === "added"), ...evidence.filter((c) => c.origin === undefined)]; + const given = evidence.filter((c) => c.origin === "subject"); + if (shown.length > 0) out.push("Evidence:", "", ...shown.map((c) => evidenceLine(c, addedLabel)), ""); + if (given.length > 0) out.push(`In the ${subjectNoun}:`, "", ...given.map((c) => evidenceLine(c, addedLabel)), ""); 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; - const subjectLabel = subject?.kind === "article" ? "The article says" : "The source says"; + const subjectNoun = subject?.kind === "article" ? "article" : "source"; + const subjectLabel = `The ${subjectNoun} says`; const pageUrl = siteLink(opts.siteUrl, reportPagePath(view.id)); 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 = subjectByline(view); - if (by) { - const author = by.author ? (by.url && !by.publisher ? mdLink(by.author, by.url) : mdText(by.author)) : null; - const publisher = by.publisher ? (by.url ? mdLink(by.publisher, by.url) : mdText(by.publisher)) : null; - out.push(`by ${[author, publisher].filter(Boolean).join(" · ")}`, ""); - } - out.push([mdText(reportKindLine(view, opts.siteTitle)), ...reportDates(view)].join(" · "), ""); + 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)}*`, ""); 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(" · ")}` : ""}`, ""); } + + // Tier 1: the quick take. + out.push(tierLine(minutes.quick), ""); const tally = isFactcheck ? verdictTally(view) : []; if (tally.length > 0) { const claims = view.sections.reduce((n, s) => n + s.claims.length, 0); @@ -183,10 +208,26 @@ export function reportExportMarkdown(view: ReportPageView, opts: ReportExportOpt } if (view.summary) out.push(citedMarkdownToPlain(view.summary, view, opts.siteUrl).trim(), ""); + // Tier 2: what the check found (a fact-check with verdicts). + const groups = isFactcheck ? foundGroups(view) : []; + if (groups.length > 0) { + out.push(tierLine(minutes.found ?? 0), "", "## What the check found", ""); + for (const g of groups) { + out.push(`### ${mdText(view.verdicts[g.verdict].label)} (${g.claims.length})`, ""); + for (const c of g.claims) { + out.push(`- ${mdText(c.title ?? c.text)}${c.gist ? ` — ${mdText(c.gist)}` : ""}${c.flag ? ` *[${mdText(c.flag)}]*` : ""}`); + } + out.push(""); + } + } + + // Tier 3: every claim, with its evidence. + out.push(tierLine(minutes.claims), "", "## Every claim, with its evidence", ""); + if (view.method) out.push("**How it was checked**", "", citedMarkdownToPlain(view.method, view, opts.siteUrl).trim(), ""); for (const s of view.sections) { out.push(`## ${mdText(s.title)}`, ""); if (s.body) out.push(citedMarkdownToPlain(s.body, view, opts.siteUrl).trim(), ""); - for (const c of s.claims) out.push(...claimLines(c, view, subjectLabel, opts.siteUrl)); + for (const c of s.claims) out.push(...claimLines(c, view, subjectLabel, subjectNoun, opts.siteUrl)); } const sources = Object.values(view.sources); @@ -205,7 +246,7 @@ export function reportExportMarkdown(view: ReportPageView, opts: ReportExportOpt const refs = orderedCitations(view); if (refs.length > 0) { out.push("## References", ""); - for (const c of refs) out.push(...referenceLines(c, opts.siteUrl), ""); + for (const c of refs) out.push(...referenceLines(c, opts.siteUrl, `Not in the ${subjectNoun}`), ""); } out.push("---", "", reportExportFooterLine(opts.footer)); diff --git a/common/publish/reportExports.ts b/common/publish/reportExports.ts @@ -17,7 +17,8 @@ // screenshot a data: URI, recompressed here to WebP (or // JPEG) at most EXPORT_IMAGE_MAX_WIDTH wide; clips are // linked on the site, never inlined -// report.pdf that HTML printed by headless Chromium (A4). Where +// report.pdf that HTML printed by headless Chromium (A4), its folded +// evidence open. Where // Playwright or its browser is missing the PDF is skipped // with a note — never a failure // report.md plain Markdown, numbered references @@ -438,17 +439,21 @@ export async function writeReportExports(o: { if (uri) dataUris.set(p, uri); else problems.push({ report: report.id, format: "html", message: `the image ${p} cannot be read` }); } - const html = reportExportHtml(view, { + const htmlOpts = { siteUrl, siteTitle, footer, - image: (p) => dataUris.get(p), - clip: (c) => { + image: (p: string) => dataUris.get(p), + clip: (c: SpanCitationView) => { const clip = files.clip(c); const href = clip && siteUrl ? `${siteUrl.replace(/\/+$/, "")}${clip.sitePath}` : undefined; return clip && href ? { href, kind: clip.kind } : undefined; }, - }); + }; + const html = reportExportHtml(view, htmlOpts); + // 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 dir = o.dir; @@ -485,7 +490,7 @@ export async function writeReportExports(o: { break; } try { - await writeFileAtomic(out, Buffer.from(await printer.print(html))); + await writeFileAtomic(out, Buffer.from(await printer.print(printHtml))); await record(f); } catch (e) { notes.push(`report.pdf skipped: printing failed (${firstLine(e)})`);