// ONE ARTICLE, TWO SHAPES — a report's page view (./views.ts) as slides. // // `buildReportSlides(view)` is the one way a report becomes slides: the export // site's Slides and Overview views, umtool's, `slides.html` and `slides.pdf` // all render what it returns. In order: // // title the series, title, the title line (report.json `slides.title`, // else the subtitle), the dates, the document under review // summary "In brief": `slides.points`, else the summary's first paragraph // (and a fact-check's tally) // entry one per timeline entry, newest first: its date, its title and // the first two sentences of its body // found "What the check found": the claims by verdict — a fact-check only // section one per section: its `slide.points`, else the first two sentences // of its body, else its claims // claim one per claim: its title (else its text), its verdict, its gist // (or `slide.points`) and the evidence of its `slide.cite`, else of // its first listed citation; a claim with none is a statement // sources how many references and documents, `slides.closing` // // A `hide` drops a slide (a section's claims keep theirs); `slides.hide` drops // them all. Long markdown never lands on a slide: a slide shows points, a gist, // one quote. // // EVERY SLIDE HAS AN ANCHOR: the place in the article it stands for, which is // also an element id on the report page (REPORT_PAGE_ANCHOR_IDS, a section's or // a claim's own id). It is the one mapping the view switch (keep the place), // the overview (pair a slide with its block) and umtool's notes share. The // slides are in the article's reading order, so the slide for a place in the // article is the last one whose anchor is at or above it. // // Pure, no imports but types and pure helpers: the browser renders from it. import type { Verdict } from "./verdicts"; import { REPORT_PAGE_ANCHOR_IDS, SLIDE_DERIVED_TEXT_MAX, SLIDE_LINE_MAX, SLIDE_POINT_MAX, SLIDE_POINTS_MAX, type SlideLayoutName, } from "./slideRules"; import { entryDateLabel, foundGroups, orderedCitations, reportDateParts, subjectMetaParts, verdictTally, type ClaimView, type EntryView, type ReportPageView, type SectionView, type VerdictCount, } from "./views"; export type SlideAnchorKind = "head" | "summary" | "entry" | "found" | "section" | "claim" | "sources"; // A place in the article: its kind, and the entry's, section's or claim's id. export type SlideAnchor = { kind: SlideAnchorKind; id?: string }; type SlideBase = { // The slide's place in the deck, from 1: its hash is `#s-`. n: number; // Unique in the deck: `title`, `summary`, `entry:`, `found`, // `section:`, `claim:`, `sources`. key: string; anchor: SlideAnchor; // The anchor's element id on the report page (`#`). anchorId: string; title: string; }; export type TitleSlide = SlideBase & { kind: "title"; layout: "title"; series?: string; // The line under the title: `slides.title`, else the subtitle. line?: string; dates: string[]; // The document under review: its title, "author · publisher · date", colour. subject?: { title: string; meta: string[]; accent?: string }; }; export type SummarySlide = SlideBase & { kind: "summary"; layout: "statement" | "points"; // The summary's first paragraph (markdown, citing inline). text?: string; points?: string[]; // A fact-check's tally. tally?: VerdictCount[]; }; export type EntrySlide = SlideBase & { kind: "entry"; layout: "statement"; // Its place in the timeline, newest first ("1 of 3"). index: number; count: number; // The entry's day (entryDateLabel), and as written. date: string; datetime: string; // The body's first two sentences (markdown, citing inline). text?: string; }; export type FoundSlide = SlideBase & { kind: "found"; layout: "found"; claimCount: number; groups: { verdict: Verdict; claims: { id: string; title: string }[] }[]; }; export type SlideClaimRef = { id: string; title: string; verdict?: Verdict }; export type SectionSlide = SlideBase & { kind: "section"; layout: SlideLayoutName; // Its place among the sections ("2 of 5"). index: number; count: number; points?: string[]; // The body's first two sentences (markdown, citing inline), with no points. text?: string; claims: SlideClaimRef[]; // The citation the slide shows (`slide.cite`). cite?: string; }; export type ClaimSlide = SlideBase & { kind: "claim"; layout: SlideLayoutName; section: { id: string; title: string }; // The claim as the document states it, when the title is not already it. text?: string; verdict?: Verdict; gist?: string; flag?: string; points?: string[]; // The citation the slide shows: `slide.cite`, else the first listed. cite?: string; // The document's own sentence (a `source` citation). sourceQuote?: string; }; export type SourcesSlide = SlideBase & { kind: "sources"; layout: "sources"; references: number; documents: number; closing?: string; }; export type SlideView = TitleSlide | SummarySlide | EntrySlide | FoundSlide | SectionSlide | ClaimSlide | SourcesSlide; // A slide before the deck numbers it. type Unnumbered = SlideView extends infer S ? (S extends SlideView ? Omit : never) : never; // ─── Text helpers ─── const CITE_LINK_RE = /\[((?:[^[\]]|\[[^[\]]*\])*)\]\(\s*cite:([^)\s]*)\s*\)/g; // A point's text as a reader sees it: each inline citation as its label. export function slidePointText(point: string): string { return point.replace(CITE_LINK_RE, (_m, label: string) => label).trim(); } // The markdown's first paragraph: up to the first blank line, a heading's // marks, a list's or a quote's markers dropped, its lines joined. export function firstParagraph(md: string | undefined): string | undefined { if (!md) return undefined; for (const para of md.split(/\n\s*\n/)) { const lines = para .split("\n") .map((l) => l.replace(/^\s{0,3}(?:#{1,6}\s+|>\s?|[-*+]\s+|\d+[.)]\s+)/, "").trim()) .filter((l) => l && !/^(?:```|~~~|---+|\*\*\*+)$/.test(l)); const text = lines.join(" ").trim(); if (text) return text; } return undefined; } // The first `n` sentences of the markdown's first paragraph. A sentence ends // at `.`, `!`, `?` or `…` (and a closing quote or bracket) before a space; a // citation's label is never split, whatever punctuation it holds. export function firstSentences(md: string | undefined, n: number): string | undefined { const para = firstParagraph(md); if (!para) return undefined; // Blank each citation link so its label's punctuation ends nothing. const masked = para.replace(CITE_LINK_RE, (m) => "x".repeat(m.length)); const ends: number[] = []; const re = /[.!?…]+["”’)\]]*(?=\s+\S)/g; for (let m = re.exec(masked); m; m = re.exec(masked)) { ends.push(m.index + m[0].length); if (ends.length === n) break; } return (ends.length === n ? para.slice(0, ends[n - 1]) : para).trim(); } // ─── The anchors ─── export function slideAnchorId(anchor: SlideAnchor): string { switch (anchor.kind) { case "head": return REPORT_PAGE_ANCHOR_IDS.head; case "summary": return REPORT_PAGE_ANCHOR_IDS.summary; case "found": return REPORT_PAGE_ANCHOR_IDS.found; case "sources": return REPORT_PAGE_ANCHOR_IDS.end; case "entry": case "section": case "claim": return anchor.id ?? ""; } } // Every element id the report page gives a place (export ReportArticle): its // own parts, each timeline entry, each section and each claim — those a page // of this view carries, in document order. export function reportPageAnchorIds(view: ReportPageView): string[] { const ids: string[] = [REPORT_PAGE_ANCHOR_IDS.head, REPORT_PAGE_ANCHOR_IDS.summary]; for (const e of view.entries ?? []) ids.push(e.id); if (view.kind === "factcheck" && foundGroups(view).length > 0) ids.push(REPORT_PAGE_ANCHOR_IDS.found); ids.push(REPORT_PAGE_ANCHOR_IDS.claims); for (const s of view.sections) { ids.push(s.id); for (const c of s.claims) ids.push(c.id); } ids.push(REPORT_PAGE_ANCHOR_IDS.end); if (orderedCitations(view).length > 0) ids.push(REPORT_PAGE_ANCHOR_IDS.references); if (view.downloads && Object.keys(view.downloads).length > 0) ids.push(REPORT_PAGE_ANCHOR_IDS.downloads); return ids; } // ─── The builder ─── // Whether the report has slides at all (`slides.hide` says no). export function hasSlides(view: Pick): boolean { return view.slides?.hide !== true; } const claimTitle = (c: ClaimView) => c.title ?? c.text; // The citation a claim's slide shows: its `slide.cite`, else the first it // lists that is not its own sentence. function claimCite(c: ClaimView): string | undefined { return c.slide?.cite ?? c.citations.find((id) => id !== c.sourceQuote); } function entrySlide(e: EntryView, index: number, count: number): Omit { const text = firstSentences(e.body, 2); return { kind: "entry", key: `entry:${e.id}`, anchor: { kind: "entry", id: e.id }, anchorId: e.id, title: e.title, layout: "statement", index, count, date: entryDateLabel(e.date), datetime: e.date, ...(text ? { text } : {}), }; } function sectionSlide(s: SectionView, index: number, count: number): Omit { const spec = s.slide; const points = spec?.points && spec.points.length > 0 ? spec.points : undefined; const cite = spec?.cite; let layout: SlideLayoutName = spec?.layout ?? "points"; if ((layout === "evidence" || layout === "quote") && !cite) layout = "points"; return { kind: "section", key: `section:${s.id}`, anchor: { kind: "section", id: s.id }, anchorId: s.id, title: spec?.title ?? s.title, layout, index, count, ...(points ? { points } : {}), ...(!points && s.body ? { text: firstSentences(s.body, 2) } : {}), claims: s.claims.map((c) => ({ id: c.id, title: claimTitle(c), ...(c.verdict ? { verdict: c.verdict } : {}) })), ...(cite ? { cite } : {}), }; } function claimSlide(c: ClaimView, s: SectionView): Omit { const spec = c.slide; const points = spec?.points && spec.points.length > 0 ? spec.points : undefined; const cite = claimCite(c); let layout: SlideLayoutName = spec?.layout ?? (cite ? "evidence" : "statement"); if ((layout === "evidence" || layout === "quote") && !cite) layout = "statement"; const title = spec?.title ?? claimTitle(c); return { kind: "claim", key: `claim:${c.id}`, anchor: { kind: "claim", id: c.id }, anchorId: c.id, title, layout, section: { id: s.id, title: s.title }, ...(title !== c.text ? { text: c.text } : {}), ...(c.verdict ? { verdict: c.verdict } : {}), ...(c.gist ? { gist: c.gist } : {}), ...(c.flag ? { flag: c.flag } : {}), ...(points ? { points } : {}), ...(cite ? { cite } : {}), ...(c.sourceQuote ? { sourceQuote: c.sourceQuote } : {}), }; } export function buildReportSlides(view: ReportPageView): SlideView[] { if (!hasSlides(view)) return []; const deck = view.slides; const out: Unnumbered[] = []; const subject = view.subject ? view.sources[view.subject] : undefined; const line = deck?.title ?? view.subtitle; out.push({ kind: "title", key: "title", anchor: { kind: "head" }, anchorId: REPORT_PAGE_ANCHOR_IDS.head, title: view.title, layout: "title", ...(view.series ? { series: view.series } : {}), ...(line ? { line } : {}), dates: reportDateParts(view), ...(subject ? { subject: { title: subject.title, meta: subjectMetaParts(subject), ...(subject.accent ? { accent: subject.accent } : {}) } } : {}), } satisfies Omit); const isFactcheck = view.kind === "factcheck"; const tally = isFactcheck ? verdictTally(view) : []; const points = deck?.points && deck.points.length > 0 ? deck.points : undefined; const text = points ? undefined : firstParagraph(view.summary); if (points || text || tally.length > 0) { out.push({ kind: "summary", key: "summary", anchor: { kind: "summary" }, anchorId: REPORT_PAGE_ANCHOR_IDS.summary, title: "In brief", layout: points ? "points" : "statement", ...(points ? { points } : {}), ...(text ? { text } : {}), ...(tally.length > 0 ? { tally } : {}), } satisfies Omit); } const entries = view.entries ?? []; entries.forEach((e, i) => out.push(entrySlide(e, i + 1, entries.length))); const groups = isFactcheck ? foundGroups(view) : []; if (groups.length > 0) { out.push({ kind: "found", key: "found", anchor: { kind: "found" }, anchorId: REPORT_PAGE_ANCHOR_IDS.found, title: "What the check found", layout: "found", claimCount: groups.reduce((n, g) => n + g.claims.length, 0), groups: groups.map((g) => ({ verdict: g.verdict, claims: g.claims.map((c) => ({ id: c.id, title: claimTitle(c) })) })), } satisfies Omit); } view.sections.forEach((s, i) => { if (s.slide?.hide !== true) out.push(sectionSlide(s, i + 1, view.sections.length)); for (const c of s.claims) if (c.slide?.hide !== true) out.push(claimSlide(c, s)); }); out.push({ kind: "sources", key: "sources", anchor: { kind: "sources" }, anchorId: REPORT_PAGE_ANCHOR_IDS.end, title: "Sources", layout: "sources", references: orderedCitations(view).length, documents: Object.keys(view.sources).length, ...(deck?.closing ? { closing: deck.closing } : {}), } satisfies Omit); return out.map((s, i) => ({ ...s, n: i + 1 }) as SlideView); } // The slide that stands for a place in the article: the one anchored there, // else (a hidden slide's place) the last one anchored before it in the // article's order `order` (reportPageAnchorIds); the first slide when none is. export function slideForAnchor(slides: readonly SlideView[], anchorId: string | null | undefined, order?: readonly string[]): SlideView | undefined { if (slides.length === 0) return undefined; if (!anchorId) return slides[0]; const own = slides.find((s) => s.anchorId === anchorId); if (own) return own; if (!order) return slides[0]; const at = order.indexOf(anchorId); if (at < 0) return slides[0]; const before = new Set(order.slice(0, at)); let best: SlideView | undefined; for (const s of slides) if (before.has(s.anchorId)) best = s; return best ?? slides[0]; } // ─── Lint (umtool's source tab) ─── export type SlideLintItem = { key: string; anchorId: string; message: string }; // What would make a slide read badly, before it is published: derived text // that runs long (give the slide its own points), a title that is a long // claim, too many or too long points. A draft read leniently can carry what // the validator refuses; this says it in a slide's terms. export function slideLint(view: ReportPageView): SlideLintItem[] { const out: SlideLintItem[] = []; const pointsLint = (key: string, anchorId: string, points: readonly string[] | undefined) => { if (!points) return; if (points.length > SLIDE_POINTS_MAX) out.push({ key, anchorId, message: `${points.length} points; a slide shows at most ${SLIDE_POINTS_MAX}` }); points.forEach((p, i) => { const n = slidePointText(p).length; if (n > SLIDE_POINT_MAX) out.push({ key, anchorId, message: `point ${i + 1} is ${n} characters; a point is at most ${SLIDE_POINT_MAX}` }); }); }; pointsLint("summary", REPORT_PAGE_ANCHOR_IDS.summary, view.slides?.points); for (const slide of buildReportSlides(view)) { if (slide.kind === "section") { pointsLint(slide.key, slide.anchorId, slide.points); if (!slide.points && slide.text) { const n = slidePointText(slide.text).length; if (n > SLIDE_DERIVED_TEXT_MAX) { out.push({ key: slide.key, anchorId: slide.anchorId, message: `no slide points: its slide shows the body's first two sentences, ${n} characters (over ${SLIDE_DERIVED_TEXT_MAX}); give it \`slide.points\``, }); } } } else if (slide.kind === "claim") { pointsLint(slide.key, slide.anchorId, slide.points); if (slide.title.length > SLIDE_LINE_MAX) { out.push({ key: slide.key, anchorId: slide.anchorId, message: `its slide's title is ${slide.title.length} characters (over ${SLIDE_LINE_MAX}); give the claim a \`title\` or \`slide.title\``, }); } } } return out; } // ─── The reader's place, in the URL ─── // // `?rv=slides` or `?rv=iso` (absent = the article — the default stays off the // URL, as the transcript's `vm`), and the hash: in the slides `#s-`, the // slide; in the article and the overview, an anchor id. A shared link opens // the same view at the same place. export const READER_VIEWS = ["article", "slides", "iso"] as const; export type ReaderView = (typeof READER_VIEWS)[number]; export const READER_VIEW_PARAM = "rv"; export type ReaderPlace = { view: ReaderView; slide: number | null; anchor: string | null }; export function parseReaderPlace(search: string, hash: string): ReaderPlace { const rv = new URLSearchParams(search).get(READER_VIEW_PARAM); const view: ReaderView = rv === "slides" ? "slides" : rv === "iso" ? "iso" : "article"; let h = hash.startsWith("#") ? hash.slice(1) : hash; try { h = decodeURIComponent(h); } catch { // a malformed escape: the hash as written } const m = /^s-(\d+)$/.exec(h); if (view === "slides") return { view, slide: m ? Number(m[1]) : null, anchor: m || !h ? null : h }; return { view, slide: null, anchor: h || null }; } // The slide (0-based) a slides place shows: its `#s-`, clamped, else the // slide for its anchor (slideForAnchor), else the first. export function slideIndexForPlace(slides: readonly SlideView[], place: ReaderPlace, order?: readonly string[]): number { if (slides.length === 0) return 0; if (place.slide !== null) return Math.min(Math.max(place.slide - 1, 0), slides.length - 1); return (slideForAnchor(slides, place.anchor, order)?.n ?? 1) - 1; } // The URL (path, query, hash) of a place, keeping every other query param. export function readerPlaceUrl(pathname: string, search: string, place: { view: ReaderView; slide?: number | null; anchor?: string | null }): string { const params = new URLSearchParams(search); if (place.view === "article") params.delete(READER_VIEW_PARAM); else params.set(READER_VIEW_PARAM, place.view); const qs = params.toString(); const hash = place.view === "slides" && place.slide != null ? `#s-${place.slide}` : place.anchor ? `#${place.anchor}` : ""; return `${pathname}${qs ? `?${qs}` : ""}${hash}`; }