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:
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)})`);