Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 711ae6f0a3861df3093e236940d2b8bc062345fe
parent e504009d8bc78f96f9a392095822c9845f055a8f
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri,  9 Oct 2026 14:06:17 -0400

common: report slides — the slide fields, their rules, and buildReportSlides (release 22 E1)

report.json gains `slides` ({hide, title, points, closing}) and a section's
or claim's `slide` ({title, points, cite, layout, hide}); version stays 1.
The validator holds them to slideRules.ts (lines one line, at most 5
points of at most 140 visible characters, a `cite` of the slide's own
section or claim, points citing only what the article cites, an evidence
layout with something to show) and refuses a section or claim id that is
one of the page's own anchors. lib/report/slides.ts builds the deck from
the page view — title, In brief, What the check found, each section and
claim, sources — each slide anchored to an id the article page carries;
with the reader's place in the URL (`rv`, `#s-<n>`) and a lint for
umtool. The view carries the fields through (compose and umtool's
lenient view alike); REPORT.md regenerated; the demo fixture gains them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
MREPORT.md | 32+++++++++++++++++++++++++++++---
Mcommon/lib/report/docs.ts | 27+++++++++++++++++++++++++--
Mcommon/lib/report/report.test.ts | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/report/schema.ts | 50++++++++++++++++++++++++++++++++++++++++++++++++--
Acommon/lib/report/slideRules.ts | 44++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/report/slides.test.ts | 302++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/report/slides.ts | 454+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/report/validate.ts | 107++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcommon/lib/report/views.ts | 11++++++++++-
Mexport/fixtures/report-site/public/reports/demo-factcheck/page.json | 26++++++++++++++++++++++++--
Mexport/fixtures/report-site/source/demo-factcheck/report.json | 14++++++++++++--
Mumtool/lib/articles/article.ts | 4+++-
12 files changed, 1109 insertions(+), 16 deletions(-)

