// A REPORT'S SLIDES AS ONE HTML FILE — `slides.html`, and printed one 16:9 // page per slide, `slides.pdf` (publish/reportExports.ts). The deck is // lib/report/slides.ts buildReportSlides of the report's page view — the same // slides the site's Slides view shows — drawn with the same stylesheet // (common/styles/slides.css, passed in as `css`, inlined whole). // // THE MARKUP MIRRORS common/components/report/slides/Slide.tsx, class for // class, as a string: this file is built by publish/reportExports.ts, which // the editor's server bundles, where React's server renderer is not to be // imported. Change one, change the other (exportSlides.test.ts holds the // classes the stylesheet styles). // // ONE FILE, NO NETWORK: its stylesheet and a few lines of script (fit each // slide to the window; ← → PgUp PgDn Home End step) inline; every picture // (a clip's poster, a post's capture, a sentence's still) whatever `image` // answers — a data: URI, or a relative path in the evidence pack. Colours are // the light base's, in the site's accent (`accent`, its light value). Each // citation's number links to the reference list under the deck (on screen; // the print is the slides alone); "Read this in the article" goes to the // article on the site, when it has a public URL. // // PURE: no I/O, deterministic for its input. import { citationAnchor } from "../citations/inline"; import { escapeHtml, markdownToHtml, reportExportFooterLine, siteLink, type ReportExportFooter } from "./exportHtml"; import { buildReportSlides, slidePointText, type ClaimSlide, type EntrySlide, type FoundSlide, type SectionSlide, type SlideView, type SourcesSlide, type SummarySlide, type TitleSlide, } from "./slides"; import { VERDICT_DEFAULTS, type Verdict, type VerdictStyle } from "./verdicts"; import { CITATION_KIND_LABELS, orderedCitations, reportFullTitle, reportPagePath, spanLabel, type CitationView, type ReportPageView, } from "./views"; export type ReportSlidesHtmlOptions = { // common/styles/slides.css, whole. css: string; // The site's accent on the light base (lib/accent.ts resolveAccent().light). accent: string; siteUrl?: string; footer: ReportExportFooter; // A site-root image path → the , or undefined to leave it out. image: (sitePath: string) => string | undefined; }; const style = (vars: Record) => { const s = Object.entries(vars) .filter(([, v]) => v !== undefined) .map(([k, v]) => `${k}:${v}`) .join(";"); return s ? ` style="${escapeHtml(s)}"` : ""; }; const verdictStyleOf = (view: ReportPageView, v: Verdict): VerdictStyle => view.verdicts?.[v] ?? VERDICT_DEFAULTS[v]; // The site-root paths of the pictures the slides show (each slide's cited // evidence): with viewImagePaths', what an export must inline or pack. export function slidesImagePaths(view: ReportPageView): string[] { const out = new Set(); for (const s of buildReportSlides(view)) { // A quote shows its words, never its picture. const id = (s.kind === "claim" || s.kind === "section") && s.layout !== "quote" ? s.cite : undefined; const c = id ? view.citations[id] : undefined; const pic = c ? pictureOf(c) : undefined; if (pic) out.add(pic.src); } return [...out].sort(); } function pictureOf(c: CitationView): { src: string; alt: string } | undefined { if ((c.kind === "video" || c.kind === "audio") && c.poster) return { src: c.poster, alt: "" }; if (c.kind === "post" && c.shot) return { src: c.shot, alt: `Screenshot of the post${c.author ? ` by ${c.author}` : ""}` }; if (c.kind === "source" && c.image) return { src: c.image, alt: `The sentence as ${c.sourceTitle} shows it: ${c.quote}` }; return undefined; } // What CitationCard's heading says: a citation's title and its meta. function heading(c: CitationView): { title: string; meta: string[] } { const host = (url: string) => { try { return new URL(url).hostname.replace(/^www\./, ""); } catch { return url; } }; switch (c.kind) { case "video": case "audio": return { title: c.record.title ?? `${c.record.channel}/${c.record.id}`, meta: [c.record.channelTitle ?? c.record.channel, c.date ?? c.record.date, spanLabel(c.start, c.end)].filter( (x): x is string => !!x, ), }; case "post": return { title: c.author ?? c.record.channelTitle ?? c.record.channel, meta: [c.author ? (c.record.channelTitle ?? c.record.channel) : undefined, c.date ?? c.record.date].filter( (x): x is string => !!x, ), }; case "source": return { title: c.sourceTitle, meta: [c.date].filter((x): x is string => !!x) }; case "page": return { title: c.title ?? host(c.href), meta: [host(c.href), c.date].filter((x): x is string => !!x) }; } } // Where a citation is, off this file: its moment page on the site, the // document, the web page. function citationUrl(c: CitationView, siteUrl: string | undefined): string | undefined { if (c.kind === "video" || c.kind === "audio" || c.kind === "post") return siteLink(siteUrl, c.href) ?? c.record.originalUrl; return c.href; } type Ctx = { view: ReportPageView; opts: ReportSlidesHtmlOptions; total: number }; function md(text: string, x: Ctx): string { const cite = (id: string) => { const c = x.view.citations[id]; return c ? { number: c.number, anchor: citationAnchor(id) } : undefined; }; return `
${markdownToHtml(text, cite, { siteUrl: x.opts.siteUrl })}
`; } function citeLabel(c: CitationView, title: string): string { const n = c.number !== undefined ? `[${c.number}]` : ""; return `${escapeHtml(title)}${n}`; } function points(ps: readonly string[], x: Ctx): string { return ``; } function chip(view: ReportPageView, v: Verdict, count?: number): string { const s = verdictStyleOf(view, v); return ( `${escapeHtml(s.label)}` + `${count !== undefined ? `${count}` : ""}` ); } function evidence(c: CitationView, x: Ctx): string { const pic = pictureOf(c); const src = pic ? x.opts.image(pic.src) : undefined; const { title, meta } = heading(c); const rail = c.kind === "source" ? c.sourceAccent : undefined; return ( `
` + (src ? `${escapeHtml(pic!.alt)}` : "") + (!(c.kind === "source" && src) ? `
${escapeHtml(c.quote)}
` : "") + `
${escapeHtml( [CITATION_KIND_LABELS[c.kind], ...meta].join(" · "), )}${citeLabel(c, title)}
` ); } function pull(c: CitationView): string { const { title, meta } = heading(c); return ( `
${escapeHtml(c.quote)}
` + `
${escapeHtml( [CITATION_KIND_LABELS[c.kind], ...meta].join(" · "), )}${citeLabel(c, title)}
` ); } function titleBody(s: TitleSlide): string { return ( (s.series ? `

