commit 98ea87ea99cd0d202fe9f52e8243ddb5f195eca5
parent 688e17cceb9f4d63108977c7c11a0207de5833c1
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 5 Oct 2026 14:03:25 -0400
docs: report exports — plans/report-sites.md "Exports", PUBLISH.md, changelogs
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
4 files changed, 54 insertions(+), 2 deletions(-)
diff --git a/PUBLISH.md b/PUBLISH.md
@@ -53,7 +53,8 @@ entry points in `common/publish/build.ts`.
| The hub | /sites → Hub → **Build hub** / **Deploy hub** | `build-hub`, `deploy-hub` | `build hub`, `deploy hub [--preview <branch>]` |
| The homepage | /sites → Homepage → **Build homepage** (tick *Deploy after build*) / **Deploy homepage**, with an optional preview branch | `build-homepage` (`{"deploy":true}` to deploy after), `deploy-homepage` (`{"preview":"<branch>"}`) | `build homepage [--no-source]`, `deploy homepage [--preview <branch>]` |
| The source mirror alone | — (every homepage build runs it) | — | `source publish [--force] [--check] [--keep-scratch]`, `source audit [<git dir>]` |
-| A site's report evidence media | — (a Reports tab is to come) | `reports-prepare` (`{"siteId"}`) | `reports prepare <id>` |
+| A site's report evidence media (then its exports) | a site's **Reports** tab → *Prepare evidence media* | `reports-prepare` (`{"siteId"}`) | `reports prepare <id>` |
+| A site's report exports (HTML, PDF, Markdown, evidence pack) | a site's **Reports** tab → *Export reports* | `reports-export` (`{"siteId", "reportId"?, "formats"?}`) | `reports export <id> [--report <rid>] [--formats html,pdf,md,zip]` |
| A report from a /sweep report, an /ask answer or a report-to-video manifest, and a starter manifest from a report | — | — | `reports convert <sweep\|ask\|manifest> <in> --out <report.json> [--channels-dir <dir>]`, `reports to-manifest <report.json> --out <manifest.json>` |
`pnpm archilyzer <command>` is the short form of
@@ -74,9 +75,25 @@ is not on disk, a clip over 24 MiB or an invalid report is listed and fails the
(exit 1, or a failed job) — fetch the window or persist the video, capture the
post, and run it again; what is already cut is reused.
+When nothing is missing, prepare ends by **exporting** the reports (`reports export`
+runs the same step alone): each published report is written, as compose would
+resolve it, to `.export-index/sites/<id>/report-exports/<report>/` as `report.html`
+(one self-contained file — inline style, no script, its stills and post screenshots
+inlined; clips linked on the site), `report.pdf` (that page printed by headless
+Chromium; skipped with a note on a host without Playwright's browser), `report.md`
+(plain Markdown with numbered references) and `evidence-pack.zip` (the page with its
+clips, stills and screenshots as files, so the clips play offline, plus the Markdown
+and the citations; packed by the system `zip`, which a host must have), with an
+`export.json` naming each file's size and checksum and the hash of the report.json it
+was made from. Every export ends with a footer naming the report's date and the first
+12 characters of that hash.
+
The build's compose then writes the reports from what prepare left
(`common/publish/composeReports.ts`): each report's page, its citations as
-`citations.json` and `citations.csv`, its cited stills (never a saved source copy),
+`citations.json` and `citations.csv`, its exports (`report.html`, `report.pdf`,
+`report.md`, `evidence-pack.zip` — only those made from the report.json as it is now,
+and only a file of at most 24 MiB: a larger pack stays on the host; the page's
+download line lists what was published), its cited stills (never a saved source copy),
one page per cited moment with the transcript lines around it, and the prepared clips
and captures — only those the published reports cite. Every quote is checked as it is
composed: a span's against its cues within 5 s either side (a record whose `en` track
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
+- **A site's reports can be exported as files a reader saves and hosts again.** `archilyzer reports export <site> [--report <id>] [--formats html,pdf,md,zip]`, the `reports-export` job (`POST /api/ops/reports-export`, `pnpm ops reports-export`, and **Export reports** on a site's Reports tab) write each published report, checked as the build checks it, into `.export-index/sites/<site>/report-exports/<report>/`: `report.html`, one self-contained page (its own style, no script, stills and post screenshots inlined and recompressed, clips linked on the site); `report.pdf`, that page printed by headless Chromium, skipped with a note where there is none; `report.md`, plain Markdown with numbered references; and `evidence-pack.zip`, the page with its clips, stills and screenshots as files plus the Markdown and the citations, packed by the system `zip` (a host without it fails that format, naming it). An `export.json` names each file's size and checksum and the checksum of the report.json it was made from; every export ends with the report's date and the start of that checksum. Preparing the evidence media now exports at its end when nothing is missing, on the same queue. The build publishes an export beside the report only when it was made from the report as it is now and is at most 24 MiB — a larger evidence pack stays local — and the Reports tab lists each report's exports, their sizes and which the next build publishes. The 24 MiB limit the source mirror and the evidence clips already kept is now one shared number.
- **A report video's cue lookup names a site that publishes only its reports.** Pointing a report-to-video manifest at such a site (`corpus.json` `site.scope: "cited"`) used to fail with "channel … is not in corpus.json"; it now says the site publishes no transcripts and to use a full archive or a local corpus. The site form's **Publish** hint says a cited-only site is never listed on the homepage or the hub.
- **A site's build composes its reports, and a site that publishes only its reports ships nothing else.** Every site's compose now writes the reports its `site.json` publishes: each report's page and its citations as `citations.json` and `citations.csv` under `/reports/<id>/`, its cited stills, a page per cited moment with the record, the transcript lines around the span and every report that cites it, and the clips and post captures `archilyzer reports prepare` made for it, only the cited ones. Each quote is checked against the record as it is composed (a span's against its cues within 5 s either side, read from `en-orig` when the `en` track has no cues; a post's against its text) and the score, time and method are written into the citation, replacing any typed by hand. The build stops with the list of every problem before anything is written: an invalid report, a citation of a channel outside the site or of a post the site may not carry, a missing record, still or post, a quote that matches less than 60 % of what the record says, and a citation without prepared media or with media cut for another span (`--allow-missing-media` on `archilyzer compose site` and `build site` lets those two through, without a clip). A site with `publish: "cited"` removes everything corpus-shaped from `export/public` before it writes its reports, and its built `out/` is checked against what a cited site may hold: anything else, a file over 25 MiB or more than 20,000 files fails the build, and every deploy path (the Publish tab, `deploy site`, Build & deploy, Build & deploy all, the container build) refuses it, as it refuses a site set to cited whose last build was a full one. The hub's compose removes a report site's files too.
- **A site has a Reports tab.** `/sites/<site>/reports` lists every report under the site's `reports/` directory — the published ones in their order, then the drafts — with its kind, dates, sections, claims, citations by kind and, for a fact-check, how many claims carry each verdict. Each report's problems, from the same checker the prepare step and the build use, open under it. A draft with no problems can be published, and a published report moved up or down or unpublished; each writes only the site's `reports` list, applied to the list as it is on disk at that moment, so it never overwrites another change to the site. "Prepare evidence media" queues the `reports-prepare` job, and beside it the tab shows the last prepared media (moments by kind, total size, problems by kind) and links the last prepare job. What the site publishes (full or cited) is shown with a link to Settings, where it is changed.
diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
+- **A report can be saved whole: as one HTML page, a PDF, Markdown, or an evidence pack.** A report page's download line now reads HTML · PDF · Markdown · Evidence pack · Citations JSON · CSV, each listed only when the site publishes it. The HTML is one file that opens with no network: the report with its verdicts, the document's sentences and the post screenshots inside it, numbered citations, and a reference list giving each quote's speaker, date, record, the original at its time and the moment page on the site. The PDF is that page printed. The Markdown is the same report as plain text with numbered references. The evidence pack is a zip of the page with its clips, stills and screenshots beside it, so the clips play offline. Each ends with a line naming the report's date and the start of its checksum. Needs `reports export` (or prepare) and a rebuild and deploy of each site with reports.
- **A report-only site with one report opens on that report.** Its home page is the report itself, its header links nothing, and `/reports/` forwards home: there is no index of one. With more reports the home page is the list, without repeating the site's title under the header; a list entry is the report's name, subtitle and dates (its counts and tally are on its page). Pages a report-only site does not have link home.
- **A report can belong to a series.** `report.json` `series` leads the report's title in the accent colour, in place of the kind's label ("Fact-check"); a page title, a cited-in link, `llms.txt` and the MCP name it `<series>: <title>`.
- **`pnpm start:export` serves a built site's moment pages.** It used `serve`, which listed a video or audio moment's directory (`3126.00-3151.00`) instead of serving its page; it now runs `export/scripts/serve-out.mjs`, which serves directories as Cloudflare Pages does, on `EXPORT_DEV_PORT` (3000).
diff --git a/plans/report-sites.md b/plans/report-sites.md
@@ -124,6 +124,39 @@ site with search off is report-only — what `publish: "cited"` was. A legacy `p
- The existing guard test that forbids publish code from naming `posts-media` is amended to allow exactly the
one module that copies CITED captures, with a test that it copies nothing else.
+## Exports
+
+A report must survive a takedown as files anyone can save and host again (slice RX).
+
+- `archilyzer reports export <siteId> [--report <id>] [--formats html,pdf,md,zip] [--allow-missing-media]`
+ and the editor's `reports-export` job (prepare's queue; Reports tab → **Export reports**) run
+ `common/publish/reportExports.ts`; `reports prepare` runs it at its end when nothing is missing. It resolves
+ the reports exactly as compose does (`resolveSiteReports`) and writes, per report, to
+ `.export-index/sites/<id>/report-exports/<reportId>/`:
+ - `report.html` — ONE file from `common/lib/report/exportHtml.ts` (pure view → HTML): inline CSS, no script;
+ stills and post screenshots recompressed on the host (WebP, else JPEG, ≤ 1200 px wide) into data URIs; clips
+ linked on the site, never inlined. The heading mirrors the site: the series on its own line in the accent,
+ the title with the reviewed document's byline inline, "Fact-check by <site title>", the dates, the subtitle.
+ Then the tally, sections → claims (verdict chip, the document's sentence as its still, findings with `[n]`
+ markers), the documents quoted with their archive links, and the numbered references (quote, speaker, date,
+ record, the original at its time, archive links, the moment page and clip when the site has a `siteUrl`).
+ - `report.pdf` — that HTML printed by headless Chromium (`importPlaywright`, A4); skipped with a note where
+ Playwright or its browser is missing, never a failure.
+ - `report.md` — plain Markdown (`exportMarkdown.ts`), `[label](cite:id)` → `label [n]`, numbered references.
+ - `evidence-pack.zip` — `<reportId>/report.html` playing its own `media/` (clips, stills, screenshots),
+ `report.md`, `citations.json`/`.csv`; packed by the system `zip` with sorted names, fixed times and modes
+ (the same report checked at the same time packs to the same bytes). No `zip` fails that format, naming it.
+ - `export.json` — each file's size and sha256, the sha256 of the report.json it was made from, the footer,
+ notes. Each run replaces the directory: nothing is left from another version.
+- Every export ends with the footer `Revision N · <date> · report sha256 <first 12> · commit <short>`; an unknown
+ part is left out. Today: the report's date and hash. The revision history (slice RH) fills `revision` and
+ `commit` in ONE place, `exportFooterFor` (`reportExports.ts`).
+- Compose (`publishableReportExports`, `common/publish/reportExportFiles.ts`) copies `report.html`, `report.pdf`,
+ `report.md` and the pack into `public/reports/<id>/` only when made from the report.json as it is now and each
+ is within the shared publish limit (`PUBLISH_MAX_FILE_BYTES`, 24 MiB, `lib/builtExport.ts`); a pack over it
+ stays local. The view's `downloads` lists what was published and the report page's download line shows HTML ·
+ PDF · Markdown · Evidence pack · Citations JSON · CSV. `reports/` is allowed wholesale by the cited audit.
+
## Compose and the contract
- `compose-site` gains a reports stage: resolve citations against the shared transcript trees, verify quotes,