// A REPORT AS ONE HTML FILE — the export a reader saves and hosts again // (publish/reportExports.ts writes it as `report.html`, prints it to // `report.pdf`, and packs a variant of it with its media as the evidence // pack). Built from the report's page view (./views.ts), the same view the // export site renders, so the file says what the page says. // // ONE FILE, NO NETWORK FOR WHAT IT SHOWS: its own small stylesheet inline, no // script, no font or image fetched. Every image (a source's sentence as a // still, a post's screenshot) is whatever the caller's `image` resolver // 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 one line and the title on the next, or the title alone; 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 (a hairline and depth dots): the quick take (the // tally, the summary, jump links); the timeline when the report keeps one // (its dated entries, newest first, each with its anchor); 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 beside its number), then the rest, then what the // document gave itself under "In the article (n)" (a
, 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, entryDateLabel, foundGroups, orderedCitations, reportDateParts, reportFullTitle, reportPagePath, reportRevisionLabel, sourceAnchor, spanLabel, subjectMetaParts, verdictTally, type CitationView, type ClaimView, type ReportPageView, type SourceView, type SpanCitationView, } from "./views"; // ─── The footer: which document this is ─── // What the footer names. `reportSha256` is the sha256 of the report.json the // export was made from (hex). `revision` and `commit` are the report's // revision and the commit of the corpus it was published from — filled by the // revision history (slice RH); until then absent, and an absent part is left // out of the line, never shown as unknown. export type ReportExportFooter = { revision?: number; // The revision's date (`YYYY-MM-DD` or an ISO date-time). date?: string; reportSha256: string; commit?: string; }; // `Revision N · · report sha256 · commit `, the // unknown parts left out. export function reportExportFooterLine(f: ReportExportFooter): string { return [ f.revision !== undefined ? `Revision ${f.revision}` : null, dateLabel(f.date) ?? null, `report sha256 ${f.reportSha256.slice(0, 12)}`, f.commit ? `commit ${f.commit.slice(0, 12)}` : null, ] .filter(Boolean) .join(" · "); } // ─── Options ─── export type ExportClip = { href: string; kind: "video" | "audio"; // Play it in place (the evidence pack, whose media/ holds it); else a link. play?: boolean; }; 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; footer: ReportExportFooter; }; export type ReportExportHtmlOptions = ReportExportOptions & { // A site-root image path the view names (a still, a post's shot) → the // : a data: URI, or a path relative to the file. Undefined: the // image is left out (a source sentence then shows its quote as text). 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 ─── export function escapeHtml(s: string): string { return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); } // `2026-10-04T12:00:00Z` → `2026-10-04`; a partial date as given. export function dateLabel(v: string | undefined): string | undefined { if (!v) return undefined; return /^\d{4}-\d{2}-\d{2}T/.test(v) ? v.slice(0, 10) : v; } // A site-root path on the site's public URL, or undefined without one. export function siteLink(siteUrl: string | undefined, sitePath: string): string | undefined { if (!siteUrl) return undefined; return `${siteUrl.replace(/\/+$/, "")}${sitePath.startsWith("/") ? "" : "/"}${sitePath}`; } // A link a reader may follow out of a saved file: http(s) or mailto. A // fragment stays in the file (or goes to `fragmentBase`, the page it belongs // to, when the HTML is read elsewhere — a feed); a site-root path goes to the // site, when known. function safeHref(href: string, siteUrl: string | undefined, fragmentBase?: string): string | undefined { if (/^(https?:|mailto:)/i.test(href)) return href; if (href.startsWith("#")) return fragmentBase ? `${fragmentBase}${href}` : href; if (href.startsWith("/")) return siteLink(siteUrl, href); return undefined; } const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`; // ─── Markdown, the small subset a report writes ─── // A citation's number and its reference's anchor in the file; `href`, when // given, is where the marker links instead (a feed links the reference on // the report's page). export type CiteRef = { number?: number; anchor: string; href?: string }; // A citation marker: `[n]` linking to its reference. function citeMarker(ref: CiteRef | undefined, id: string): string { const n = ref?.number !== undefined ? String(ref.number) : "?"; const href = ref?.href ?? `#${ref?.anchor ?? citationAnchor(id)}`; return `[${n}]`; } const PH = "\u0000"; // Emphasis over already-escaped text. function emphasis(escaped: string): string { return escaped .replace(/\*\*(?=\S)([\s\S]*?\S)\*\*/g, "$1") .replace(/(^|[^\w])__(?=\S)([\s\S]*?\S)__(?!\w)/g, "$1$2") .replace(/\*(?=\S)([^*]*?\S)\*/g, "$1") .replace(/(^|[^\w])_(?=\S)([^_]*?\S)_(?!\w)/g, "$1$2"); } // One paragraph's inline markdown: code spans, links (a `cite:` link is the // label and its number), autolinks, emphasis. Raw HTML is escaped. function inlineMd( text: string, cite: (id: string) => CiteRef | undefined, siteUrl: string | undefined, fragmentBase?: string, ): string { const held: string[] = []; const hold = (html: string) => `${PH}${held.push(html) - 1}${PH}`; let s = text.replace(/(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/g, (_m, _t, code: string) => hold(`${escapeHtml(code.trim())}`), ); s = s.replace(/\[([^\]]*)\]\(\s*([^)\s]+)(?:\s+"[^"]*")?\s*\)/g, (_m, label: string, href: string) => { const shown = emphasis(escapeHtml(label)); if (href.startsWith(CITE_SCHEME)) { const id = href.slice(CITE_SCHEME.length).trim(); return hold(`${shown}${citeMarker(cite(id), id)}`); } const safe = safeHref(href, siteUrl, fragmentBase); return hold(safe ? `${shown}` : shown); }); s = s.replace(/<(https?:\/\/[^\s<>]+)>/g, (_m, url: string) => hold(`${escapeHtml(url)}`)); s = emphasis(escapeHtml(s)) .replace(/ {2,}\n/g, "
") .replace(/\n/g, " "); return s.replace(new RegExp(`${PH}(\\d+)${PH}`, "g"), (_m, i: string) => held[Number(i)]); } const LIST_RE = /^ {0,3}([-*+]|\d{1,9}[.)])\s+(.*)$/; const FENCE_RE = /^ {0,3}(`{3,}|~{3,})/; const HEADING_RE = /^ {0,3}(#{1,6})\s+(.*?)\s*#*\s*$/; const HR_RE = /^ {0,3}([-*_])(\s*\1){2,}\s*$/; const QUOTE_RE = /^ {0,3}>/; const isBlank = (l: string) => /^\s*$/.test(l); const startsBlock = (l: string) => FENCE_RE.test(l) || HEADING_RE.test(l) || HR_RE.test(l) || QUOTE_RE.test(l) || LIST_RE.test(l); // A report's markdown (a summary, a section's body, a claim's findings) as // HTML: paragraphs, headings (shifted under the page's own), lists, quotes, // code, rules. `headingBase` is the level a `#` becomes; `fragmentBase`, the // page a `#fragment` link is on when the HTML is read elsewhere. export function markdownToHtml( md: string, cite: (id: string) => CiteRef | undefined, opts: { siteUrl?: string; headingBase?: number; fragmentBase?: string } = {}, ): string { const lines = md.replace(/\r\n?/g, "\n").split("\n"); const base = opts.headingBase ?? 3; const inline = (t: string) => inlineMd(t, cite, opts.siteUrl, opts.fragmentBase); const out: string[] = []; let i = 0; while (i < lines.length) { const line = lines[i]; if (isBlank(line)) { i++; continue; } const fence = FENCE_RE.exec(line); if (fence) { const close = new RegExp(`^ {0,3}${fence[1][0] === "`" ? "`" : "~"}{${fence[1].length},}\\s*$`); const body: string[] = []; i++; while (i < lines.length && !close.test(lines[i])) body.push(lines[i++]); i++; out.push(`
${escapeHtml(body.join("\n"))}
`); continue; } const h = HEADING_RE.exec(line); if (h) { const level = Math.min(6, base - 1 + h[1].length); out.push(`${inline(h[2])}`); i++; continue; } if (HR_RE.test(line)) { out.push("
"); i++; continue; } if (QUOTE_RE.test(line)) { const inner: string[] = []; while (i < lines.length && QUOTE_RE.test(lines[i])) inner.push(lines[i++].replace(/^ {0,3}> ?/, "")); out.push(`
${markdownToHtml(inner.join("\n"), cite, opts)}
`); continue; } const first = LIST_RE.exec(line); if (first) { const ordered = /\d/.test(first[1]); const items: string[] = []; while (i < lines.length) { const m = LIST_RE.exec(lines[i]); if (m && /\d/.test(m[1]) === ordered) { items.push(m[2]); i++; } else if (items.length > 0 && !isBlank(lines[i]) && /^\s{2,}\S/.test(lines[i])) { items[items.length - 1] += `\n${lines[i++].trim()}`; } else break; } const start = ordered ? Number.parseInt(first[1], 10) : 1; const tag = ordered ? "ol" : "ul"; const attr = ordered && start !== 1 ? ` start="${start}"` : ""; out.push(`<${tag}${attr}>${items.map((it) => `
  • ${inline(it)}
  • `).join("")}`); continue; } const para: string[] = [line]; i++; while (i < lines.length && !isBlank(lines[i]) && !startsBlock(lines[i])) para.push(lines[i++]); out.push(`

    ${inline(para.join("\n").trim())}

    `); } return out.join("\n"); } // ─── The stylesheet ─── // Small and self-contained: system fonts, a light page that prints as it // reads. The verdict chip takes its colour from `--v` (the view's verdict // styles, report overrides applied), as the site's VerdictChip does. export const REPORT_EXPORT_CSS = ` :root{--fg:#1b1b1b;--muted:#5d5d5d;--border:#d8d8d8;--surface:#f6f6f4;--accent:#1f4e8c;color-scheme:light} *{box-sizing:border-box} html{-webkit-text-size-adjust:100%} body{margin:0;background:#fff;color:var(--fg);font:16px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif} 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;overflow-wrap:anywhere} h1 .series{display:block;color:var(--accent)} h1 .title{display:block;font-weight:400} 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} p{margin:.6rem 0} q{quotes:"\\201C" "\\201D"} blockquote{margin:.75rem 0;padding-left:.9rem;border-left:3px solid var(--border);color:var(--muted)} code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.88em;background:var(--surface);padding:.05em .3em;border-radius:3px} pre{background:var(--surface);padding:.75rem;border-radius:4px;overflow-x:auto} 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} .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)} .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)} .tier .dots i.on{background:var(--accent);border-color:var(--accent)} .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{opacity:.8;vertical-align:-.1em} .sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap} 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)} .verdict .n{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--muted);font-weight:400} nav.toc ol{margin:.5rem 0;padding-left:1.4rem} ol.timeline{list-style:none;margin:1rem 0 0;padding:0 0 0 1rem;border-left:2px solid var(--border)} ol.timeline>li{margin:0 0 1.5rem} .entry-date{margin:0;font:600 .95rem/1.3 ui-monospace,SFMono-Regular,Menlo,monospace} .entry-date a{text-decoration:none} .entry-date .meta{font-weight:400} ol.timeline h3{margin:.2rem 0 0} .claim{margin:1.25rem 0;padding:1rem 1.1rem;border:1px solid var(--border);border-radius:8px} .claim-head{display:flex;flex-wrap:wrap;align-items:baseline;gap:.4rem .7rem} .claim-head h3{flex:1 1 15rem} .claim-text{color:var(--muted);font-size:.95rem} 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} .evidence li{margin:.2rem 0} sup.cite{font-size:.72em;line-height:0;margin-left:.1em} sup.cite a{text-decoration:none;font-weight:600} .archives{margin:.3rem 0 0;padding-left:1.2rem;font-size:.85rem} .archives li{margin:.15rem 0} .archives .ctx{color:var(--muted)} .source{margin:1rem 0} ol.references{padding-left:2.2rem} ol.references>li{margin:0 0 1rem;padding-left:.2rem} ol.references .quote{margin:0;font-size:1rem} ol.references .meta,ol.references .links{margin:.15rem 0;font-size:.85rem} ol.references img{display:block;max-width:24rem;border:1px solid var(--border);border-radius:4px;margin:.4rem 0;break-inside:avoid} :target{background:#fff6d6} .export-footer{margin-top:3rem;padding-top:1rem;border-top:1px solid var(--border)} .export-footer p{margin:.25rem 0} @media print{ @page{size:A4;margin:16mm 14mm} body{font-size:11pt} main{max-width:none;padding:0} a{color:var(--fg)} h2{break-after:avoid} .claim{break-inside:auto} :target{background:none} } `.trim(); // ─── The parts ─── function verdictChip(view: ReportPageView, verdict: NonNullable, count?: number): string { const s = view.verdicts[verdict]; const n = count !== undefined ? ` ${count}` : ""; return `${escapeHtml(s.label)}${n}`; } function archiveList(archives: readonly SourceArchive[]): string { if (archives.length === 0) return ""; return `
      ${archives .map( (a) => `
    • ${escapeHtml(a.label)} ${escapeHtml(a.url)}` + `${a.context ? ` — ${escapeHtml(a.context)}` : ""}
    • `, ) .join("")}
    `; } function sourceByline(s: SourceView): string { return [s.publisher, s.author, dateLabel(s.date)].filter(Boolean).map((x) => escapeHtml(x!)).join(" · "); } function sourceTitleHtml(s: SourceView): string { return s.url ? `${escapeHtml(s.title)}` : escapeHtml(s.title); } // The citation's record or document, as one line: the title, then where. function citationSourceLine(c: CitationView): string { switch (c.kind) { case "video": case "audio": { const r = c.record; return [r.title ? `${escapeHtml(r.title)}` : null, r.channelTitle ? escapeHtml(r.channelTitle) : null, spanLabel(c.start, c.end)] .filter(Boolean) .join(" · "); } case "post": return [c.author ? escapeHtml(c.author) : null, c.record.channelTitle ? escapeHtml(c.record.channelTitle) : null, "post"] .filter(Boolean) .join(" · "); case "source": return `from ${escapeHtml(c.sourceTitle)}`; case "page": return c.title ? `${escapeHtml(c.title)}` : "web page"; } } function citationDate(c: CitationView): string | undefined { return dateLabel(c.date ?? (c.kind === "video" || c.kind === "audio" || c.kind === "post" ? c.record.date : undefined)); } function link(href: string, text?: string): string { return `${escapeHtml(text ?? href)}`; } // Evidence the report's author found (`origin: "added"`): the project's mark // alone (the site's AddedMark, as a static SVG), beside the citation's number; // the words are its tooltip and what a screen reader says. const ADDED_MARK = markSvg(ICON_PALETTES.archilyzer).replace( "${escapeHtml(label)}`; } function flagPill(flag: string): string { return `${ADDED_MARK}${escapeHtml(flag)}`; } function reference(c: CitationView, opts: ReportExportHtmlOptions, addedLabel: string): string { const meta = [ `${CITATION_KIND_LABELS[c.kind]}`, c.speaker ? escapeHtml(c.speaker) : null, citationDate(c) ? escapeHtml(citationDate(c)!) : null, citationSourceLine(c), c.verification?.quoteScore !== undefined ? `quote match ${Math.round(c.verification.quoteScore * 100)}%` : null, ].filter(Boolean); const links: string[] = []; let media = ""; switch (c.kind) { case "video": case "audio": { if (c.record.originalUrl) links.push(`Original at ${spanLabel(c.start, c.end).split("–")[0]}: ${link(c.record.originalUrl)}`); const moment = siteLink(opts.siteUrl, c.href); if (moment) links.push(`Moment page: ${link(moment)}`); const clip = opts.clip?.(c); if (clip) { links.push(`Clip: ${link(clip.href)}`); if (clip.play) { media = clip.kind === "audio" ? `` : ``; } } break; } case "post": { if (c.record.originalUrl) links.push(`Original: ${link(c.record.originalUrl)}`); const moment = siteLink(opts.siteUrl, c.href); if (moment) links.push(`Moment page: ${link(moment)}`); const shot = c.shot ? opts.image(c.shot) : undefined; if (shot) media = `${escapeHtml(`Screenshot of the post: ${c.quote}`)}`; break; } case "source": if (c.href) links.push(`Original: ${link(c.href)}`); break; case "page": links.push(`Original: ${link(c.href)}`); if (c.archiveUrl) links.push(`Archived: ${link(c.archiveUrl)}`); break; } const extra = [c.label, c.note].filter(Boolean).map((x) => `

    ${escapeHtml(x!)}

    `).join(""); return ( `
  • ` + `

    ${c.origin === "added" ? `${addedTag(addedLabel)} ` : ""}${escapeHtml(c.quote)}

    ` + `

    ${meta.join(" · ")}

    ` + extra + (links.length > 0 ? `` : "") + media + `
  • ` ); } // One line of a claim's evidence, numbered to its reference. function evidenceItem(c: CitationView, addedLabel: string): string { return ( `
  • [${c.number ?? "?"}] ` + `${c.origin === "added" ? `${addedTag(addedLabel)} ` : ""}` + `${CITATION_KIND_LABELS[c.kind]} · ${citationSourceLine(c)} — ${escapeHtml(c.quote)}
  • ` ); } type ClaimContext = { view: ReportPageView; 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( `
    ${claim.verdict ? verdictChip(view, claim.verdict) : ""}` + `${claim.flag ? flagPill(claim.flag) : ""}

    ${escapeHtml(claim.title ?? claim.text)}

    `, ); // The document's own sentence (its still, else its verbatim words) when the // claim cites one; else the claim as the report states it, plain. const sentence = claim.sourceQuote ? view.citations[claim.sourceQuote] : undefined; if (sentence?.kind !== "source") { if (claim.title) parts.push(`

    ${escapeHtml(claim.text)}

    `); } else { // 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 ? `${escapeHtml(sentence.quote)}` : `
    ${escapeHtml(sentence.quote)}
    `; const caption = [ isSubject ? citeMarker(x.cite(sentence.id), sentence.id) : `from ${escapeHtml(sentence.sourceTitle)}${citeMarker(x.cite(sentence.id), sentence.id)}`, claim.archives ? archiveList(claim.archives) : "", ].join(""); parts.push( `
    ${shown}` + `
    ${caption}
    `, ); } if (claim.findings) parts.push(`
    ${markdownToHtml(claim.findings, x.cite, { siteUrl: opts.siteUrl, headingBase: 4 })}
    `); 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(`

    Evidence

    `); if (shown.length > 0) parts.push(`
      ${shown.map((c) => evidenceItem(c, addedLabel)).join("")}
    `); if (given.length > 0) { parts.push( `
    ` + `${escapeHtml(`In the ${x.subjectNoun} (${given.length})`)}` + `
      ${given.map((c) => evidenceItem(c, addedLabel)).join("")}
    `, ); } } return `
    ${parts.join("\n")}
    `; } // 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}"` : ""; } // Where a tier of the page begins: a hairline with, at its left, how deep it // goes (one, two or three dots). Decoration only. function tierMarker(depth: 1 | 2 | 3): string { const dots = [1, 2, 3].map((i) => ``).join(""); return ``; } // ─── The document ─── export function reportExportHtml(view: ReportPageView, opts: ReportExportHtmlOptions): string { const isFactcheck = view.kind === "factcheck"; const cite = (id: string): CiteRef | undefined => { const c = view.citations[id]; return c ? { number: c.number, anchor: citationAnchor(id) } : undefined; }; const md = (text: string, headingBase = 3) => markdownToHtml(text, cite, { siteUrl: opts.siteUrl, headingBase }); const subject = view.subject ? view.sources[view.subject] : undefined; const subjectNoun = subject?.kind === "article" ? "article" : "source"; const tally = isFactcheck ? verdictTally(view) : []; const groups = isFactcheck ? foundGroups(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, subjectNoun, cite, opts }; // The heading, in the site's order: the series on one line and the title // on the next (or the title alone), 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 name = view.series ? `${escapeHtml(view.series)} ${escapeHtml(view.title)}` : escapeHtml(view.title); const head: string[] = [`

    ${name}

    `]; 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(`

    ${dateLine.join(" · ")}

    `); if (subject) { const meta = subjectMetaParts(subject).map((x) => `${escapeHtml(x)}`).join(" · "); head.push( `

    ${sourceTitleHtml(subject)}` + `${meta ? ` — ${meta}` : ""}

    `, ); } if (view.subtitle) head.push(`

    ${escapeHtml(view.subtitle)}

    `); const body: string[] = [`
    ${head.join("\n")}
    `]; // Tier 1, the quick take: the tally, the summary, where to go next. const quick: string[] = [tierMarker(1)]; if (tally.length > 0) { quick.push( `

    ${plural(claimCount, "claim")} checked

    ` + `
      ${tally.map((t) => `
    • ${verdictChip(view, t.verdict, t.count)}
    • `).join("")}
    `, ); } if (view.summary) quick.push(`
    ${md(view.summary)}
    `); const jumps = [ ...(groups.length > 0 ? [["#found", "What the check found"]] : []), ["#claims", "Every claim"], ...(orderedCitations(view).length > 0 ? [["#references", "References"]] : []), ]; quick.push(`

    ${jumps.map(([h, l]) => `${l}`).join(" · ")}

    `); body.push(`
    ${quick.join("\n")}
    `); // The timeline: its dated entries, newest first, each its own anchor. const entries = view.entries ?? []; if (entries.length > 0) { body.push( `

    Timeline

      ${entries .map( (e) => `
    1. ` + `

      ${escapeHtml(e.title)}

      ${md(e.body, 4)}
    2. `, ) .join("\n")}
    `, ); } // Tier 2, what the check found: every ruled claim by verdict, one line each. if (groups.length > 0) { body.push( `
    ${tierMarker(2)}

    What the check found

    ` + groups .map( (g) => `

    ${verdictChip(view, g.verdict, g.claims.length)}

      ` + g.claims .map( (c) => `
    • ${escapeHtml(c.title ?? c.text)}` + `${c.gist ? ` — ${escapeHtml(c.gist)}` : ""}${c.flag ? ` ${flagPill(c.flag)}` : ""}
    • `, ) .join("") + `
    `, ) .join("\n") + `
    `, ); } // Tier 3, every claim with its evidence, opening with how it was checked. const full: string[] = [tierMarker(3), `

    Every claim, with its evidence

    `]; if (view.method) full.push(`

    How it was checked

    ${md(view.method)}
    `); if (view.sections.length > 1) { full.push( ``, ); } for (const s of view.sections) { full.push( `

    ${escapeHtml(s.title)}

    ` + `${s.body ? md(s.body) : ""}${s.claims.map((c) => claimHtml(c, ctx)).join("\n")}
    `, ); } body.push(`
    ${full.join("\n")}
    `); // Every document the report quotes, the subject first, each once with its // archive links in context. const sources = Object.values(view.sources); if (sources.length > 0) { body.push( `

    Sources

    ${sources .map((s) => { const sb = sourceByline(s); return ( `

    ${sourceTitleHtml(s)}` + `${s.id === view.subject ? ` (under review)` : ""}

    ` + `${s.url ? `

    ${escapeHtml(s.url)}

    ` : ""}` + `${sb ? `

    ${sb}

    ` : ""}` + `${s.note ? `

    ${escapeHtml(s.note)}

    ` : ""}` + `${s.archives.length > 0 ? `

    ${plural(s.archives.length, "archive link")} in context

    ${archiveList(s.archives)}` : ""}
    ` ); }) .join("\n")}
    `, ); } const refs = orderedCitations(view); if (refs.length > 0) { body.push( `

    References

      ${refs .map((c) => reference(c, opts, `Not in the ${subjectNoun}`)) .join("\n")}
    `, ); } body.push( `

    ${escapeHtml(reportExportFooterLine(opts.footer))}

    ` + `${pageUrl ? `

    Published at ${link(pageUrl)}

    ` : ""}
    `, ); return ( `\n\n\n\n` + `\n` + `${escapeHtml(reportFullTitle(view))}\n` + `${view.subtitle ? `\n` : ""}` + `\n` + `\n\n\n
    \n` + `${body.join("\n")}\n
    \n\n\n` ); }