${escapeHtml(s.series)}

` : "") + `

${escapeHtml(s.title)}

` + (s.line ? `

${escapeHtml(s.line)}

` : "") + (s.dates.length ? `

${escapeHtml(s.dates.join(" · "))}

` : "") + (s.subject ? `
${escapeHtml(s.subject.title)}` + `${s.subject.meta.length ? `${escapeHtml(s.subject.meta.join(" · "))}` : ""}
` : "") ); } function summaryBody(s: SummarySlide, x: Ctx): string { return ( `

${escapeHtml(s.title)}

` + (s.points ? points(s.points, x) : s.text ? `
${md(s.text, x)}
` : "") + (s.tally ? `
    ${s.tally.map((t) => `
  • ${chip(x.view, t.verdict, t.count)}
  • `).join("")}
` : "") ); } function entryBody(s: EntrySlide, x: Ctx): string { return ( `

` + `

${escapeHtml(s.title)}

` + (s.text ? `
${md(s.text, x)}
` : "") ); } function foundBody(s: FoundSlide, x: Ctx): string { return ( `

${escapeHtml(s.title)}

` + `
` + s.groups .map((g) => { const v = verdictStyleOf(x.view, g.verdict); return ( `
` + `${g.claims.length}${escapeHtml(v.label)}` + `
    ${g.claims.map((c) => `
  • ${escapeHtml(c.title)}
  • `).join("")}
` ); }) .join("") + `
` ); } function sectionBody(s: SectionSlide, x: Ctx): string { const cited = s.cite ? x.view.citations[s.cite] : undefined; const words = s.points ? points(s.points, x) : s.text ? `
${md(s.text, x)}
` : s.claims.length ? `` : ""; if (s.layout === "quote" && cited) return `

${escapeHtml(s.title)}

${pull(cited)}`; if (cited) { return `

${escapeHtml(s.title)}

${words}
${evidence(cited, x)}
`; } return `

${escapeHtml(s.title)}

${words}`; } function claimHead(s: ClaimSlide, x: Ctx, small: boolean): string { const chips = s.verdict || s.flag ? `
${s.verdict ? chip(x.view, s.verdict) : ""}${s.flag ? `${escapeHtml(s.flag)}` : ""}
` : ""; return `${chips}

${escapeHtml(s.title)}

`; } function claimBody(s: ClaimSlide, x: Ctx): string { const cited = s.cite ? x.view.citations[s.cite] : undefined; const finding = s.points ? points(s.points, x) : s.gist ? `

${escapeHtml(s.gist)}

` : ""; const text = s.text ? `

${escapeHtml(s.text)}

` : ""; if (s.layout === "quote" && cited) return `${claimHead(s, x, true)}${pull(cited)}`; if (s.layout === "evidence" && cited) { return `
${claimHead(s, x, true)}${text}${finding}
${evidence(cited, x)}
`; } return `${claimHead(s, x, false)}${text}${finding}`; } function sourcesBody(s: SourcesSlide, x: Ctx): string { const pageUrl = siteLink(x.opts.siteUrl, reportPagePath(x.view.id)); return ( `

${escapeHtml(s.title)}

` + `
${s.references}${s.references === 1 ? "reference" : "references"}
` + (s.documents > 0 ? `
${s.documents}${s.documents === 1 ? "document quoted" : "documents quoted"}
` : "") + `
` + (s.closing ? `

${escapeHtml(s.closing)}

` : "") + (pageUrl ? `

${escapeHtml(pageUrl)}

` : "") + `

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

