commit ce0dc5e1989551fb882224bf931f6283c520bb65
parent 330ffef1294d490daa25ecc4d9a02b6550f6c2de
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 9 Oct 2026 14:47:37 -0400
common, export: slides.html and slides.pdf join a report's exports (release 22 E5)
`slides` (slides.html) and `slides-pdf` (slides.pdf) join
REPORT_EXPORT_FORMATS, so the downloads line, compose's publishing, the
cited audit's allowlist, the revision commit's exports.json and the CLI's
and the ops job's `formats` take them with no change of their own.
slides.html is one self-contained file like report.html
(lib/report/exportSlidesHtml.ts): the site's slide stylesheet inline, the
light base in the site's accent, every picture a data: URI, a few lines of
script to fit and step, citations numbered to a reference list under the
deck, each slide's way back to its place on the site, and the sources
slide naming which document this is. Its markup mirrors Slide.tsx class for
class (a test holds them equal): the export is built in the editor's
server bundle, where React's server renderer is not imported. slides.pdf
prints it one 1280 × 720 page per slide (the printer takes a page size),
skipped with a note where there is no browser, as report.pdf is. The
evidence pack carries slides.html with its media; a clip's poster is
inlined or packed when this host has it, never a problem when it has not.
A report whose `slides.hide` says so exports neither.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
16 files changed, 737 insertions(+), 31 deletions(-)
diff --git a/COMMANDS.md b/COMMANDS.md
@@ -30,7 +30,7 @@ Run from the repo root (in the container: `docker compose exec editor pnpm archi
| `archilyzer build hub` | | compose:hub + INSTANCE\_MODE=hub next build into export/out — a raw build, unstamped; to deploy the hub build it with `publish hub` |
| `archilyzer build homepage` | `[--no-source]` | compose + source publish + next build in homepage/ (reads the index as it stands); --no-source removes the published source instead |
| `archilyzer reports prepare` | `<id>` | cut every clip and copy every post capture the site's published reports cite into its report-media cache, before its build, then export the reports as files (reports export) when nothing is missing (exit 1 when a citation lacks media or an export fails; default id: SITE\_ID) |
-| `archilyzer reports export` | `<id> [--report <reportId>] [--formats html,pdf,md,zip] [--allow-missing-media]` | write the site's published reports as files (report.html, report.pdf, report.md, evidence-pack.zip) into its report-exports staging, where compose publishes them from (exit 1 on a problem; a PDF skipped for want of a browser is a note; default id: SITE\_ID) |
+| `archilyzer reports export` | `<id> [--report <reportId>] [--formats html,pdf,md,slides,slides-pdf,zip] [--allow-missing-media]` | write the site's published reports as files (report.html, report.pdf, report.md, slides.html, slides.pdf, evidence-pack.zip) into its report-exports staging, where compose publishes them from (exit 1 on a problem; a PDF skipped for want of a browser is a note; default id: SITE\_ID) |
| `archilyzer reports check` | `<id> [--reports <a,b>] [--allow-missing-media]` | what compose would say about the site's reports, with no build: each report validated, every cited quote checked against its record, every cited moment's prepared media present and current, the report video under the publish limit (exit 1 with the list; --reports checks those, drafts included; default id: SITE\_ID) |
| `archilyzer reports verify-quotes` | `<report.json> [--json]` | every video, audio and post quote of one report against its record with compose's own check: the best score and track, and the en-orig track's score where the record has one — a quote that matches a served `en` rewrite and not en-orig is reported (exit 1 when any quote drifted) |
| `archilyzer reports attach-video` | `<report.json> <video> [--poster <image>] [--caption <line>]` | the report's video: remuxed (an H.264 mp4 under the limit) or encoded to fit the 24 MiB publish limit, written beside report.json as video.mp4 with a poster (given, kept, or a frame of the video), and `video` set in report.json |
@@ -99,7 +99,7 @@ Drives a running editor over HTTP (`/api/ops/*`, the same actions its pages run)
| `relocate-back` | — | |
| `evict-clips` | — | |
| `reports-prepare` | reports-prepare cuts every clip and copies every post capture a site's published reports cite into its report-media cache, before its build: {"siteId"}. The job fails, naming each one, when a citation lacks media. When nothing is missing it then exports the reports, as reports-export. | `pnpm ops reports-prepare --json '{"siteId":"demo-site"}' --wait` |
-| `reports-export` | reports-export writes each published report as report.html, report.pdf, report.md and evidence-pack.zip for the site's build to publish: {"siteId", "reportId"?, "formats"?: \["html","pdf","md","zip"\]}. | `pnpm ops reports-export --json '{"siteId":"demo-site","formats":["html","md"]}' --wait` |
+| `reports-export` | reports-export writes each published report as report.html, report.pdf, report.md, slides.html, slides.pdf and evidence-pack.zip for the site's build to publish: {"siteId", "reportId"?, "formats"?: \["html","pdf","md", "slides","slides-pdf","zip"\]}. | `pnpm ops reports-export --json '{"siteId":"demo-site","formats":["html","md"]}' --wait` |
| `lane` | — | `pnpm ops lane --json '{"lane":"download","held":true}'` |
| `tags` | — | `pnpm ops tags --json '{"op":"define","tag":{"id":"eva-collab","label":"Collab"}}'` |
| `tag-videos` | — | `pnpm ops tag-videos --file ids.json` |
diff --git a/common/bin/archilyzer.ts b/common/bin/archilyzer.ts
@@ -262,7 +262,7 @@ export const COMMANDS: Command[] = [
{
path: ["reports", "export"],
usage:
- "<id> [--report <reportId>] [--formats html,pdf,md,zip] [--allow-missing-media] write the site's published reports as files (report.html, report.pdf, report.md, evidence-pack.zip) into its report-exports staging, where compose publishes them from (exit 1 on a problem; a PDF skipped for want of a browser is a note; default id: SITE_ID)",
+ "<id> [--report <reportId>] [--formats html,pdf,md,slides,slides-pdf,zip] [--allow-missing-media] write the site's published reports as files (report.html, report.pdf, report.md, slides.html, slides.pdf, evidence-pack.zip) into its report-exports staging, where compose publishes them from (exit 1 on a problem; a PDF skipped for want of a browser is a note; default id: SITE_ID)",
flags: { report: "string", formats: "string", "allow-missing-media": "boolean" },
maxPositionals: 1,
run: async ({ positionals, flags, env }) => {
diff --git a/common/bin/reports-export.ts b/common/bin/reports-export.ts
@@ -1,6 +1,6 @@
-// `archilyzer reports export <siteId> [--report <id>] [--formats html,pdf,md,zip]`
+// `archilyzer reports export <siteId> [--report <id>] [--formats html,pdf,md,slides,slides-pdf,zip]`
// — write a site's published reports as files (report.html, report.pdf,
-// report.md, evidence-pack.zip) into
+// report.md, slides.html, slides.pdf, evidence-pack.zip) into
// `.export-index/sites/<siteId>/report-exports/<reportId>/`, where compose
// publishes them from. The work is publish/reportExports.ts's, the same the
// editor's `reports-export` job runs; this file prints its log and problems.
diff --git a/common/lib/report/exportSlides.test.ts b/common/lib/report/exportSlides.test.ts
@@ -0,0 +1,136 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import * as React from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import type { Report } from "./schema";
+import { validateReport } from "./validate";
+import { buildReportPageView, type ReportPageView } from "./views";
+import { buildReportSlides } from "./slides";
+import { reportSlidesHtml, slidesImagePaths, SLIDES_EXPORT_CSS } from "./exportSlidesHtml";
+import { Slide } from "../../components/report/slides/Slide";
+
+(globalThis as { React?: typeof React }).React = React;
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+const CSS = readFileSync(path.join(HERE, "..", "..", "styles", "slides.css"), "utf8");
+
+function report(): Report {
+ return {
+ format: "archilyzer-report",
+ version: 1,
+ id: "demo",
+ kind: "factcheck",
+ series: "Demo Checks",
+ title: "A demo <fact-check>",
+ summary: "One thing [right](cite:v1).",
+ published: "2026-10-01",
+ subject: { source: "s0" },
+ sources: { s0: { kind: "article", title: "An article", accent: "#8a6fb0" } },
+ citations: {
+ v1: { kind: "video", channel: "demo-channel", id: "abc123", start: 10, end: 20, quote: "one" },
+ p1: { kind: "post", channel: "demo-social", id: "123", quote: "three" },
+ a1: { kind: "source", source: "s0", quote: "the claim", image: "stills/a1.png" },
+ },
+ slides: { closing: "The end." },
+ sections: [
+ {
+ id: "ch1",
+ title: "Chapter one",
+ body: "Opening. Second.",
+ slide: { points: ["A point [citing](cite:v1)"], cite: "a1" },
+ claims: [
+ { id: "k1", text: "Claim one.", verdict: "CONTRADICTED", gist: "No.", sourceQuote: { citation: "a1" }, citations: ["a1", "v1"] },
+ { id: "k2", text: "Claim two.", verdict: "PARTLY", citations: ["p1"], slide: { layout: "quote" } },
+ { id: "k3", text: "Claim three.", flag: "No source given" },
+ ],
+ },
+ ],
+ };
+}
+
+function view(r = report()): ReportPageView {
+ assert.deepEqual(validateReport(r), []);
+ return buildReportPageView(r, {
+ record: (c) => ({ channel: c.channel, id: c.id, title: `Record ${c.id}` }),
+ poster: () => "/media/posters/abc123.png",
+ post: () => ({ author: "@demo", shot: "/media/posts/demo-social/123/shot.png" }),
+ });
+}
+
+const FOOTER = { revision: 2, date: "2026-10-04", reportSha256: "ab".repeat(32) };
+const images: Record<string, string> = {
+ "/media/posters/abc123.png": "data:image/png;base64,AAA",
+ "/reports/demo/stills/a1.png": "data:image/png;base64,BBB",
+ "/media/posts/demo-social/123/shot.png": "data:image/png;base64,CCC",
+};
+const html = (v = view(), siteUrl = "https://site.example/") =>
+ reportSlidesHtml(v, { css: CSS, accent: "#6a4bc4", siteUrl, footer: FOOTER, image: (p) => images[p] });
+
+test("one page per slide, numbered as the site's hash numbers them, in the slides' order", () => {
+ const v = view();
+ const slides = buildReportSlides(v);
+ const out = html(v);
+ const pages = [...out.matchAll(/<section class="rs-page" id="s-(\d+)"/g)].map((m) => Number(m[1]));
+ assert.deepEqual(pages, slides.map((s) => s.n));
+ const keys = [...out.matchAll(/<article class="rs-slide" data-slide="([^"]+)"/g)].map((m) => m[1]);
+ assert.deepEqual(keys, slides.map((s) => s.key));
+});
+
+test("one file: the stylesheet and the script inline, every picture the resolver's, nothing fetched", () => {
+ const out = html();
+ assert.ok(out.includes(CSS.slice(0, 200)));
+ assert.ok(out.includes(SLIDES_EXPORT_CSS.slice(0, 80)));
+ assert.equal([...out.matchAll(/<script>/g)].length, 1);
+ assert.doesNotMatch(out, /<script [^>]*src=|<link /);
+ const srcs = [...out.matchAll(/<img [^>]*src="([^"]+)"/g)].map((m) => m[1]);
+ // The clip's poster on k1's evidence, the still on the section's; k2's
+ // quote shows its post's words, not its capture.
+ assert.deepEqual(srcs.sort(), ["data:image/png;base64,AAA", "data:image/png;base64,BBB"]);
+ assert.deepEqual(slidesImagePaths(view()), ["/media/posters/abc123.png", "/reports/demo/stills/a1.png"]);
+ // The site's accent, on the light base.
+ assert.match(out, /<html lang="en" class="rs-root" style="--brand:#6a4bc4;/);
+ // Escaped, as everywhere.
+ assert.match(out, /A demo <fact-check>/);
+});
+
+test("the way back: each slide links to its place in the article on the site; with no site, nowhere", () => {
+ const out = html();
+ assert.match(out, /href="https:\/\/site\.example\/reports\/demo\/#k1" data-slide-read="k1"/);
+ assert.match(out, /href="https:\/\/site\.example\/reports\/demo\/#report-end" data-slide-read="report-end"/);
+ assert.doesNotMatch(html(view(), ""), /data-slide-read=/);
+ // A citation's number goes to the reference list under the deck, which links out.
+ assert.match(out, /<a href="#c-v1">\[1\]<\/a>/);
+ assert.match(out, /<li id="c-v1" value="1">/);
+ assert.match(out, /href="https:\/\/site\.example\/m\/demo-channel\/abc123\/10\.00-20\.00\/"/);
+});
+
+test("the sources slide names which document this is", () => {
+ assert.match(html(), /data-export-footer="">Revision 2 · 2026-10-04 · report sha256 abababababab</);
+ assert.match(html(), /The end\./);
+});
+
+test("the string slides mirror Slide.tsx: the same `rs-` classes, slide by slide", () => {
+ const v = view();
+ const slides = buildReportSlides(v);
+ // The export's own line on the sources slide (which document this is) is
+ // the one difference.
+ const out = html(v).replace(/<p class="rs-dates" data-export-footer="">[^<]*<\/p>/, "");
+ const classes = (markup: string) =>
+ [...new Set([...markup.matchAll(/class="([^"]*)"/g)].flatMap((m) => m[1].split(/\s+/)).filter((c) => c.startsWith("rs-") && c !== "rs-n"))].sort();
+ const articles = out.split(/(?=<article class="rs-slide")/).slice(1).map((a) => a.slice(0, a.indexOf("</article>") + 10));
+ slides.forEach((s, i) => {
+ const react = renderToStaticMarkup(
+ React.createElement(Slide, { slide: s, view: v, total: slides.length, pageUrl: "https://site.example/reports/demo/" }),
+ );
+ assert.deepEqual(classes(articles[i]), classes(react), s.key);
+ });
+});
+
+test("every `rs-` class a slide uses has a rule in slides.css", () => {
+ const used = new Set([...html().matchAll(/class="([^"]*)"/g)].flatMap((m) => m[1].split(/\s+/)).filter((c) => c.startsWith("rs-")));
+ const styled = new Set([...(CSS + SLIDES_EXPORT_CSS).matchAll(/\.(rs-[a-z-]+)/g)].map((m) => m[1]));
+ assert.deepEqual([...used].filter((c) => !styled.has(c)), []);
+});
diff --git a/common/lib/report/exportSlidesHtml.ts b/common/lib/report/exportSlidesHtml.ts
@@ -0,0 +1,416 @@
+// 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 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 <img src>, or undefined to leave it out.
+ image: (sitePath: string) => string | undefined;
+};
+
+const style = (vars: Record<string, string | undefined>) => {
+ 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<string>();
+ 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 `<div class="rs-md">${markdownToHtml(text, cite, { siteUrl: x.opts.siteUrl })}</div>`;
+}
+
+function citeLabel(c: CitationView, title: string): string {
+ const n = c.number !== undefined ? `<sup class="rs-n"><a href="#${escapeHtml(citationAnchor(c.id))}">[${c.number}]</a></sup>` : "";
+ return `${escapeHtml(title)}${n}`;
+}
+
+function points(ps: readonly string[], x: Ctx): string {
+ return `<ul class="rs-points">${ps.map((p) => `<li>${md(p, x)}</li>`).join("")}</ul>`;
+}
+
+function chip(view: ReportPageView, v: Verdict, count?: number): string {
+ const s = verdictStyleOf(view, v);
+ return (
+ `<span data-verdict="${escapeHtml(v)}"${style({ "--verdict": s.color })}><span aria-hidden="true"></span>${escapeHtml(s.label)}` +
+ `${count !== undefined ? `<span>${count}</span>` : ""}</span>`
+ );
+}
+
+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 (
+ `<figure class="rs-ev" data-slide-evidence="${escapeHtml(c.id)}">` +
+ (src ? `<img class="rs-ev-pic" data-kind="${c.kind}" src="${escapeHtml(src)}" alt="${escapeHtml(pic!.alt)}">` : "") +
+ (!(c.kind === "source" && src) ? `<blockquote class="rs-ev-quote"${style({ "--rs-ev-rail": rail })}>${escapeHtml(c.quote)}</blockquote>` : "") +
+ `<figcaption class="rs-ev-cap"><span class="rs-ev-kind">${escapeHtml(
+ [CITATION_KIND_LABELS[c.kind], ...meta].join(" · "),
+ )}</span><span class="rs-ev-title">${citeLabel(c, title)}</span></figcaption></figure>`
+ );
+}
+
+function pull(c: CitationView): string {
+ const { title, meta } = heading(c);
+ return (
+ `<figure class="rs-ev" data-slide-evidence="${escapeHtml(c.id)}"><blockquote class="rs-pull">${escapeHtml(c.quote)}</blockquote>` +
+ `<figcaption class="rs-ev-cap" style="padding-left:64px"><span class="rs-ev-kind">${escapeHtml(
+ [CITATION_KIND_LABELS[c.kind], ...meta].join(" · "),
+ )}</span><span class="rs-ev-title">${citeLabel(c, title)}</span></figcaption></figure>`
+ );
+}
+
+function titleBody(s: TitleSlide): string {
+ return (
+ (s.series ? `<p class="rs-title-series">${escapeHtml(s.series)}</p>` : "") +
+ `<h2 class="rs-h rs-title-h">${escapeHtml(s.title)}</h2>` +
+ (s.line ? `<p class="rs-sub">${escapeHtml(s.line)}</p>` : "") +
+ (s.dates.length ? `<p class="rs-dates">${escapeHtml(s.dates.join(" · "))}</p>` : "") +
+ (s.subject
+ ? `<div class="rs-subject"${style({ "--rs-subject": s.subject.accent })}><b>${escapeHtml(s.subject.title)}</b>` +
+ `${s.subject.meta.length ? `<span>${escapeHtml(s.subject.meta.join(" · "))}</span>` : ""}</div>`
+ : "")
+ );
+}
+
+function summaryBody(s: SummarySlide, x: Ctx): string {
+ return (
+ `<h2 class="rs-h rs-h-sm">${escapeHtml(s.title)}</h2>` +
+ (s.points ? points(s.points, x) : s.text ? `<div class="rs-statement">${md(s.text, x)}</div>` : "") +
+ (s.tally
+ ? `<div class="rs-chip"><ul data-verdict-tally="">${s.tally.map((t) => `<li>${chip(x.view, t.verdict, t.count)}</li>`).join("")}</ul></div>`
+ : "")
+ );
+}
+
+function foundBody(s: FoundSlide, x: Ctx): string {
+ return (
+ `<h2 class="rs-h rs-h-sm">${escapeHtml(s.title)}</h2>` +
+ `<div class="rs-found"${style({ "--rs-cols": String(Math.min(s.groups.length, 5)) })}>` +
+ s.groups
+ .map((g) => {
+ const v = verdictStyleOf(x.view, g.verdict);
+ return (
+ `<div class="rs-found-group" data-found-group="${escapeHtml(g.verdict)}"${style({ "--rs-verdict": v.color })}>` +
+ `<span class="rs-found-n">${g.claims.length}</span><span class="rs-found-label">${escapeHtml(v.label)}</span>` +
+ `<ul class="rs-found-claims">${g.claims.map((c) => `<li>${escapeHtml(c.title)}</li>`).join("")}</ul></div>`
+ );
+ })
+ .join("") +
+ `</div>`
+ );
+}
+
+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
+ ? `<div class="rs-statement">${md(s.text, x)}</div>`
+ : s.claims.length
+ ? `<ul class="rs-claimlist">${s.claims
+ .map((c) => {
+ const v = c.verdict ? verdictStyleOf(x.view, c.verdict) : undefined;
+ return `<li${style({ "--rs-verdict": v?.color })}><i aria-hidden="true"></i><span>${escapeHtml(c.title)}${
+ v ? `<span class="sr-only"> (${escapeHtml(v.label)})</span>` : ""
+ }</span></li>`;
+ })
+ .join("")}</ul>`
+ : "";
+ if (s.layout === "quote" && cited) return `<h2 class="rs-h rs-h-sm">${escapeHtml(s.title)}</h2>${pull(cited)}`;
+ if (cited) {
+ return `<div class="rs-split"><div class="rs-col"><h2 class="rs-h">${escapeHtml(s.title)}</h2>${words}</div>${evidence(cited, x)}</div>`;
+ }
+ return `<h2 class="rs-h">${escapeHtml(s.title)}</h2>${words}`;
+}
+
+function claimHead(s: ClaimSlide, x: Ctx, small: boolean): string {
+ const chips =
+ s.verdict || s.flag
+ ? `<div class="rs-chips rs-chip">${s.verdict ? chip(x.view, s.verdict) : ""}${s.flag ? `<span class="rs-flag">${escapeHtml(s.flag)}</span>` : ""}</div>`
+ : "";
+ return `${chips}<h2 class="${small ? "rs-h rs-h-sm" : "rs-h"}">${escapeHtml(s.title)}</h2>`;
+}
+
+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 ? `<p class="rs-gist">${escapeHtml(s.gist)}</p>` : "";
+ const text = s.text ? `<p class="rs-claimtext">${escapeHtml(s.text)}</p>` : "";
+ if (s.layout === "quote" && cited) return `${claimHead(s, x, true)}${pull(cited)}`;
+ if (s.layout === "evidence" && cited) {
+ return `<div class="rs-split"><div class="rs-col">${claimHead(s, x, true)}${text}${finding}</div>${evidence(cited, x)}</div>`;
+ }
+ 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 (
+ `<h2 class="rs-h">${escapeHtml(s.title)}</h2><div class="rs-figures">` +
+ `<div class="rs-figure"><b>${s.references}</b><span>${s.references === 1 ? "reference" : "references"}</span></div>` +
+ (s.documents > 0
+ ? `<div class="rs-figure"><b>${s.documents}</b><span>${s.documents === 1 ? "document quoted" : "documents quoted"}</span></div>`
+ : "") +
+ `</div>` +
+ (s.closing ? `<p class="rs-sub">${escapeHtml(s.closing)}</p>` : "") +
+ (pageUrl ? `<p class="rs-url"><a href="${escapeHtml(pageUrl)}">${escapeHtml(pageUrl)}</a></p>` : "") +
+ `<p class="rs-dates" data-export-footer="">${escapeHtml(reportExportFooterLine(x.opts.footer))}</p>`
+ );
+}
+
+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 "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 (
+ `<p class="rs-eyebrow"><span class="rs-eyebrow-place"><b>${escapeHtml(kind)}</b>${place ? ` · ${escapeHtml(place)}` : ""}</span>` +
+ `<span class="rs-eyebrow-n" aria-hidden="true">${pad(s.n)} / ${pad(total)}</span></p>`
+ );
+}
+
+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 "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 (
+ `<article class="rs-slide" data-slide="${escapeHtml(s.key)}" data-slide-kind="${s.kind}" data-layout="${s.layout}" data-fit="canvas"${style({ "--rs-mark": mark })}>` +
+ `<span class="rs-band" aria-hidden="true"></span>${eyebrow(s, x.total)}<div class="rs-main">${body}</div>` +
+ `<footer class="rs-foot"><span class="rs-foot-name">${escapeHtml(reportFullTitle(x.view))}</span>` +
+ (article ? `<a class="rs-read" href="${escapeHtml(article)}" data-slide-read="${escapeHtml(s.anchorId)}">Read this in the article <span aria-hidden="true">→</span></a>` : "") +
+ `</footer></article>`
+ );
+}
+
+function referencesHtml(x: Ctx): string {
+ const refs = orderedCitations(x.view);
+ if (refs.length === 0) return "";
+ return (
+ `<section class="rs-refs" aria-labelledby="rs-refs-h"><h2 id="rs-refs-h">References</h2><ol>` +
+ refs
+ .map((c) => {
+ const { title, meta } = heading(c);
+ const url = citationUrl(c, x.opts.siteUrl);
+ const t = url ? `<a href="${escapeHtml(url)}">${escapeHtml(title)}</a>` : escapeHtml(title);
+ return (
+ `<li id="${escapeHtml(citationAnchor(c.id))}" value="${c.number ?? ""}"><span class="rs-ev-kind">${escapeHtml(
+ [CITATION_KIND_LABELS[c.kind], ...meta].join(" · "),
+ )}</span> ${t}<q>${escapeHtml(c.quote)}</q></li>`
+ );
+ })
+ .join("") +
+ `</ol></section>`
+ );
+}
+
+// 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<p.length;j++)if(p[j].offsetTop<=y)i=j;var m={ArrowRight:1,PageDown:1,ArrowDown:1,ArrowLeft:-1,PageUp:-1,ArrowUp:-1,Home:-i,End:p.length-1-i}[e.key];if(m===undefined||!p.length)return;e.preventDefault();var t=p[Math.max(0,Math.min(p.length-1,i+m))];t.scrollIntoView();history.replaceState(null,"","#"+t.id)})})();`;
+
+export function reportSlidesHtml(view: ReportPageView, opts: ReportSlidesHtmlOptions): string {
+ const slides = buildReportSlides(view);
+ const x: Ctx = { view, opts, total: slides.length };
+ const pages = slides
+ .map(
+ (s) =>
+ `<section class="rs-page" id="s-${s.n}" aria-label="${escapeHtml(`Slide ${s.n} of ${slides.length}: ${slidePointText(s.title)}`)}">` +
+ `<div class="rs-page-box">${slideHtml(s, x)}</div></section>`,
+ )
+ .join("\n");
+ const root = `--brand:${opts.accent};--brand-soft:color-mix(in oklab, ${opts.accent} 12%, transparent);color-scheme:light`;
+ return (
+ `<!doctype html>\n<html lang="en" class="rs-root" style="${escapeHtml(root)}">\n<head>\n<meta charset="utf-8">\n` +
+ `<meta name="viewport" content="width=device-width, initial-scale=1">\n` +
+ `<title>${escapeHtml(`${reportFullTitle(view)} — slides`)}</title>\n` +
+ `<meta name="generator" content="Archilyzer">\n` +
+ `<style>\n${opts.css}\n${SLIDES_EXPORT_CSS}\n</style>\n</head>\n<body>\n<main data-report-slides="${escapeHtml(view.id)}">\n` +
+ `${pages}\n${referencesHtml(x)}\n</main>\n<script>${SLIDES_SCRIPT}</script>\n</body>\n</html>\n`
+ );
+}
diff --git a/common/lib/report/views.ts b/common/lib/report/views.ts
@@ -92,14 +92,18 @@ export function reportCitationsDownloadPath(reportId: string, format: "json" | "
}
// A report's exports (publish/reportExports.ts): the whole report as files a
-// reader can save and host again. Their names, and where compose publishes
-// them beside the report's page.
-export const REPORT_EXPORT_FORMATS = ["html", "pdf", "md", "zip"] as const;
+// reader can save and host again — the article (html, pdf, md), its slides
+// (lib/report/slides.ts: `slides` is slides.html, `slides-pdf` one 16:9 page
+// per slide; none for a report whose `slides.hide` says so) and the evidence
+// pack. Their names, and where compose publishes them beside the report's page.
+export const REPORT_EXPORT_FORMATS = ["html", "pdf", "md", "slides", "slides-pdf", "zip"] as const;
export type ReportExportFormat = (typeof REPORT_EXPORT_FORMATS)[number];
export const REPORT_EXPORT_FILENAMES: Readonly<Record<ReportExportFormat, string>> = {
html: "report.html",
pdf: "report.pdf",
md: "report.md",
+ slides: "slides.html",
+ "slides-pdf": "slides.pdf",
zip: "evidence-pack.zip",
};
diff --git a/common/publish/composeReports.test.ts b/common/publish/composeReports.test.ts
@@ -614,9 +614,24 @@ test("reports export: every format from the resolved view, a self-contained HTML
const r = await exportSiteReports({ siteId: "cited", paths, now, openPdfPrinter: fakePrinter(printed), onLog: () => {} });
assert.deepEqual(r.problems, []);
const dir = reportExportDir(paths, "cited", REPORT);
- assert.deepEqual(readdirSync(dir).sort(), ["evidence-pack.zip", "export.json", "report.html", "report.md", "report.pdf"]);
+ assert.deepEqual(readdirSync(dir).sort(), [
+ "evidence-pack.zip",
+ "export.json",
+ "report.html",
+ "report.md",
+ "report.pdf",
+ "slides.html",
+ "slides.pdf",
+ ]);
const html = readFileSync(path.join(dir, "report.html"), "utf8");
- assert.deepEqual(printed, [html], "the PDF is the HTML, printed");
+ const slides = readFileSync(path.join(dir, "slides.html"), "utf8");
+ assert.deepEqual(printed, [html, slides], "each PDF is its HTML, printed");
+ // The slides: one file, the site's stylesheet and its few lines of script
+ // inline, every picture inlined, one page per slide.
+ assert.match(slides, /\.rs-slide\s*\{/);
+ assert.equal([...slides.matchAll(/<script>/g)].length, 1);
+ for (const m of slides.matchAll(/<img [^>]*src="([^"]+)"/g)) assert.match(m[1], /^data:image\//);
+ assert.ok([...slides.matchAll(/class="rs-page" id="s-\d+"/g)].length >= 4);
assert.doesNotMatch(html, /<script/i);
const imgs = [...html.matchAll(/<img [^>]*src="([^"]+)"/g)].map((m) => m[1]);
assert.equal(imgs.length, 2, "the claim's still and the post's screenshot");
@@ -632,7 +647,7 @@ test("reports export: every format from the resolved view, a self-contained HTML
path.join(dir, "export.json"),
);
assert.equal(manifest.reportSha256, sha(path.join(paths.sitesDir, "cited", "reports", REPORT, "report.json")));
- assert.deepEqual(Object.keys(manifest.files), ["html", "pdf", "md", "zip"]);
+ assert.deepEqual(Object.keys(manifest.files), ["html", "pdf", "md", "slides", "slides-pdf", "zip"]);
assert.equal(manifest.files.html.sha256, sha(path.join(dir, "report.html")));
assert.deepEqual(manifest.notes, []);
assert.match(html, new RegExp(`report sha256 ${manifest.reportSha256.slice(0, 12)}`));
@@ -649,20 +664,21 @@ test("reports export: every format from the resolved view, a self-contained HTML
`${REPORT}/media/report/stills/a01.png`,
`${REPORT}/report.html`,
`${REPORT}/report.md`,
+ `${REPORT}/slides.html`,
]);
const packed = execFileSync("unzip", ["-p", zip, `${REPORT}/report.html`], { encoding: "utf8" });
assert.match(packed, /<video controls preload="none" src="media\/clips\/demo-channel\/abc123\/10\.00-20\.00\.mp4">/);
assert.match(packed, /<img src="media\/report\/stills\/a01\.png"/);
// The same report exports to the same bytes.
- const before = { html: sha(path.join(dir, "report.html")), zip: sha(zip) };
+ const before = { html: sha(path.join(dir, "report.html")), slides: sha(path.join(dir, "slides.html")), zip: sha(zip) };
await exportSiteReports({ siteId: "cited", paths, now, openPdfPrinter: fakePrinter([]), onLog: () => {} });
- assert.deepEqual({ html: sha(path.join(dir, "report.html")), zip: sha(zip) }, before);
+ assert.deepEqual({ html: sha(path.join(dir, "report.html")), slides: sha(path.join(dir, "slides.html")), zip: sha(zip) }, before);
});
test("compose publishes the exports beside the report and lists them as downloads", async () => {
await compose("cited");
- for (const f of ["report.html", "report.pdf", "report.md", "evidence-pack.zip"]) {
+ for (const f of ["report.html", "report.pdf", "report.md", "slides.html", "slides.pdf", "evidence-pack.zip"]) {
assert.equal(sha(pub("reports", REPORT, f)), sha(path.join(reportExportDir(paths, "cited", REPORT), f)), f);
}
const view = readJson<{ downloads: Record<string, string> }>(pub("reports", REPORT, "page.json"));
@@ -670,6 +686,8 @@ test("compose publishes the exports beside the report and lists them as download
html: `/reports/${REPORT}/report.html`,
pdf: `/reports/${REPORT}/report.pdf`,
md: `/reports/${REPORT}/report.md`,
+ slides: `/reports/${REPORT}/slides.html`,
+ "slides-pdf": `/reports/${REPORT}/slides.pdf`,
zip: `/reports/${REPORT}/evidence-pack.zip`,
json: `/reports/${REPORT}/citations.json`,
csv: `/reports/${REPORT}/citations.csv`,
@@ -761,7 +779,7 @@ test("an export of another version of the report is not published; a pack over t
assert.ok(existsSync(pub("reports", REPORT, "report.html")));
assert.ok(!existsSync(pub("reports", REPORT, "evidence-pack.zip")));
const view = readJson<{ downloads: Record<string, string> }>(pub("reports", REPORT, "page.json"));
- assert.deepEqual(Object.keys(view.downloads), ["html", "pdf", "md", "json", "csv"]);
+ assert.deepEqual(Object.keys(view.downloads), ["html", "pdf", "md", "slides", "slides-pdf", "json", "csv"]);
});
test("reports export: no browser skips the PDF with a note; no zip fails the pack, naming it", async () => {
@@ -773,8 +791,11 @@ test("reports export: no browser skips the PDF with a note; no zip fails the pac
onLog: () => {},
});
const dir = reportExportDir(paths, "cited", REPORT);
- assert.deepEqual(readdirSync(dir).sort(), ["export.json", "report.html", "report.md"]);
- assert.deepEqual(r.exported[0].manifest.notes, ["report.pdf skipped: Playwright is not available on this host"]);
+ assert.deepEqual(readdirSync(dir).sort(), ["export.json", "report.html", "report.md", "slides.html"]);
+ assert.deepEqual(r.exported[0].manifest.notes, [
+ "report.pdf skipped: Playwright is not available on this host",
+ "slides.pdf skipped: Playwright is not available on this host",
+ ]);
assert.equal(r.problems.length, 1);
assert.equal(r.problems[0].format, "zip");
assert.match(r.problems[0].message, /needs the `zip` program/);
diff --git a/common/publish/reportExports.ts b/common/publish/reportExports.ts
@@ -23,7 +23,15 @@
// with a note — never a failure
// report.md plain Markdown, numbered references
// (lib/report/exportMarkdown.ts)
-// evidence-pack.zip `<reportId>/report.html` pointing at its own media/
+// slides.html the report as slides (lib/report/slides.ts), ONE
+// self-contained file like report.html
+// (lib/report/exportSlidesHtml.ts): the site's slide
+// stylesheet inline, a few lines of script for the keys,
+// every picture a data: URI. None for a report whose
+// `slides.hide` says so
+// slides.pdf those slides printed by headless Chromium, one
+// 1280 × 720 page per slide; skipped like report.pdf
+// evidence-pack.zip `<reportId>/report.html` (and slides.html) pointing at its own media/
// (the clips play offline, the stills and screenshots
// as files), report.md, citations.json and .csv. Packed
// by the system `zip`, deterministically (sorted names,
@@ -65,6 +73,9 @@ import { parseMomentKey, type SpanMoment } from "../lib/citations/moments";
import type { Report } from "../lib/report/schema";
import { reportExportHtml, type ExportClip, type ReportExportFooter } from "../lib/report/exportHtml";
import { reportExportMarkdown } from "../lib/report/exportMarkdown";
+import { reportSlidesHtml, slidesImagePaths } from "../lib/report/exportSlidesHtml";
+import { hasSlides } from "../lib/report/slides";
+import { resolveAccent } from "../lib/accent";
import {
REPORT_EXPORT_FILENAMES,
REPORT_EXPORT_FORMATS,
@@ -113,9 +124,12 @@ export type ReportExportProblem = {
message: string;
};
-// Prints HTML to PDF. One is opened per run and closed at its end.
+// Prints HTML to PDF — A4, or a page size (`size`, slides.pdf's 1280 × 720).
+// One is opened per run and closed at its end.
+export type PdfPageSize = { width: string; height: string };
+export const SLIDES_PDF_PAGE: PdfPageSize = { width: "1280px", height: "720px" };
export type PdfPrinter = {
- print: (html: string) => Promise<Uint8Array>;
+ print: (html: string, size?: PdfPageSize) => Promise<Uint8Array>;
close: () => Promise<void>;
};
@@ -194,13 +208,13 @@ export const openPlaywrightPdfPrinter: OpenPdfPrinter = async () => {
}
const b = browser;
return {
- print: async (html) => {
+ print: async (html, size) => {
const context = await b.newContext({});
try {
const page = await context.newPage();
if (!page.setContent || !page.pdf) throw new Error("this Playwright cannot print a page");
await page.setContent(html, { waitUntil: "load" });
- return await page.pdf({ format: "A4", printBackground: true });
+ return await page.pdf(size ? { ...size, printBackground: true } : { format: "A4", printBackground: true });
} finally {
await context.close();
}
@@ -268,6 +282,23 @@ function viewImagePaths(view: ReportPageView): string[] {
return [...out].sort();
}
+// The pictures only the slides show (a clip's poster on a claim's slide):
+// inlined when this host has the file, else the slide shows its quote alone —
+// never a problem, a poster may be a link elsewhere.
+function slidesOnlyImagePaths(view: ReportPageView): string[] {
+ if (!hasSlides(view)) return [];
+ const named = new Set(viewImagePaths(view));
+ return slidesImagePaths(view).filter((p) => p.startsWith("/") && !named.has(p));
+}
+
+// The slides' stylesheet, the one the sites draw them with: inlined into slides.html.
+export async function readSlidesCss(paths: Pick<Paths, "monorepoRoot"> = getPaths()): Promise<string> {
+ return readFile(path.join(/* turbopackIgnore: true */ paths.monorepoRoot, "common", "styles", "slides.css"), "utf8");
+}
+
+// What slides.html is drawn with: the stylesheet, and the site's accent on the light base.
+type SlidesLook = { css: string; accent: string };
+
// ─── Where a view's files are on this host ───
export type ReportFiles = {
@@ -344,6 +375,8 @@ async function buildEvidencePack(o: {
markdown: string;
outFile: string;
zipBin: string;
+ // The slides' look, when the pack carries slides.html.
+ slides?: SlidesLook;
}): Promise<void> {
const tmp = await mkdtemp(path.join(os.tmpdir(), "report-evidence-pack-"));
try {
@@ -356,7 +389,7 @@ async function buildEvidencePack(o: {
names.push(`${top}/${rel}`);
};
const images = new Map<string, string>();
- for (const p of viewImagePaths(o.view)) {
+ for (const p of [...viewImagePaths(o.view), ...(o.slides ? slidesOnlyImagePaths(o.view) : [])]) {
const local = o.files.local(p);
const rel = packPathOf(o.view, p);
if (!local || !rel) continue;
@@ -383,6 +416,17 @@ async function buildEvidencePack(o: {
clip: (c) => clips.get(c.moment),
});
await put(REPORT_EXPORT_FILENAMES.html, html);
+ if (o.slides) {
+ await put(
+ REPORT_EXPORT_FILENAMES.slides,
+ reportSlidesHtml(o.view, {
+ ...o.slides,
+ siteUrl: o.site.siteUrl || undefined,
+ footer: o.footer,
+ image: (p) => images.get(p),
+ }),
+ );
+ }
await put(REPORT_EXPORT_FILENAMES.md, o.markdown);
await put("citations.json", `${JSON.stringify(citationSet(o.report, o.view), null, 2)}\n`);
await put("citations.csv", citationsCsv(o.view));
@@ -432,7 +476,7 @@ async function exportOneReport(o: {
// changed report.json is committed as the next one.
export async function writeReportExports(o: {
dir: string;
- site: Pick<Site, "siteId" | "siteUrl" | "siteTitle">;
+ site: Pick<Site, "siteId" | "siteUrl" | "siteTitle"> & { accent?: unknown };
report: Report;
view: ReportPageView;
reportSha256: string;
@@ -445,6 +489,8 @@ export async function writeReportExports(o: {
log: (line: string) => void;
signal?: AbortSignal;
history?: { gitDir: string; reportJson: Uint8Array };
+ // The slides' stylesheet; default common/styles/slides.css (readSlidesCss).
+ slidesCss?: string;
}): Promise<{ result: ReportExportResult; problems: ReportExportProblem[] }> {
const { site, report, view, files } = o;
const problems: ReportExportProblem[] = [];
@@ -480,6 +526,22 @@ export async function writeReportExports(o: {
},
};
const html = reportExportHtml(view, htmlOpts);
+ // The slides, when the report has them and a format asks for them.
+ const wantsSlides = o.formats.some((f) => f === "slides" || f === "slides-pdf" || f === "zip");
+ const slidesLook: SlidesLook | undefined =
+ hasSlides(view) && wantsSlides
+ ? { css: o.slidesCss ?? (await readSlidesCss()), accent: resolveAccent(site.accent).light }
+ : undefined;
+ if (slidesLook) {
+ for (const p of slidesOnlyImagePaths(view)) {
+ const local = files.local(p);
+ const uri = local ? await exportImageDataUri(local, o.ffmpegBin, o.signal) : null;
+ if (uri) dataUris.set(p, uri);
+ }
+ }
+ const slidesHtml = slidesLook
+ ? reportSlidesHtml(view, { ...slidesLook, siteUrl, footer, image: (p) => dataUris.get(p) })
+ : undefined;
// The PDF prints what the page folds away (the evidence the document under
// review gave itself) open.
const printHtml = reportExportHtml(view, { ...htmlOpts, print: true });
@@ -511,6 +573,34 @@ export async function writeReportExports(o: {
await writeFileAtomic(out, markdown);
await record(f);
break;
+ case "slides":
+ if (!slidesHtml) {
+ o.log(` - ${report.id}/slides.html: the report publishes no slides`);
+ break;
+ }
+ await writeFileAtomic(out, slidesHtml);
+ await record(f);
+ break;
+ case "slides-pdf": {
+ if (!slidesHtml) {
+ o.log(` - ${report.id}/slides.pdf: the report publishes no slides`);
+ break;
+ }
+ const printer = await o.printer();
+ if ("missing" in printer) {
+ notes.push(`slides.pdf skipped: ${printer.missing}`);
+ o.log(` - ${report.id}/slides.pdf skipped: ${printer.missing}`);
+ break;
+ }
+ try {
+ await writeFileAtomic(out, Buffer.from(await printer.print(slidesHtml, SLIDES_PDF_PAGE)));
+ await record(f);
+ } catch (e) {
+ notes.push(`slides.pdf skipped: printing failed (${firstLine(e)})`);
+ o.log(` - ${report.id}/slides.pdf skipped: printing failed (${firstLine(e)})`);
+ }
+ break;
+ }
case "pdf": {
const printer = await o.printer();
if ("missing" in printer) {
@@ -537,7 +627,7 @@ export async function writeReportExports(o: {
break;
}
try {
- await buildEvidencePack({ report, view, files, site, footer, markdown, outFile: out, zipBin: o.zipBin });
+ await buildEvidencePack({ report, view, files, site, footer, markdown, outFile: out, zipBin: o.zipBin, slides: slidesLook });
await record(f);
} catch (e) {
problems.push({ report: report.id, format: "zip", message: `the evidence pack could not be packed: ${firstLine(e)}` });
diff --git a/common/publish/reportMedia.test.ts b/common/publish/reportMedia.test.ts
@@ -248,8 +248,8 @@ test("a clean prepare ends by exporting the reports; a host with no browser skip
);
assert.equal(code, 0, err.join("\n"));
const dir = path.join(paths.exportSitesIndexDir, SITE, "report-exports", "r2");
- assert.deepEqual(readdirSync(dir).sort(), ["evidence-pack.zip", "export.json", "report.html", "report.md"]);
+ assert.deepEqual(readdirSync(dir).sort(), ["evidence-pack.zip", "export.json", "report.html", "report.md", "slides.html"]);
const manifest = JSON.parse(readFileSync(path.join(dir, "export.json"), "utf8"));
- assert.deepEqual(manifest.notes, ["report.pdf skipped: no browser here"]);
+ assert.deepEqual(manifest.notes, ["report.pdf skipped: no browser here", "slides.pdf skipped: no browser here"]);
assert.ok(out.some((l) => l.includes("report.pdf skipped: no browser here")));
});
diff --git a/common/social/playwrightRuntime.ts b/common/social/playwrightRuntime.ts
@@ -54,6 +54,9 @@ export type PageLike = {
setContent?: (html: string, opts?: { waitUntil?: "load" | "domcontentloaded" | "networkidle" }) => Promise<void>;
pdf?: (opts?: {
format?: string;
+ // A page size instead of a format (slides.pdf: 1280px × 720px).
+ width?: string;
+ height?: string;
printBackground?: boolean;
margin?: { top?: string; right?: string; bottom?: string; left?: string };
}) => Promise<Uint8Array>;
diff --git a/export/app/components/reports/ReportArticle.tsx b/export/app/components/reports/ReportArticle.tsx
@@ -49,6 +49,8 @@ const DOWNLOADS: readonly (readonly [keyof NonNullable<ReportPageView["downloads
["html", "HTML"],
["pdf", "PDF"],
["md", "Markdown"],
+ ["slides", "Slides"],
+ ["slides-pdf", "Slides PDF"],
["zip", "Evidence pack"],
["json", "Citations JSON"],
["csv", "CSV"],
diff --git a/export/e2e-report/audit.spec.ts b/export/e2e-report/audit.spec.ts
@@ -31,6 +31,8 @@ test("it holds the reports, the moments, the cited media and the contract", () =
"reports/demo-factcheck/report.html",
"reports/demo-factcheck/report.pdf",
"reports/demo-factcheck/report.md",
+ "reports/demo-factcheck/slides.html",
+ "reports/demo-factcheck/slides.pdf",
"reports/demo-factcheck/evidence-pack.zip",
"reports/demo-factcheck/stills/a01.png",
"reports/demo-factcheck/history/index.html",
diff --git a/export/e2e-report/report-site.spec.ts b/export/e2e-report/report-site.spec.ts
@@ -245,18 +245,20 @@ test("a citation's number jumps to its entry in the reference list", async ({ pa
await expect(page.locator("#c-w01")).toContainText("Opened to traffic: April 2019.");
});
-test("the report downloads as HTML, PDF, Markdown and an evidence pack, its citations as JSON and CSV", async ({
+test("the report downloads as HTML, PDF, Markdown, slides and an evidence pack, its citations as JSON and CSV", async ({
page,
request,
}) => {
await page.goto(REPORT);
const links = page.locator("[data-citation-downloads] a[download]");
- await expect(links).toHaveText(["HTML", "PDF", "Markdown", "Evidence pack", "Citations JSON", "CSV"]);
+ await expect(links).toHaveText(["HTML", "PDF", "Markdown", "Slides", "Slides PDF", "Evidence pack", "Citations JSON", "CSV"]);
const hrefs = await links.evaluateAll((els) => els.map((el) => el.getAttribute("href")!));
expect(hrefs).toEqual([
`${REPORT}report.html`,
`${REPORT}report.pdf`,
`${REPORT}report.md`,
+ `${REPORT}slides.html`,
+ `${REPORT}slides.pdf`,
`${REPORT}evidence-pack.zip`,
`${REPORT}citations.json`,
`${REPORT}citations.csv`,
@@ -267,6 +269,7 @@ test("the report downloads as HTML, PDF, Markdown and an evidence pack, its cita
const body = await res.body();
if (href.endsWith("citations.json")) expect(JSON.parse(body.toString()).format).toBe("archilyzer-citations");
else if (href.endsWith(".csv")) expect(body.toString()).toContain("c01");
+ else if (href.endsWith("slides.html")) expect(body.toString()).toContain("<title>Demo Checks: Checking an example article — slides</title>");
else if (href.endsWith(".html")) expect(body.toString()).toContain("<title>Demo Checks: Checking an example article</title>");
else if (href.endsWith(".pdf")) expect(body.subarray(0, 5).toString()).toBe("%PDF-");
else if (href.endsWith(".md")) expect(body.toString()).toContain("**Demo Checks**\n\n# Checking an example article\n");
diff --git a/export/e2e-report/slides.spec.ts b/export/e2e-report/slides.spec.ts
@@ -160,3 +160,29 @@ test("light and dark: the slides follow the base and wear the site's accent", as
});
await expect.poll(() => slide.evaluate((el) => getComputedStyle(el).backgroundColor)).not.toBe(lightBg);
});
+
+test("slides.html opens with no network: one page per slide, its pictures inlined, the keys step", async ({ page, baseURL }) => {
+ const url = `${baseURL}${REPORT}slides.html`;
+ const asked: string[] = [];
+ await page.route("**/*", (route) => {
+ const u = route.request().url();
+ if (u === url) return route.continue();
+ asked.push(u);
+ return route.abort();
+ });
+ await page.setViewportSize({ width: 1280, height: 720 });
+ await page.goto(url);
+ await expect(page.locator(".rs-page")).toHaveCount(11);
+ await expect(page.locator('#s-1 [data-slide="title"]')).toContainText("Checking an example article");
+ const imgs = page.locator("img");
+ expect(await imgs.count()).toBeGreaterThan(0);
+ for (const img of await imgs.all()) {
+ expect(await img.getAttribute("src")).toMatch(/^data:image\//);
+ await expect.poll(() => img.evaluate((el) => (el as HTMLImageElement).naturalWidth)).toBeGreaterThan(0);
+ }
+ await page.keyboard.press("ArrowRight");
+ await expect(page).toHaveURL(`${url}#s-2`);
+ await expect(page.locator("#s-2")).toBeInViewport();
+ await expect(page.locator("#s-11 [data-export-footer]")).toContainText("Revision 2");
+ expect(asked).toEqual([]);
+});
diff --git a/export/fixtures/report-site/public/reports/demo-factcheck/page.json b/export/fixtures/report-site/public/reports/demo-factcheck/page.json
@@ -246,6 +246,8 @@
"html": "/reports/demo-factcheck/report.html",
"pdf": "/reports/demo-factcheck/report.pdf",
"md": "/reports/demo-factcheck/report.md",
+ "slides": "/reports/demo-factcheck/slides.html",
+ "slides-pdf": "/reports/demo-factcheck/slides.pdf",
"zip": "/reports/demo-factcheck/evidence-pack.zip",
"json": "/reports/demo-factcheck/citations.json",
"csv": "/reports/demo-factcheck/citations.csv"
diff --git a/scripts/archilyzer-ops.mjs b/scripts/archilyzer-ops.mjs
@@ -437,8 +437,9 @@ export function usage() {
' When nothing is missing it then exports the reports, as reports-export.',
"",
'reports-export writes each published report as report.html, report.pdf,',
- ' report.md and evidence-pack.zip for the site\'s build to publish:',
- ' {"siteId", "reportId"?, "formats"?: ["html","pdf","md","zip"]}.',
+ ' report.md, slides.html, slides.pdf and evidence-pack.zip for the site\'s',
+ ' build to publish: {"siteId", "reportId"?, "formats"?: ["html","pdf","md",',
+ ' "slides","slides-pdf","zip"]}.',
"",
'channel-config changes a channel as its Configure form does: {"slug"} and',
' any of "patch" (form field names; "" clears one), "sites" (the WHOLE',