diff --git a/REPORT.md b/REPORT.md @@ -6,7 +6,7 @@ One cited report, format `"archilyzer-report"`, version 1, persisted to `transcr A **fact-check** (`"kind": "factcheck"`) is sections (chapters) of claims, each with a verdict and its findings. A **sweep** (`"kind": "sweep"`) is sections with no verdicts, or bodies that cite inline. Markdown fields (`summary`, a section's `body`, a claim's `findings`) cite with `[label](cite:<id>)`. -`common/lib/report/validate.ts` reports every problem with its JSON path: an unknown key, a reference that names nothing (a listed citation, a claim's source sentence, a `cite:` link, the subject), a section or claim id used twice (they share one namespace: the report page's anchors), a sweep's claim with a verdict, `updated` before `published`, and every citation problem CITATIONS.md lists. Whether a still or the report's video exists, whether the video fits the publish limit, and whether a quote matches its cues are checked when the site is composed. +`common/lib/report/validate.ts` reports every problem with its JSON path: an unknown key, a reference that names nothing (a listed citation, a claim's source sentence, a `cite:` link, the subject), a section or claim id used twice (they share one namespace: the report page's anchors) or named for one of the page's own anchors, a sweep's claim with a verdict, `updated` before `published`, a slide field out of bounds (below), and every citation problem CITATIONS.md lists. Whether a still or the report's video exists, whether the video fits the publish limit, and whether a quote matches its cues are checked when the site is composed. Regenerate this file with `pnpm --filter yt-dlp-transcript-common exec tsx bin/file-schemas-docs.ts`. @@ -31,21 +31,23 @@ Regenerate this file with `pnpm --filter yt-dlp-transcript-common exec tsx bin/f | `sources` | no | The documents the report's `source` citations quote, by id — see [CITATIONS.md](CITATIONS.md). Absent = none. | | `citations` | no | The report's citations, by id — see [CITATIONS.md](CITATIONS.md). A citation is cited from markdown with `[label](cite:<id>)` and listed under the claims that rest on it. Absent = none. | | `sections` | yes | The report's sections, in order. | +| `slides` | no | How the report reads as slides (its page's Slides and Overview views, `slides.html`, `slides.pdf`) — see `slides` below. Absent = slides derived from its text. | #### `sections[]` | Key | Required | Description | |---|---|---| -| `id` | yes | The section's id (letters, digits, `_ . : -`; at most 64): its anchor on the report page. Unique among the report's section and claim ids. | +| `id` | yes | The section's id (letters, digits, `_ . : -`; at most 64): its anchor on the report page. Unique among the report's section and claim ids, and none of the page's own anchors (`report-head`, `in-brief`, `found`, `claims`, `references`, `downloads`, `report-end`). | | `title` | yes | The section's heading. | | `body` | no | Markdown under the heading. May cite inline. | | `claims` | no | The section's claims, in order. Absent = none. | +| `slide` | no | The section's slide — see `slide` below. Absent = derived from its text. | #### `sections[].claims[]` | Key | Required | Description | |---|---|---| -| `id` | yes | The claim's id (letters, digits, `_ . : -`; at most 64): its anchor on the report page. Unique among the report's section and claim ids. | +| `id` | yes | The claim's id (letters, digits, `_ . : -`; at most 64): its anchor on the report page. Unique among the report's section and claim ids, and none of the page's own anchors (`report-head`, `in-brief`, `found`, `claims`, `references`, `downloads`, `report-end`). | | `title` | no | A short headline for the claim (plain text, e.g. a phrase it turns on), shown above its text. Absent = the text alone. | | `text` | yes | The claim, as stated by the document under review (plain text). | | `verdict` | no | The ruling on the claim: `CORROBORATED`, `PARTLY`, `CONTRADICTED`, `NOT_FOUND`, `UNTESTABLE`. A fact-check's claim may leave it out (not yet ruled); a sweep's carries none. | @@ -54,6 +56,30 @@ Regenerate this file with `pnpm --filter yt-dlp-transcript-common exec tsx bin/f | `sourceQuote` | no | The document's own sentence making the claim: `{ "citation": "<id>" }`, naming a `source` citation (its still is shown with the claim). | | `findings` | no | What the evidence shows, in markdown, citing inline: `[label](cite:<id>)`. | | `citations` | no | The citations the claim rests on, in the order they are listed under it. Each must exist; none twice. | +| `slide` | no | The claim's slide — see `slide` below. Absent = derived from the claim. | + +## Slides + +A report is also read as slides — its page's **Slides** view (`?rv=slides`), the **Overview** that pairs each part of the article with its slide (`?rv=iso`), `slides.html` and `slides.pdf` — built by `common/lib/report/slides.ts` from the same view as the article: a title slide, "In brief", "What the check found" (a fact-check), one slide per section and per claim, then a sources slide. With no slide fields a report still has slides, derived from its text: a section shows the first two sentences of its body (else its claims), a claim its verdict, gist and the evidence of its first listed citation. The fields below make them good. A slide's points cite only what the article cites elsewhere, and its `cite` names a citation of its own section or claim. Every slide links back to its place in the article (the section's or the claim's anchor). + +#### `slides` + +| Key | Required | Description | +|---|---|---| +| `hide` | no | `true` publishes no slides: the page offers no Slides or Overview view, and no `slides.html` or `slides.pdf` is exported. | +| `title` | no | The title slide's line under the report's title (plain text, one line, at most 160 characters). Absent = the subtitle. | +| `points` | no | The quick take's slide, as points (at most 5, each one line of at most 140 characters, the label of an inline citation counted; may cite inline a citation the report cites elsewhere). Absent = the summary's first paragraph. | +| `closing` | no | The sources slide's closing line (plain text, one line, at most 160 characters). Absent = none. | + +#### `sections[].slide` and `sections[].claims[].slide` + +| Key | Required | Description | +|---|---|---| +| `title` | no | The slide's title (plain text, one line, at most 160 characters). Absent = the section's title; the claim's title, else its text. | +| `points` | no | The slide's own words, as points (at most 5, each one line of at most 140 characters, the label of an inline citation counted). May cite inline, `[label](cite:<id>)`, a citation the report cites elsewhere. Absent = a section's slide shows the first two sentences of its body (else its claims); a claim's shows its gist. | +| `cite` | no | The evidence the slide shows (a still, a post's capture, a clip's poster, with its quote): a citation of this section (its body or its claims) or of this claim (its sentence, findings or list). Absent = a claim's first listed citation; a section shows none. | +| `layout` | no | How the slide is laid out: `points`, `evidence`, `quote`, `statement`. Absent = `points` for a section; `evidence` for a claim, `statement` when it has no citation to show. `evidence` and `quote` need a citation. | +| `hide` | no | `true` leaves this section's or claim's slide out (a section's claims keep theirs). | ## The verdicts diff --git a/common/lib/report/docs.ts b/common/lib/report/docs.ts @@ -11,11 +11,15 @@ import { CLAIM_FIELD_DOCS, REPORT_FIELD_DOCS, REPORT_FORMAT, + REPORT_SLIDES_FIELD_DOCS, REPORT_VERSION, SECTION_FIELD_DOCS, + SLIDE_FIELD_DOCS, claimSchema, reportSchema, + reportSlidesSchema, sectionSchema, + slideSchema, } from "./schema"; import { VERDICT_DEFAULTS, VERDICT_LABEL_MAX, VERDICTS } from "./verdicts"; @@ -48,8 +52,9 @@ export function renderReportMarkdown(): string { "`common/lib/report/validate.ts` reports every problem with its JSON path: an " + "unknown key, a reference that names nothing (a listed citation, a claim's " + "source sentence, a `cite:` link, the subject), a section or claim id used " + - "twice (they share one namespace: the report page's anchors), a sweep's claim " + - "with a verdict, `updated` before `published`, and every citation problem " + + "twice (they share one namespace: the report page's anchors) or named for one of " + + "the page's own anchors, a sweep's claim with a verdict, `updated` before " + + "`published`, a slide field out of bounds (below), and every citation problem " + "CITATIONS.md lists. Whether a still or the report's video exists, whether the " + "video fits the publish limit, and whether a quote matches its cues are checked " + "when the site is composed.", @@ -61,6 +66,24 @@ export function renderReportMarkdown(): string { renderKeyTable(out, "#### `sections[]`", sectionSchema.shape, SECTION_FIELD_DOCS); renderKeyTable(out, "#### `sections[].claims[]`", claimSchema.shape, CLAIM_FIELD_DOCS); + out.push("## Slides"); + out.push(""); + out.push( + "A report is also read as slides — its page's **Slides** view (`?rv=slides`), the **Overview** " + + "that pairs each part of the article with its slide (`?rv=iso`), `slides.html` and `slides.pdf` " + + "— built by `common/lib/report/slides.ts` from the same view as the article: a title slide, " + + "\"In brief\", \"What the check found\" (a fact-check), one slide per section and per claim, " + + "then a sources slide. With no slide fields a report still has slides, derived from its text: " + + "a section shows the first two sentences of its body (else its claims), a claim its verdict, " + + "gist and the evidence of its first listed citation. The fields below make them good. A " + + "slide's points cite only what the article cites elsewhere, and its `cite` names a citation " + + "of its own section or claim. Every slide links back to its place in the article (the " + + "section's or the claim's anchor).", + ); + out.push(""); + renderKeyTable(out, "#### `slides`", reportSlidesSchema.shape, REPORT_SLIDES_FIELD_DOCS); + renderKeyTable(out, "#### `sections[].slide` and `sections[].claims[].slide`", slideSchema.shape, SLIDE_FIELD_DOCS); + out.push("## The verdicts"); out.push(""); out.push( diff --git a/common/lib/report/report.test.ts b/common/lib/report/report.test.ts @@ -270,3 +270,57 @@ test("a report's video: an mp4 and an image poster, relative to the report, and assert.deepEqual(paths(validateReport(withVideo({ src: "video.mp4", caption: "two\nlines" }))), ["video.caption"]); assert.equal(parseReport(withVideo({ src: "video.mp4", autoplay: true })).ok, false); }); + +test("slides: lines, points, a cite of its own section or claim, a point citing what the article cites", () => { + const f = fixture(); + f.slides = { title: "One line", closing: "Read it on the site.", points: ["One [right](cite:c02)", "One wrong"] }; + f.sections[0].slide = { title: "The chapter", points: ["It cites [the page](cite:w01)"], cite: "c01", layout: "evidence" }; + f.sections[0].claims![0].slide = { cite: "p01", layout: "quote", points: ["Said [here](cite:c01)"] }; + f.sections[0].claims![1].slide = { hide: true }; + assert.deepEqual(validateReport(f), []); + + const g = fixture(); + g.slides = { title: "two\nlines", closing: " ", points: [] }; + g.sections[0].slide = { + title: "x".repeat(161), + points: ["a", "b", "c", "d", "e", "f"], + cite: "zzz", + }; + g.sections[0].claims![0].slide = { + points: ["y".repeat(141), "[never cited](cite:unknown-or-uncited)", "[](cite:)"], + // cited by the other claim, not this one + cite: "c02", + }; + // A claim with no listed evidence, asked to show some. + g.sections[1].claims![0].slide = { layout: "evidence" }; + assert.deepEqual(paths(validateReport(g)), [ + "slides.title", + "slides.closing", + "slides.points", + "sections[0].claims[0].slide.points[0]", + "sections[0].claims[0].slide.points[1]", + "sections[0].claims[0].slide.points[2]", + "sections[0].claims[0].slide.cite", + "sections[0].slide.title", + "sections[0].slide.points", + "sections[0].slide.cite", + "sections[1].claims[0].slide.layout", + ]); + // A point is measured as it reads: a citation counts as its label. + const h = fixture(); + h.sections[0].claims![0].slide = { points: [`${"z".repeat(130)} [label](cite:c01)`] }; + assert.deepEqual(validateReport(h), []); +}); + +test("a section or claim may not take the page's own anchors", () => { + const f = fixture(); + f.sections[1].id = "found"; + f.sections[0].claims![1].id = "report-end"; + assert.deepEqual(paths(validateReport(f)).sort(), ["sections[0].claims[1].id", "sections[1].id"]); +}); + +test("slide fields are shape-checked: an unknown layout or key is refused", () => { + const f = fixture(); + (f.sections[0] as Record<string, unknown>).slide = { layout: "carousel", extra: 1 }; + assert.equal(parseReport(f).ok, false); +}); diff --git a/common/lib/report/schema.ts b/common/lib/report/schema.ts @@ -27,6 +27,7 @@ import { z } from "zod"; import type { FieldDocs } from "../fieldDocs"; import { citationSchema, sourceSchema } from "../citations/schema"; import { VERDICTS, type Verdict } from "./verdicts"; +import { RESERVED_ANCHOR_IDS, SLIDE_LAYOUTS, SLIDE_LINE_MAX, SLIDE_POINT_MAX, SLIDE_POINTS_MAX } from "./slideRules"; export const REPORT_FORMAT = "archilyzer-report"; export const REPORT_VERSION = 1; @@ -42,6 +43,27 @@ export const CLAIM_FLAG_MAX = 60; // A claim's gist is its one line in the overview of what the check found. export const CLAIM_GIST_MAX = 240; +// SLIDES (release 22). An article is also read as slides (lib/report/slides.ts +// buildReportSlides): a title slide, the quick take, what the check found, one +// slide per section and per claim, and a sources slide. With none of these +// fields a report still gets slides, derived from its text; these make them +// good. The limits are ./slideRules.ts's; counts, lengths and references are +// checked by ./validate.ts. +export const slideSchema = z.strictObject({ + title: text.optional(), + points: z.array(text).optional(), + cite: text.optional(), + layout: z.enum(SLIDE_LAYOUTS).optional(), + hide: z.boolean().optional(), +}); + +export const reportSlidesSchema = z.strictObject({ + hide: z.boolean().optional(), + title: text.optional(), + points: z.array(text).optional(), + closing: text.optional(), +}); + export const claimSchema = z.strictObject({ id: text, title: text.optional(), @@ -52,6 +74,7 @@ export const claimSchema = z.strictObject({ sourceQuote: z.strictObject({ citation: text }).optional(), findings: text.optional(), citations: z.array(text).optional(), + slide: slideSchema.optional(), }); export const sectionSchema = z.strictObject({ @@ -59,6 +82,7 @@ export const sectionSchema = z.strictObject({ title: text, body: text.optional(), claims: z.array(claimSchema).optional(), + slide: slideSchema.optional(), }); const verdictOverride = z.strictObject({ label: text.optional(), color: text.optional() }); @@ -81,6 +105,7 @@ export const reportSchema = z.strictObject({ sources: z.record(text, sourceSchema).optional(), citations: z.record(text, citationSchema).optional(), sections: z.array(sectionSchema), + slides: reportSlidesSchema.optional(), }); export type Claim = z.infer<typeof claimSchema>; @@ -88,6 +113,8 @@ export type Section = z.infer<typeof sectionSchema>; export type Report = z.infer<typeof reportSchema>; export type ReportSubject = NonNullable<Report["subject"]>; export type ClaimSourceQuote = NonNullable<Claim["sourceQuote"]>; +export type SlideSpec = z.infer<typeof slideSchema>; +export type ReportSlidesSpec = z.infer<typeof reportSlidesSchema>; // A report id is its directory name under `reports/` and a path segment of its // page (`/reports/<id>/`): a lowercase slug, like a site id. @@ -119,17 +146,35 @@ export const REPORT_FIELD_DOCS: FieldDocs<Report> = { citations: "The report's citations, by id — see [CITATIONS.md](CITATIONS.md). A citation is cited from markdown with `[label](cite:<id>)` and listed under the claims that rest on it. Absent = none.", sections: "The report's sections, in order.", + slides: + "How the report reads as slides (its page's Slides and Overview views, `slides.html`, `slides.pdf`) — see `slides` below. Absent = slides derived from its text.", +}; + +export const REPORT_SLIDES_FIELD_DOCS: FieldDocs<ReportSlidesSpec> = { + hide: "`true` publishes no slides: the page offers no Slides or Overview view, and no `slides.html` or `slides.pdf` is exported.", + title: `The title slide's line under the report's title (plain text, one line, at most ${SLIDE_LINE_MAX} characters). Absent = the subtitle.`, + points: `The quick take's slide, as points (at most ${SLIDE_POINTS_MAX}, each one line of at most ${SLIDE_POINT_MAX} characters, the label of an inline citation counted; may cite inline a citation the report cites elsewhere). Absent = the summary's first paragraph.`, + closing: `The sources slide's closing line (plain text, one line, at most ${SLIDE_LINE_MAX} characters). Absent = none.`, +}; + +export const SLIDE_FIELD_DOCS: FieldDocs<SlideSpec> = { + title: `The slide's title (plain text, one line, at most ${SLIDE_LINE_MAX} characters). Absent = the section's title; the claim's title, else its text.`, + points: `The slide's own words, as points (at most ${SLIDE_POINTS_MAX}, each one line of at most ${SLIDE_POINT_MAX} characters, the label of an inline citation counted). May cite inline, \`[label](cite:<id>)\`, a citation the report cites elsewhere. Absent = a section's slide shows the first two sentences of its body (else its claims); a claim's shows its gist.`, + cite: "The evidence the slide shows (a still, a post's capture, a clip's poster, with its quote): a citation of this section (its body or its claims) or of this claim (its sentence, findings or list). Absent = a claim's first listed citation; a section shows none.", + layout: `How the slide is laid out: ${SLIDE_LAYOUTS.map((l) => `\`${l}\``).join(", ")}. Absent = \`points\` for a section; \`evidence\` for a claim, \`statement\` when it has no citation to show. \`evidence\` and \`quote\` need a citation.`, + hide: "`true` leaves this section's or claim's slide out (a section's claims keep theirs).", }; export const SECTION_FIELD_DOCS: FieldDocs<Section> = { - id: "The section's id (letters, digits, `_ . : -`; at most 64): its anchor on the report page. Unique among the report's section and claim ids.", + id: `The section's id (letters, digits, \`_ . : -\`; at most 64): its anchor on the report page. Unique among the report's section and claim ids, and none of the page's own anchors (${RESERVED_ANCHOR_IDS.map((a) => `\`${a}\``).join(", ")}).`, title: "The section's heading.", body: "Markdown under the heading. May cite inline.", claims: "The section's claims, in order. Absent = none.", + slide: "The section's slide — see `slide` below. Absent = derived from its text.", }; export const CLAIM_FIELD_DOCS: FieldDocs<Claim> = { - id: "The claim's id (letters, digits, `_ . : -`; at most 64): its anchor on the report page. Unique among the report's section and claim ids.", + id: `The claim's id (letters, digits, \`_ . : -\`; at most 64): its anchor on the report page. Unique among the report's section and claim ids, and none of the page's own anchors (${RESERVED_ANCHOR_IDS.map((a) => `\`${a}\``).join(", ")}).`, title: "A short headline for the claim (plain text, e.g. a phrase it turns on), shown above its text. Absent = the text alone.", text: "The claim, as stated by the document under review (plain text).", verdict: `The ruling on the claim: ${VERDICTS.map((v) => `\`${v}\``).join(", ")}. A fact-check's claim may leave it out (not yet ruled); a sweep's carries none.`, @@ -139,4 +184,5 @@ export const CLAIM_FIELD_DOCS: FieldDocs<Claim> = { "The document's own sentence making the claim: `{ \"citation\": \"<id>\" }`, naming a `source` citation (its still is shown with the claim).", findings: "What the evidence shows, in markdown, citing inline: `[label](cite:<id>)`.", citations: "The citations the claim rests on, in the order they are listed under it. Each must exist; none twice.", + slide: "The claim's slide — see `slide` below. Absent = derived from the claim.", }; diff --git a/common/lib/report/slideRules.ts b/common/lib/report/slideRules.ts @@ -0,0 +1,44 @@ +// THE SLIDE RULES — the numbers and names report.json's slide fields +// (./schema.ts) are held to, and the report page's own anchors. Pure, no +// imports: the schema (server-only, zod), the validator, the slide builder +// (./slides.ts) and the browser all read the one copy. + +// How a section's or a claim's slide is laid out (`slide.layout`). +export const SLIDE_LAYOUTS = ["points", "evidence", "quote", "statement"] as const; +export type SlideLayoutName = (typeof SLIDE_LAYOUTS)[number]; + +// A slide shows at most this many points, each at most this long (its visible +// text: an inline citation counts as its label). +export const SLIDE_POINTS_MAX = 5; +export const SLIDE_POINT_MAX = 140; + +// A slide's own title, and the deck's title and closing lines: one line, at +// most this long. +export const SLIDE_LINE_MAX = 160; + +// A section slide with no `points` shows the first sentences of its body; past +// this many characters they no longer read as a slide (umtool's lint says so). +export const SLIDE_DERIVED_TEXT_MAX = 240; + +// THE REPORT PAGE'S OWN ANCHORS — the ids the page gives its parts, beside a +// section's and a claim's own id. A slide's anchor (./slides.ts) is one of +// these or a section or claim id, so a section or claim may not take one +// (./validate.ts): the page would carry two elements with one id. +export const REPORT_PAGE_ANCHOR_IDS = { + // The header: the report's name, dates, the document under review. + head: "report-head", + // The quick take: the tally and the summary. + summary: "in-brief", + // What the check found. + found: "found", + // Every claim, with its evidence. + claims: "claims", + // The reference list. + references: "references", + // The downloads line. + downloads: "downloads", + // Everything after the claims: the documents quoted, the references, the downloads. + end: "report-end", +} as const; + +export const RESERVED_ANCHOR_IDS: readonly string[] = Object.values(REPORT_PAGE_ANCHOR_IDS); diff --git a/common/lib/report/slides.test.ts b/common/lib/report/slides.test.ts @@ -0,0 +1,302 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import type { Report } from "./schema"; +import { REPORT_PAGE_ANCHOR_IDS } from "./slideRules"; +import { + buildReportSlides, + firstParagraph, + firstSentences, + parseReaderPlace, + readerPlaceUrl, + reportPageAnchorIds, + slideAnchorId, + slideForAnchor, + slideLint, + slidePointText, + type ClaimSlide, + type SectionSlide, + type SlideView, +} from "./slides"; +import { validateReport } from "./validate"; +import { buildReportPageView, type ReportPageView } from "./views"; + +// One fact-check, every slide kind: a subject, a summary, two sections (one +// with a body, one without), claims with and without citations and verdicts. +function base(): Report { + return { + format: "archilyzer-report", + version: 1, + id: "demo", + kind: "factcheck", + series: "Demo Checks", + title: "A demo fact-check", + subtitle: "What the article says, against the record", + summary: "The article gets one thing [right](cite:v1). It gets another wrong.\n\nA second paragraph that is never on a slide.", + published: "2026-10-01", + updated: "2026-10-04", + subject: { source: "s0" }, + sources: { + s0: { kind: "article", title: "An article", author: "A. Writer", publisher: "Gazette", date: "2026-09-20", accent: "#8a6fb0" }, + }, + citations: { + v1: { kind: "video", channel: "demo-channel", id: "abc123", start: 10, end: 20, quote: "one" }, + w1: { kind: "page", url: "https://example.org/p", quote: "two" }, + p1: { kind: "post", channel: "demo-social", id: "123", quote: "three" }, + a1: { kind: "source", source: "s0", quote: "the claim, as printed" }, + }, + sections: [ + { + id: "ch1", + title: "Chapter one", + body: "The first sentence, citing [Mr. Example. Esq.](cite:w1). The second sentence! A third that is left off.", + claims: [ + { + id: "k1", + title: "“Opened it himself”", + text: "He opened it himself.", + verdict: "CONTRADICTED", + gist: "It opened a year later.", + sourceQuote: { citation: "a1" }, + findings: "See [the stream](cite:v1).", + citations: ["a1", "v1", "w1"], + }, + { id: "k2", text: "He went back every year.", verdict: "PARTLY", flag: "No source given" }, + ], + }, + { id: "ch2", title: "Chapter two", claims: [{ id: "k3", text: "He denied it.", verdict: "CORROBORATED", citations: ["p1"] }] }, + ], + }; +} + +function viewOf(report: Report): ReportPageView { + assert.deepEqual(validateReport(report), [], "the test report must be sound"); + return buildReportPageView(report, { record: (c) => ({ channel: c.channel, id: c.id }) }); +} + +const keys = (slides: readonly SlideView[]) => slides.map((s) => s.key); +const byKey = <K extends SlideView["kind"]>(slides: readonly SlideView[], key: string) => + slides.find((s) => s.key === key) as Extract<SlideView, { kind: K }>; + +test("the deck, derived: title, in brief, found, each section and claim, sources — numbered from 1", () => { + const slides = buildReportSlides(viewOf(base())); + assert.deepEqual(keys(slides), [ + "title", + "summary", + "found", + "section:ch1", + "claim:k1", + "claim:k2", + "section:ch2", + "claim:k3", + "sources", + ]); + assert.deepEqual(slides.map((s) => s.n), [1, 2, 3, 4, 5, 6, 7, 8, 9]); +}); + +// Every default, one row each: the slide, what it carries. +const DEFAULTS: [string, (s: SlideView[]) => unknown, unknown][] = [ + ["title: series, title, the subtitle as its line, dates, the subject", (s) => { + const t = byKey<"title">(s, "title"); + return [t.layout, t.series, t.title, t.line, t.dates, t.subject]; + }, ["title", "Demo Checks", "A demo fact-check", "What the article says, against the record", ["2026-10-01", "updated 2026-10-04"], + { title: "An article", meta: ["A. Writer", "Gazette", "2026-09-20"], accent: "#8a6fb0" }]], + ["summary: the summary's first paragraph, as a statement, with the tally", (s) => { + const t = byKey<"summary">(s, "summary"); + return [t.layout, t.title, t.text, t.points, t.tally?.map((x) => `${x.verdict}:${x.count}`)]; + }, ["statement", "In brief", "The article gets one thing [right](cite:v1). It gets another wrong.", undefined, + ["CORROBORATED:1", "PARTLY:1", "CONTRADICTED:1"]]], + ["found: the claims by verdict, contradicted first", (s) => { + const t = byKey<"found">(s, "found"); + return [t.layout, t.claimCount, t.groups.map((g) => `${g.verdict}:${g.claims.map((c) => c.title).join("|")}`)]; + }, ["found", 3, ["CONTRADICTED:“Opened it himself”", "PARTLY:He went back every year.", "CORROBORATED:He denied it."]]], + ["section: the body's first two sentences, a cite's label never split", (s) => { + const t = byKey<"section">(s, "section:ch1"); + return [t.layout, t.title, t.index, t.count, t.text, t.points, t.cite, t.claims.map((c) => c.id)]; + }, ["points", "Chapter one", 1, 2, "The first sentence, citing [Mr. Example. Esq.](cite:w1). The second sentence!", undefined, undefined, ["k1", "k2"]]], + ["section with no body: its claims, no text", (s) => { + const t = byKey<"section">(s, "section:ch2"); + return [t.layout, t.text, t.claims]; + }, ["points", undefined, [{ id: "k3", title: "He denied it.", verdict: "CORROBORATED" }]]], + ["claim: its title, its text under it, the first listed citation that is not its sentence", (s) => { + const t = byKey<"claim">(s, "claim:k1"); + return [t.layout, t.title, t.text, t.verdict, t.gist, t.cite, t.sourceQuote, t.section]; + }, ["evidence", "“Opened it himself”", "He opened it himself.", "CONTRADICTED", "It opened a year later.", "v1", "a1", + { id: "ch1", title: "Chapter one" }]], + ["claim with no citation: a statement, its text the title", (s) => { + const t = byKey<"claim">(s, "claim:k2"); + return [t.layout, t.title, t.text, t.flag, t.cite]; + }, ["statement", "He went back every year.", undefined, "No source given", undefined]], + ["sources: the references and documents counted, no closing", (s) => { + const t = byKey<"sources">(s, "sources"); + return [t.layout, t.title, t.references, t.documents, t.closing]; + }, ["sources", "Sources", 4, 1, undefined]], +]; + +for (const [name, pick, want] of DEFAULTS) { + test(`default — ${name}`, () => { + assert.deepEqual(pick(buildReportSlides(viewOf(base()))), want); + }); +} + +// Every override, one row each: the report as changed, what the slide carries. +const OVERRIDES: [string, (r: Report) => void, (s: SlideView[]) => unknown, unknown][] = [ + ["slides.title replaces the subtitle as the title slide's line", (r) => { + r.slides = { title: "One article, checked" }; + }, (s) => byKey<"title">(s, "title").line, "One article, checked"], + ["slides.points make In brief points", (r) => { + r.slides = { points: ["One [right](cite:v1)", "One wrong"] }; + }, (s) => { + const t = byKey<"summary">(s, "summary"); + return [t.layout, t.points, t.text]; + }, ["points", ["One [right](cite:v1)", "One wrong"], undefined]], + ["slides.closing on the sources slide", (r) => { + r.slides = { closing: "Read it all on the site." }; + }, (s) => byKey<"sources">(s, "sources").closing, "Read it all on the site."], + ["section slide: title, points (no derived text), cite and layout", (r) => { + r.sections[0].slide = { title: "The bridge", points: ["It opened in 2019"], cite: "v1", layout: "evidence" }; + }, (s) => { + const t = byKey<"section">(s, "section:ch1"); + return [t.title, t.points, t.text, t.cite, t.layout]; + }, ["The bridge", ["It opened in 2019"], undefined, "v1", "evidence"]], + ["claim slide: cite picks the evidence, layout quote", (r) => { + r.sections[0].claims![0].slide = { cite: "w1", layout: "quote" }; + }, (s) => { + const t = byKey<"claim">(s, "claim:k1"); + return [t.cite, t.layout]; + }, ["w1", "quote"]], + ["claim slide: points and a title; the text stays when the title is not it", (r) => { + r.sections[0].claims![1].slide = { title: "Every year?", points: ["Once is on record"] }; + }, (s) => { + const t = byKey<"claim">(s, "claim:k2"); + return [t.title, t.text, t.points, t.layout]; + }, ["Every year?", "He went back every year.", ["Once is on record"], "statement"]], + ["claim slide: layout points over the derived evidence", (r) => { + r.sections[0].claims![0].slide = { layout: "points", points: ["It opened in 2019"] }; + }, (s) => byKey<"claim">(s, "claim:k1").layout, "points"], +]; + +for (const [name, change, pick, want] of OVERRIDES) { + test(`override — ${name}`, () => { + const r = base(); + change(r); + assert.deepEqual(pick(buildReportSlides(viewOf(r))), want); + }); +} + +test("hide: a section's slide goes, its claims keep theirs; a claim's goes; slides.hide drops the deck", () => { + const r = base(); + r.sections[0].slide = { hide: true }; + r.sections[1].claims![0].slide = { hide: true }; + assert.deepEqual(keys(buildReportSlides(viewOf(r))), ["title", "summary", "found", "claim:k1", "claim:k2", "section:ch2", "sources"]); + r.slides = { hide: true }; + assert.deepEqual(buildReportSlides(viewOf(r)), []); +}); + +test("a sweep: no tally, no found slide; with no summary, no In brief", () => { + const r = base(); + r.kind = "sweep"; + for (const s of r.sections) for (const c of s.claims ?? []) delete c.verdict; + let slides = buildReportSlides(viewOf(r)); + assert.ok(!keys(slides).includes("found")); + assert.equal(byKey<"summary">(slides, "summary").tally, undefined); + delete r.summary; + slides = buildReportSlides(viewOf(r)); + assert.deepEqual(keys(slides).slice(0, 2), ["title", "section:ch1"]); +}); + +test("an evidence layout with nothing to show falls back: a claim to statement, a section to points", () => { + const view = viewOf(base()); + // A view is read leniently in umtool: a layout the validator would refuse still builds. + view.sections[0].slide = { layout: "quote" }; + view.sections[0].claims[1].slide = { layout: "evidence" }; + const slides = buildReportSlides(view); + assert.equal(byKey<"section">(slides, "section:ch1").layout, "points"); + assert.equal(byKey<"claim">(slides, "claim:k2").layout, "statement"); +}); + +test("anchors: unique, each an id the article's page carries, in the page's order", () => { + const view = viewOf(base()); + const slides = buildReportSlides(view); + const ids = slides.map((s) => s.anchorId); + assert.equal(new Set(ids).size, ids.length); + assert.equal(new Set(keys(slides)).size, slides.length); + const page = reportPageAnchorIds(view); + for (const s of slides) { + assert.ok(page.includes(s.anchorId), `${s.key} → #${s.anchorId}`); + assert.equal(slideAnchorId(s.anchor), s.anchorId); + } + const at = ids.map((id) => page.indexOf(id)); + assert.deepEqual(at, [...at].sort((a, b) => a - b)); + assert.deepEqual( + [ids[0], ids[1], ids[2], ids[ids.length - 1]], + [REPORT_PAGE_ANCHOR_IDS.head, REPORT_PAGE_ANCHOR_IDS.summary, REPORT_PAGE_ANCHOR_IDS.found, REPORT_PAGE_ANCHOR_IDS.end], + ); +}); + +test("slideForAnchor: the slide anchored there, else the last before it, else the first", () => { + const r = base(); + r.sections[0].claims![1].slide = { hide: true }; + const view = viewOf(r); + const slides = buildReportSlides(view); + const order = reportPageAnchorIds(view); + assert.equal(slideForAnchor(slides, "k1", order)?.key, "claim:k1"); + // k2's slide is hidden: the slide before it in the article. + assert.equal(slideForAnchor(slides, "k2", order)?.key, "claim:k1"); + // `claims` (the tier heading) has no slide: the found slide is before it. + assert.equal(slideForAnchor(slides, REPORT_PAGE_ANCHOR_IDS.claims, order)?.key, "found"); + assert.equal(slideForAnchor(slides, null, order)?.key, "title"); + assert.equal(slideForAnchor(slides, "nowhere", order)?.key, "title"); + assert.equal(slideForAnchor([], "k1", order), undefined); +}); + +test("text helpers: a point's visible text, the first paragraph, the first sentences", () => { + assert.equal(slidePointText("He said so [on stream](cite:c01), twice."), "He said so on stream, twice."); + assert.equal(firstParagraph("## A heading\n\n- one\n- two\n\nlater"), "A heading"); + assert.equal(firstParagraph("> quoted\n> on two lines\n\nnext"), "quoted on two lines"); + assert.equal(firstParagraph("\n\n"), undefined); + assert.equal(firstSentences("One. Two? Three!", 2), "One. Two?"); + assert.equal(firstSentences("Only one sentence", 2), "Only one sentence"); + assert.equal(firstSentences("He said “no.” Then left. Later.", 2), "He said “no.” Then left."); +}); + +test("lint: long derived text, too many or too long points, a long claim as a title", () => { + const view = viewOf(base()); + assert.deepEqual(slideLint(view), []); + const long = "word ".repeat(60).trim(); + view.sections[1].body = `${long}. ${long}.`; + view.sections[0].slide = { points: ["a", "b", "c", "d", "e", "f", "x".repeat(150)] }; + view.sections[0].claims[1].text = "y".repeat(200); + const lint = slideLint(view).map((l) => `${l.key}: ${l.message.split(";")[0].split(":")[0]}`); + assert.deepEqual(lint, [ + "section:ch1: 7 points", + "section:ch1: point 7 is 150 characters", + "claim:k2: its slide's title is 200 characters (over 160)", + "section:ch2: no slide points", + ]); +}); + +test("the reader's place in the URL: the view, the slide, the anchor; the default stays off", () => { + assert.deepEqual(parseReaderPlace("", ""), { view: "article", slide: null, anchor: null }); + assert.deepEqual(parseReaderPlace("?rv=slides", "#s-4"), { view: "slides", slide: 4, anchor: null }); + assert.deepEqual(parseReaderPlace("?rv=slides", "#k1"), { view: "slides", slide: null, anchor: "k1" }); + assert.deepEqual(parseReaderPlace("?rv=iso", "#ch2"), { view: "iso", slide: null, anchor: "ch2" }); + assert.deepEqual(parseReaderPlace("?rv=other", "#claim-1"), { view: "article", slide: null, anchor: "claim-1" }); + // An article's anchor that looks like a slide is still an anchor there. + assert.deepEqual(parseReaderPlace("", "#s-4"), { view: "article", slide: null, anchor: "s-4" }); + + assert.equal(readerPlaceUrl("/reports/demo/", "", { view: "slides", slide: 3 }), "/reports/demo/?rv=slides#s-3"); + assert.equal(readerPlaceUrl("/reports/demo/", "?rv=slides&x=1", { view: "article", anchor: "k1" }), "/reports/demo/?x=1#k1"); + assert.equal(readerPlaceUrl("/r/", "?rv=slides", { view: "iso", anchor: "ch1" }), "/r/?rv=iso#ch1"); + assert.equal(readerPlaceUrl("/r/", "", { view: "article" }), "/r/"); + const round = parseReaderPlace(...(["?rv=slides", "#s-7"] as const)); + assert.equal(readerPlaceUrl("/r/", "", round), "/r/?rv=slides#s-7"); +}); + +test("a section slide's claims list keeps a hidden claim (it is still in the section)", () => { + const r = base(); + r.sections[0].claims![1].slide = { hide: true }; + const s = byKey<"section">(buildReportSlides(viewOf(r)), "section:ch1") as SectionSlide; + assert.deepEqual(s.claims.map((c) => c.id), ["k1", "k2"]); + const c = byKey<"claim">(buildReportSlides(viewOf(r)), "claim:k1") as ClaimSlide; + assert.equal(c.n, 5); +}); diff --git a/common/lib/report/slides.ts b/common/lib/report/slides.ts @@ -0,0 +1,454 @@ +// 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) +// 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 { + foundGroups, + orderedCitations, + reportDateParts, + subjectMetaParts, + verdictTally, + type ClaimView, + type ReportPageView, + type SectionView, + type VerdictCount, +} from "./views"; + +export type SlideAnchorKind = "head" | "summary" | "found" | "section" | "claim" | "sources"; + +// A place in the article: its kind, and the 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>`. + n: number; + // Unique in the deck: `title`, `summary`, `found`, `section:<id>`, + // `claim:<id>`, `sources`. + key: string; + anchor: SlideAnchor; + // The anchor's element id on the report page (`#<anchorId>`). + 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 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 | FoundSlide | SectionSlide | ClaimSlide | SourcesSlide; + +// A slide before the deck numbers it. +type Unnumbered = SlideView extends infer S ? (S extends SlideView ? Omit<S, "n"> : 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 "section": + case "claim": + return anchor.id ?? ""; + } +} + +// Every element id the report page gives a place (export ReportArticle): its +// own parts, 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]; + 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<ReportPageView, "slides">): 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 sectionSlide(s: SectionView, index: number, count: number): Omit<SectionSlide, "n"> { + 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<ClaimSlide, "n"> { + 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<TitleSlide, "n">); + + 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<SummarySlide, "n">); + } + + 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<FoundSlide, "n">); + } + + 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<SourcesSlide, "n">); + + 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-<n>`, 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 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}`; +} diff --git a/common/lib/report/validate.ts b/common/lib/report/validate.ts @@ -9,7 +9,9 @@ // - the dates are dates, `updated` not before `published`; // - the verdict overrides follow the shared rule (./verdicts.mjs); // - section and claim ids are reference ids, unique together (they are the -// report page's anchors); +// report page's anchors) and none of the page's own (./slideRules.ts); +// - the slide fields are in bounds and cite what the article cites +// (slideProblems, below); // - a sweep's claims carry no verdict; // - every citation and source is sound (lib/citations/validate.ts: ids, // spans, safe paths, URLs, verification); @@ -34,7 +36,9 @@ import { } from "../citations/validate"; import { extractCiteRefs } from "../citations/inline"; import { isRefId } from "../citations/schema"; -import { reportSchema, isReportId, type Report } from "./schema"; +import { reportSchema, isReportId, type Claim, type Report, type SlideSpec } from "./schema"; +import { RESERVED_ANCHOR_IDS, SLIDE_LINE_MAX, SLIDE_POINT_MAX, SLIDE_POINTS_MAX } from "./slideRules"; +import { slidePointText } from "./slides"; import { reportCitationUses } from "./uses"; import { verdictOverrideProblems } from "./verdicts"; @@ -131,7 +135,9 @@ function reportProblems(report: Report, opts: ReportValidateOptions): Problem[] } const first = anchors.get(id); if (first) out.push(problem([...at, "id"], `${JSON.stringify(id)} is already the id of ${first}`)); - else anchors.set(id, jsonPath(at)); + else if (RESERVED_ANCHOR_IDS.includes(id)) { + out.push(problem([...at, "id"], `${JSON.stringify(id)} is the report page's own anchor (${RESERVED_ANCHOR_IDS.join(", ")}); pick another`)); + } else anchors.set(id, jsonPath(at)); }; report.sections.forEach((section, si) => { const sp: PathSegment[] = ["sections", si]; @@ -160,6 +166,8 @@ function reportProblems(report: Report, opts: ReportValidateOptions): Problem[] }); }); + out.push(...slideProblems(report)); + for (const use of reportCitationUses(report)) { const id = use.citationId; if (use.field === "citations" || use.field === "sourceQuote") { @@ -178,6 +186,99 @@ function reportProblems(report: Report, opts: ReportValidateOptions): Problem[] return out; } +// ─── Slides ─── +// +// The slide fields (./schema.ts `slides`, a section's or a claim's `slide`): +// lines are one line and not too long, points are few and short, a `cite` +// names a citation of its own section or claim, a point's inline citation +// names one the report cites elsewhere (a slide shows the article's evidence; +// it never brings its own, which would have no number), and a layout that +// shows evidence has some to show. + +function slideLineProblems(v: string | undefined, at: PathSegment[]): Problem[] { + if (v === undefined) return []; + if (blank(v)) return [problem(at, "must not be blank")]; + if (/[\r\n]/.test(v)) return [problem(at, "must be one line")]; + if (v.length > SLIDE_LINE_MAX) return [problem(at, `is ${v.length} characters; a slide's line is at most ${SLIDE_LINE_MAX}`)]; + return []; +} + +function slidePointsProblems(points: readonly string[] | undefined, at: PathSegment[], cited: ReadonlySet<string>): Problem[] { + if (points === undefined) return []; + const out: Problem[] = []; + if (points.length === 0) out.push(problem(at, "must hold at least one point (leave it out to derive the slide)")); + if (points.length > SLIDE_POINTS_MAX) out.push(problem(at, `holds ${points.length} points; a slide shows at most ${SLIDE_POINTS_MAX}`)); + points.forEach((p, i) => { + const pp = [...at, i]; + if (blank(p)) return void out.push(problem(pp, "must not be blank")); + if (/[\r\n]/.test(p)) out.push(problem(pp, "must be one line")); + const visible = slidePointText(p); + if (visible.length > SLIDE_POINT_MAX) { + out.push(problem(pp, `is ${visible.length} characters; a point is at most ${SLIDE_POINT_MAX}`)); + } + for (const ref of extractCiteRefs(p)) { + if (ref.id === "") out.push(problem(pp, `the link [${ref.label}](cite:) names no citation`)); + else if (!cited.has(ref.id)) { + out.push(problem(pp, `the link [${ref.label}](cite:${ref.id}) names a citation the report does not cite elsewhere (a slide cites what the article cites)`)); + } + } + }); + return out; +} + +function slideSpecProblems( + spec: SlideSpec | undefined, + at: PathSegment[], + own: ReadonlySet<string>, + cited: ReadonlySet<string>, + what: "section" | "claim", + hasDefaultEvidence: boolean, +): Problem[] { + if (!spec) return []; + const out: Problem[] = []; + out.push(...slideLineProblems(spec.title, [...at, "title"])); + out.push(...slidePointsProblems(spec.points, [...at, "points"], cited)); + if (spec.cite !== undefined && !own.has(spec.cite)) { + out.push(problem([...at, "cite"], `names ${JSON.stringify(spec.cite)}, which this ${what} does not cite`)); + } + if ((spec.layout === "evidence" || spec.layout === "quote") && spec.cite === undefined && !hasDefaultEvidence) { + out.push(problem([...at, "layout"], `"${spec.layout}" shows a citation, and this ${what}'s slide has none: name one in \`cite\``)); + } + return out; +} + +// The citations a claim of the document cites: its sentence, its findings' +// inline citations and its list. +function claimCiteIds(claim: Claim): Set<string> { + const ids = new Set<string>(); + if (claim.sourceQuote) ids.add(claim.sourceQuote.citation); + for (const r of extractCiteRefs(claim.findings)) ids.add(r.id); + for (const id of claim.citations ?? []) ids.add(id); + return ids; +} + +function slideProblems(report: Report): Problem[] { + const out: Problem[] = []; + const cited = new Set(reportCitationUses(report).map((u) => u.citationId)); + if (report.slides) { + out.push(...slideLineProblems(report.slides.title, ["slides", "title"])); + out.push(...slideLineProblems(report.slides.closing, ["slides", "closing"])); + out.push(...slidePointsProblems(report.slides.points, ["slides", "points"], cited)); + } + report.sections.forEach((section, si) => { + const sp: PathSegment[] = ["sections", si]; + const sectionIds = new Set(extractCiteRefs(section.body).map((r) => r.id)); + (section.claims ?? []).forEach((claim, ci) => { + const own = claimCiteIds(claim); + for (const id of own) sectionIds.add(id); + const evidence = (claim.citations ?? []).some((id) => id !== claim.sourceQuote?.citation); + out.push(...slideSpecProblems(claim.slide, [...sp, "claims", ci, "slide"], own, cited, "claim", evidence)); + }); + out.push(...slideSpecProblems(section.slide, [...sp, "slide"], sectionIds, cited, "section", false)); + }); + return out; +} + // A report: parsed, and every problem. `ok` means it parsed (its shape is a // report); `problems` may still be non-empty, and a report with problems must // not be published. diff --git a/common/lib/report/views.ts b/common/lib/report/views.ts @@ -55,7 +55,7 @@ import { quoteTokens } from "../citations/verify"; import { formatTimestamp } from "../vtt"; import type { CitedIn } from "./citedIn"; import type { ReportHistoryRef } from "./revisions"; -import type { Claim, Report, ReportKind } from "./schema"; +import type { Claim, Report, ReportKind, ReportSlidesSpec, SlideSpec } from "./schema"; import { reportCitationNumbers } from "./uses"; import { resolveVerdicts, VERDICTS, type Verdict, type VerdictStyle } from "./verdicts"; @@ -255,6 +255,8 @@ export type ClaimView = { archives?: SourceArchive[]; findings?: string; citations: string[]; + // How the claim reads as a slide (report.json `slide`; lib/report/slides.ts). + slide?: SlideSpec; }; export type SectionView = { @@ -262,6 +264,8 @@ export type SectionView = { title: string; body?: string; claims: ClaimView[]; + // How the section reads as a slide (report.json `slide`). + slide?: SlideSpec; }; export type ReportPageView = { @@ -298,6 +302,8 @@ export type ReportPageView = { // Its newest revision and the history page (lib/report/revisions.ts), when // compose published a history. history?: ReportHistoryRef; + // How the report reads as slides (report.json `slides`; lib/report/slides.ts). + slides?: ReportSlidesSpec; }; export type ReportVideoView = { src: string; poster?: string; caption?: string }; @@ -552,6 +558,7 @@ export function buildReportPageView(report: Report, resolve: ReportViewResolver) id: s.id, title: s.title, body: s.body, + slide: s.slide, claims: (s.claims ?? []).map((c) => defined({ id: c.id, @@ -564,12 +571,14 @@ export function buildReportPageView(report: Report, resolve: ReportViewResolver) archives: archivesFor(c), findings: c.findings, citations: c.citations ?? [], + slide: c.slide, }), ), }), ), downloads: resolve.downloads, history: resolve.history, + slides: report.slides, }); } diff --git a/export/fixtures/report-site/public/reports/demo-factcheck/page.json b/export/fixtures/report-site/public/reports/demo-factcheck/page.json @@ -162,6 +162,13 @@ "id": "bridge", "title": "The bridge", "body": "The article's first chapter is about the bridge.", + "slide": { + "points": [ + "The article dates the opening to 2018", + "He says 2019 on stream, and [the city's record](cite:w01) agrees" + ], + "cite": "w01" + }, "claims": [ { "id": "claim-1", @@ -185,7 +192,14 @@ "findings": "He mentions going back [once](cite:au1); nothing in the recordings says every year.", "citations": [ "au1" - ] + ], + "slide": { + "title": "Every year?", + "points": [ + "He mentions going back [once](cite:au1)", + "Nothing in the recordings says every year" + ] + } } ] }, @@ -202,7 +216,11 @@ "findings": "The article [puts it as a habit](cite:a02); he [denies it outright](cite:p01).", "citations": [ "p01" - ] + ], + "slide": { + "cite": "p01", + "layout": "quote" + } }, { "id": "claim-4", @@ -237,5 +255,9 @@ "date": "2026-10-04T12:00:00Z", "href": "/reports/demo-factcheck/history/", "current": true + }, + "slides": { + "title": "Five claims, checked against what was said on air", + "closing": "Every citation opens the moment it quotes." } } diff --git a/export/fixtures/report-site/source/demo-factcheck/report.json b/export/fixtures/report-site/source/demo-factcheck/report.json @@ -11,6 +11,10 @@ "published": "2026-10-01", "updated": "2026-10-04", "subject": { "source": "s0" }, + "slides": { + "title": "Five claims, checked against what was said on air", + "closing": "Every citation opens the moment it quotes." + }, "sources": { "s0": { "kind": "article", @@ -86,6 +90,10 @@ "id": "bridge", "title": "The bridge", "body": "The article's first chapter is about the bridge.", + "slide": { + "points": ["The article dates the opening to 2018", "He says 2019 on stream, and [the city's record](cite:w01) agrees"], + "cite": "w01" + }, "claims": [ { "id": "claim-1", @@ -103,7 +111,8 @@ "verdict": "PARTLY", "gist": "Once is on record; every year is not.", "findings": "He mentions going back [once](cite:au1); nothing in the recordings says every year.", - "citations": ["au1"] + "citations": ["au1"], + "slide": { "title": "Every year?", "points": ["He mentions going back [once](cite:au1)", "Nothing in the recordings says every year"] } } ] }, @@ -118,7 +127,8 @@ "gist": "He denies ever saying it.", "sourceQuote": { "citation": "a02" }, "findings": "The article [puts it as a habit](cite:a02); he [denies it outright](cite:p01).", - "citations": ["p01"] + "citations": ["p01"], + "slide": { "layout": "quote", "cite": "p01" } }, { "id": "claim-4", diff --git a/umtool/lib/articles/article.ts b/umtool/lib/articles/article.ts @@ -179,11 +179,13 @@ export async function articleView(report: Report): Promise<{ view: ReportPageVie sources: {}, verdicts: {}, citations: {}, + ...(report.slides ? { slides: report.slides } : {}), sections: report.sections.map((s) => ({ id: s.id, title: s.title, ...(s.body ? { body: s.body } : {}), - claims: (s.claims ?? []).map((cl) => ({ ...cl, citations: cl.citations ?? [] })), + ...(s.slide ? { slide: s.slide } : {}), + claims: (s.claims ?? []).map((cl) => ({ ...cl, sourceQuote: cl.sourceQuote?.citation, citations: cl.citations ?? [] })), })), } as unknown as ReportPageView; return { view, error: err instanceof Error ? err.message : String(err) };