` ); } function eyebrow(s: SlideView, total: number): string { let kind = ""; let place: string | undefined; switch (s.kind) { case "title": kind = "Article"; break; case "summary": kind = "In brief"; break; case "entry": kind = "Timeline"; place = `${s.index} of ${s.count}`; break; case "found": kind = "Findings"; place = `${s.claimCount} claim${s.claimCount === 1 ? "" : "s"} ruled`; break; case "section": kind = `Section ${s.index} of ${s.count}`; break; case "claim": kind = "Claim"; place = s.section.title; break; case "sources": kind = "Sources"; break; } const pad = (n: number) => String(n).padStart(2, "0"); return ( `

${escapeHtml(kind)}${place ? ` · ${escapeHtml(place)}` : ""}` + `

` ); } function slideHtml(s: SlideView, x: Ctx): string { let body: string; switch (s.kind) { case "title": body = titleBody(s); break; case "summary": body = summaryBody(s, x); break; case "entry": body = entryBody(s, x); break; case "found": body = foundBody(s, x); break; case "section": body = sectionBody(s, x); break; case "claim": body = claimBody(s, x); break; case "sources": body = sourcesBody(s, x); break; } const mark = s.kind === "claim" && s.verdict ? verdictStyleOf(x.view, s.verdict).color : undefined; const article = siteLink(x.opts.siteUrl, `${reportPagePath(x.view.id)}#${s.anchorId}`); return ( `` ); } function referencesHtml(x: Ctx): string { const refs = orderedCitations(x.view); if (refs.length === 0) return ""; return ( `

References

    ` + refs .map((c) => { const { title, meta } = heading(c); const url = citationUrl(c, x.opts.siteUrl); const t = url ? `${escapeHtml(title)}` : escapeHtml(title); return ( `
  1. ${escapeHtml( [CITATION_KIND_LABELS[c.kind], ...meta].join(" · "), )} ${t}${escapeHtml(c.quote)}
  2. ` ); }) .join("") + `
` ); } // The deck around the slides: one screen per slide, snapped; in print, one // 1280 × 720 page per slide and nothing else. export const SLIDES_EXPORT_CSS = ` html{scroll-snap-type:y mandatory;background:var(--rs-ground);-webkit-text-size-adjust:100%} body{margin:0;background:var(--rs-ground);color:var(--rs-ink);font-family:var(--rs-sans)} .rs-page{height:100vh;height:100dvh;display:flex;align-items:center;justify-content:center;scroll-snap-align:start;overflow:hidden} .rs-page-box{position:relative;flex:none;width:calc(1280px * var(--k, 0.5));height:calc(720px * var(--k, 0.5));border-radius:12px;box-shadow:var(--rs-shadow)} .rs-page-box>.rs-slide{position:absolute;left:0;top:0;transform:scale(var(--k, 0.5));transform-origin:0 0} .rs-n{font-family:var(--rs-mono);font-size:.7em;margin-left:2px} .rs-n a,.rs-md a{color:var(--rs-accent)} .rs-md sup.cite{font-family:var(--rs-mono);font-size:.62em} .rs-url a{color:inherit} .rs-refs{max-width:46rem;margin:0 auto;padding:3rem 1rem 4rem;scroll-snap-align:start;font-size:15px;line-height:1.5} .rs-refs h2{font-family:var(--rs-display);font-size:1.5rem;margin:0 0 1rem} .rs-refs ol{margin:0;padding-left:2.2rem;display:flex;flex-direction:column;gap:.8rem} .rs-refs li .rs-ev-kind{display:block} .rs-refs q{display:block;color:var(--rs-muted);font-style:italic} .rs-refs a{color:var(--rs-accent)} @page{size:1280px 720px;margin:0} @media print{ html,body{background:var(--rs-paper);scroll-snap-type:none} .rs-page{width:1280px;height:720px;break-after:page;page-break-after:always} .rs-page:last-of-type{break-after:auto;page-break-after:auto} .rs-page-box{width:1280px;height:720px;box-shadow:none;border-radius:0} .rs-page-box>.rs-slide{transform:none;border-radius:0} .rs-refs{display:none} } `.trim(); // Fit each slide to the window; step with the keys. const SLIDES_SCRIPT = `(function(){var d=document,r=d.documentElement;function fit(){r.style.setProperty("--k",String(Math.min(innerWidth/1280,innerHeight/720)*0.96))}fit();addEventListener("resize",fit);addEventListener("keydown",function(e){if(e.altKey||e.ctrlKey||e.metaKey)return;var p=[].slice.call(d.querySelectorAll(".rs-page"));var y=scrollY+innerHeight/2,i=0;for(var j=0;j `
` + `
${slideHtml(s, x)}
`, ) .join("\n"); const root = `--brand:${opts.accent};--brand-soft:color-mix(in oklab, ${opts.accent} 12%, transparent);color-scheme:light`; return ( `\n\n\n\n` + `\n` + `${escapeHtml(`${reportFullTitle(view)} — slides`)}\n` + `\n` + `\n\n\n
\n` + `${pages}\n${referencesHtml(x)}\n
\n\n\n\n` ); }