// A REPORT AS PLAIN MARKDOWN — `report.md`, the export that reads anywhere // text does (publish/reportExports.ts writes it). Built from the report's page // view (./views.ts), like the HTML export (./exportHtml.ts), with the same // footer. // // The report's own markdown (summary, timeline entries, section bodies, // findings) passes through as written, each inline citation // `[label](cite:)` becoming `label [n]` — the number of its entry in the // numbered reference list at the end. Plain // text fields (titles, quotes) are escaped so a `*` or `[` in a quote stays a // character. No images: a still or a screenshot is in the HTML export and the // evidence pack; here the quote is the text. // // PURE: no I/O, deterministic for its input. import { CITE_SCHEME } from "../citations/inline"; import type { SourceArchive } from "../citations/schema"; import { dateLabel, reportExportFooterLine, siteLink, type ReportExportOptions } from "./exportHtml"; import { CITATION_KIND_LABELS, entryDateLabel, foundGroups, orderedCitations, reportDateParts, reportPagePath, reportRevisionLabel, spanLabel, subjectMetaParts, verdictTally, type CitationView, type ClaimView, type ReportPageView, } from "./views"; // Plain text, safe in a Markdown paragraph: the characters Markdown reads as // syntax are escaped, and a line can never open a heading, a list or a quote. export function mdText(s: string): string { return s .replace(/[\\`*_[\]<>|]/g, (c) => `\\${c}`) .replace(/\s*\n\s*/g, " ") .replace(/^([#>+-]|\d+[.)])/, "\\$1"); } // A URL as a Markdown link target: spaces and parentheses escaped. function mdUrl(u: string): string { return u.replace(/ /g, "%20").replace(/\(/g, "%28").replace(/\)/g, "%29"); } const mdLink = (text: string, url: string) => `[${mdText(text)}](${mdUrl(url)})`; // The report's markdown with each inline citation as `label [n]`. A site-root // link goes to the site when its URL is known, else stays its label. export function citedMarkdownToPlain(md: string, view: Pick, siteUrl?: string): string { return md.replace(/\[([^\]]*)\]\(\s*([^)\s]+)(\s+"[^"]*")?\s*\)/g, (m, label: string, href: string, title?: string) => { if (href.startsWith(CITE_SCHEME)) { const c = view.citations[href.slice(CITE_SCHEME.length).trim()]; return `${label} [${c?.number ?? "?"}]`; } if (href.startsWith("/")) { const abs = siteLink(siteUrl, href); return abs ? `[${label}](${mdUrl(abs)}${title ?? ""})` : label; } return m; }); } function archiveLines(archives: readonly SourceArchive[]): string[] { return archives.map((a) => `- ${mdLink(a.label, a.url)} <${a.url}>${a.context ? ` — ${mdText(a.context)}` : ""}`); } function citationWhere(c: CitationView): string { switch (c.kind) { case "video": case "audio": return [c.record.title ? `*${mdText(c.record.title)}*` : null, c.record.channelTitle ? mdText(c.record.channelTitle) : null, spanLabel(c.start, c.end)] .filter(Boolean) .join(", "); case "post": return [c.author ? mdText(c.author) : null, c.record.channelTitle ? mdText(c.record.channelTitle) : null, "post"].filter(Boolean).join(", "); case "source": return `from *${mdText(c.sourceTitle)}*`; case "page": return c.title ? `*${mdText(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 referenceLines(c: CitationView, siteUrl: string | undefined): string[] { const meta = [ CITATION_KIND_LABELS[c.kind], c.speaker ? mdText(c.speaker) : null, citationDate(c) ?? null, citationWhere(c), c.verification?.quoteScore !== undefined ? `quote match ${Math.round(c.verification.quoteScore * 100)}%` : null, ].filter(Boolean); const out = [`${c.number ?? "-"}. “${mdText(c.quote)}”`]; 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) { case "video": case "audio": { if (c.record.originalUrl) link(`Original at ${spanLabel(c.start, c.end).split("–")[0]}`, c.record.originalUrl); const moment = siteLink(siteUrl, c.href); if (moment) link("Moment page", moment); break; } case "post": { if (c.record.originalUrl) link("Original", c.record.originalUrl); const moment = siteLink(siteUrl, c.href); if (moment) link("Moment page", moment); break; } case "source": if (c.href) link("Original", c.href); break; case "page": link("Original", c.href); if (c.archiveUrl) link("Archived", c.archiveUrl); break; } // One list item: its lines indented under the number, each ending in a // hard break but the last. return out.map((l, i, a) => (i < a.length - 1 ? `${l} ` : l)); } function evidenceLine(c: CitationView): string { return `- [${c.number ?? "?"}] ${CITATION_KIND_LABELS[c.kind]}, ${citationWhere(c)}: “${mdText(c.quote)}”`; } function claimLines( claim: ClaimView, view: ReportPageView, subjectNoun: string, siteUrl: string | undefined, ): string[] { const out: string[] = [`### ${mdText(claim.title ?? claim.text)}`, ""]; 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(" "), ""); // The document's own sentence 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) out.push(mdText(claim.text), ""); } else { 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(), ""); 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, 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)), ""); if (given.length > 0) out.push(`In the ${subjectNoun}:`, "", ...given.map((c) => evidenceLine(c)), ""); return out; } export function reportExportMarkdown(view: ReportPageView, opts: ReportExportOptions): string { const out: string[] = []; const subject = view.subject ? view.sources[view.subject] : undefined; const subjectNoun = subject?.kind === "article" ? "article" : "source"; const pageUrl = siteLink(opts.siteUrl, reportPagePath(view.id)); const isFactcheck = view.kind === "factcheck"; // The series on its own line and the title as the heading (or the title // alone), one line of dates and this export's revision, the document under // review as a cited line, the subtitle. if (view.series) out.push(`**${mdText(view.series)}**`, ""); out.push(`# ${mdText(view.title)}`, ""); const historyUrl = view.history ? siteLink(opts.siteUrl, view.history.href) : undefined; const revision = opts.footer.revision !== undefined ? historyUrl ? mdLink(reportRevisionLabel(opts.footer.revision), historyUrl) : mdText(reportRevisionLabel(opts.footer.revision)) : undefined; const dateLine = [...reportDateParts(view).map(mdText), ...(revision ? [revision] : [])]; if (dateLine.length > 0) out.push(dateLine.join(" · "), ""); if (subject) { const meta = subjectMetaParts(subject).map(mdText); out.push(`${subject.url ? mdLink(subject.title, subject.url) : mdText(subject.title)}${meta.length ? ` — ${meta.join(" · ")}` : ""}`, ""); } if (view.subtitle) out.push(`*${mdText(view.subtitle)}*`, ""); // Tier 1: the quick take, after a rule (each tier opens with one). out.push("---", ""); const tally = isFactcheck ? verdictTally(view) : []; if (tally.length > 0) { const claims = view.sections.reduce((n, s) => n + s.claims.length, 0); out.push( `${claims} claim${claims === 1 ? "" : "s"} checked: ${tally.map((t) => `${mdText(view.verdicts[t.verdict].label)} ${t.count}`).join(" · ")}`, "", ); } if (view.summary) out.push(citedMarkdownToPlain(view.summary, view, opts.siteUrl).trim(), ""); // The timeline: its dated entries, newest first. const entries = view.entries ?? []; if (entries.length > 0) { out.push("## Timeline", ""); for (const e of entries) { out.push(`### ${entryDateLabel(e.date)} — ${mdText(e.title)}`, ""); if (e.updated) out.push(`*updated ${entryDateLabel(e.updated)}*`, ""); out.push(citedMarkdownToPlain(e.body, 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("---", "", "## 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("---", "", "## 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, subjectNoun, opts.siteUrl)); } const sources = Object.values(view.sources); if (sources.length > 0) { out.push("## Sources", ""); for (const s of sources) { out.push(`### ${mdText(s.title)}${s.id === view.subject ? " (under review)" : ""}`, ""); if (s.url) out.push(`<${s.url}>`, ""); const byline = [s.publisher, s.author, dateLabel(s.date)].filter(Boolean).map((x) => mdText(x!)); if (byline.length > 0) out.push(byline.join(" · "), ""); if (s.note) out.push(mdText(s.note), ""); if (s.archives.length > 0) out.push(`${s.archives.length} archive link${s.archives.length === 1 ? "" : "s"} in context:`, "", ...archiveLines(s.archives), ""); } } const refs = orderedCitations(view); if (refs.length > 0) { out.push("## References", ""); for (const c of refs) out.push(...referenceLines(c, opts.siteUrl), ""); } out.push("---", "", reportExportFooterLine(opts.footer)); if (pageUrl) out.push("", `Published at <${pageUrl}>`); return `${out.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd()}\n`; }