// THE REPORT DOCUMENT — one definition of `report.json` (format // `archilyzer-report`), used by the validator, the compose stage, the export // site's report pages and REPORT.md. // // A report is a cited document: a summary, then sections, each with an // optional markdown body and claims. A FACT-CHECK (`kind: "factcheck"`) is // sections (chapters) of claims, each with a verdict from the shared // vocabulary (./verdicts.mjs) and its findings; a SWEEP (`kind: "sweep"`) is // sections with no verdicts, or bodies with inline citations. Either may keep // a TIMELINE (`entries`): dated, cited blocks appended over time. // // ITS CITATIONS ARE THE CITATION MODEL'S (lib/citations/): the `sources` and // `citations` maps are that model's schemas, cited inline with // `[label](cite:)` and listed per claim. Nothing about a citation is // defined here. // // Lives at `transcripts/sites//reports//report.json`, beside // its `stills/` and `sources//`; a relative path in it (a still, a // saved source) is relative to that directory. // // As in lib/citations/schema.ts, zod checks SHAPE only; every rule about // values — ids, references, `cite:` links, the verdict overrides, dates — is // ./validate.ts's, which reports every problem with its JSON path. // // SERVER-ONLY: zod. A client importer takes the types with `import type`. 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; export const REPORT_KINDS = ["factcheck", "sweep"] as const; export type ReportKind = (typeof REPORT_KINDS)[number]; const text = z.string(); const verdict = z.enum(VERDICTS as unknown as [Verdict, ...Verdict[]]); // A claim's flag is a short mark, shown as a pill on the claim's card. 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(), text, verdict: verdict.optional(), gist: text.max(CLAIM_GIST_MAX).optional(), flag: text.max(CLAIM_FLAG_MAX).optional(), sourceQuote: z.strictObject({ citation: text }).optional(), findings: text.optional(), citations: z.array(text).optional(), slide: slideSchema.optional(), }); export const sectionSchema = z.strictObject({ id: text, title: text, body: text.optional(), claims: z.array(claimSchema).optional(), slide: slideSchema.optional(), }); // THE TIMELINE. A report may carry dated entries — small cited blocks a reader // sees newest first, appended over time (lib/report/entries.ts orders them), // each its own anchor on the page and an item in the report's feeds. export const entrySchema = z.strictObject({ id: text, date: text, title: text, body: text, updated: text.optional(), }); const verdictOverride = z.strictObject({ label: text.optional(), color: text.optional() }); export const reportSchema = z.strictObject({ format: z.literal(REPORT_FORMAT), version: z.literal(REPORT_VERSION), id: text, kind: z.enum(REPORT_KINDS), series: text.optional(), title: text, subtitle: text.optional(), summary: text.optional(), method: text.optional(), published: text.optional(), updated: text.optional(), subject: z.strictObject({ source: text }).optional(), video: z.strictObject({ src: text, poster: text.optional(), caption: text.optional() }).optional(), verdicts: z.partialRecord(verdict, verdictOverride).optional(), sources: z.record(text, sourceSchema).optional(), citations: z.record(text, citationSchema).optional(), sections: z.array(sectionSchema), entries: z.array(entrySchema).optional(), slides: reportSlidesSchema.optional(), }); export type Claim = z.infer; export type Section = z.infer; export type Report = z.infer; export type ReportEntry = z.infer; export type ReportSubject = NonNullable; export type ClaimSourceQuote = NonNullable; export type SlideSpec = z.infer; export type ReportSlidesSpec = z.infer; // A report id is its directory name under `reports/` and a path segment of its // page (`/reports//`): a lowercase slug, like a site id. export const REPORT_ID_RE = /^[a-z0-9][a-z0-9-]{0,63}$/; export function isReportId(v: unknown): v is string { return typeof v === "string" && REPORT_ID_RE.test(v); } export const REPORT_FIELD_DOCS: FieldDocs = { format: `\`"${REPORT_FORMAT}"\`.`, version: `\`${REPORT_VERSION}\`.`, id: "The report's id: a lowercase slug (`[a-z0-9][a-z0-9-]*`, at most 64), its directory name under `reports/` and the last segment of its page, `/reports//`. Must match the directory.", kind: '`"factcheck"` — sections of claims, each with a verdict — or `"sweep"` — sections with no verdicts, or bodies with inline citations.', series: "The series the report belongs to: a recurring name for its kind of report. It is shown on its own line above the title, in the accent colour, at the head of the report's page, its exports and the report list (where a report with no series shows its kind instead), and is joined to the title as `: ` where one line names the report (a page title, a link, a cited-in entry).", title: "The report's title.", subtitle: "A line under the title.", summary: "The report's summary, in markdown, shown before the sections. May cite inline: `[label](cite:<id>)`.", method: "How the report was checked, in markdown (short; it cites nothing): shown under \"How it was checked\" at the start of the claims in full.", published: "When the report was published: `YYYY-MM-DD` or an ISO 8601 date-time with a zone.", updated: "When it was last changed, in the same form; not before `published`. A report with a timeline is shown as updated at its newest entry's `date` or `updated` when that is later.", subject: "The document under review, when the report reviews one: `{ \"source\": \"<id>\" }`, an id in `sources`.", video: "A video of the report, shown at the head of its page under the title: `{ \"src\": \"video.mp4\", \"poster\": \"poster.jpg\", \"caption\": \"…\" }`. `src` is an mp4 and `poster` an image (png, jpg or webp), both relative to the report's directory; the caption is one line. Absent = none.", verdicts: `Overrides of the shared verdict vocabulary's labels and colours, by verdict (${VERDICTS.map((v) => `\`${v}\``).join(", ")}): \`{ "label": "…", "color": "#rrggbb" }\`, each key optional. Absent = the shared defaults.`, sources: "The documents the report's `source` citations quote, by id — see [CITATIONS.md](CITATIONS.md). Absent = none.", 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.", entries: "The report's timeline: dated entries, appended over time and shown newest first under \"Timeline\", before the claims — see `entries[]` below. With entries and a public `siteUrl`, the report publishes feeds of them (`feed.xml`, RSS 2.0; `feed.json`, JSON Feed 1.1). Absent = none.", 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, claim and entry 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, claim and entry 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.`, gist: `The claim's finding in one line (plain text, at most ${CLAIM_GIST_MAX} characters), shown beside its title in the overview of what the check found. Absent = the title alone.`, flag: `A short mark on the claim (plain text, one line, at most ${CLAIM_FLAG_MAX} characters), shown as a pill on its card: e.g. that the document gives no source for it. Absent = none.`, sourceQuote: "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.", }; export const ENTRY_FIELD_DOCS: FieldDocs<ReportEntry> = { id: `The entry's id (letters, digits, \`_ . : -\`; at most 64): its anchor on the report page and its permalink in the feeds. Unique among the report's section, claim and entry ids, and none of the page's own anchors (${RESERVED_ANCHOR_IDS.map((a) => `\`${a}\``).join(", ")}).`, date: "When the entry was added: `YYYY-MM-DD` or an ISO 8601 date-time with a zone. The timeline is newest first by this date; entries of the same instant keep their order here.", title: "The entry's heading (plain text, one line).", body: "The entry, in markdown. May cite inline, `[label](cite:<id>)`, as a section's body does; its citations are numbered with the rest, in the page's order (the timeline comes after the summary).", updated: "When the entry was last changed, in the same form; not before its `date`. Absent = never.", };