Archilyzer · Source

archilyzer

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

commit 21f0421be4dce83ac18500225306ebe0a161bd18
parent 5036cb4985d34c0381074fb66f000393b7f2d466
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri,  9 Oct 2026 18:11:23 -0400

export: the timeline's page test, and a changelog line

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

Diffstat:
Mexport/CHANGELOG.md | 1+
Mexport/app/lib/reports.test.ts | 38++++++++++++++++++++++++++++++++++++++
2 files changed, 39 insertions(+), 0 deletions(-)

diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] +- **A report can keep a timeline, and a reader can subscribe to it.** `report.json` `entries` (each `{ "id", "date", "title", "body" }`, optionally `updated`) shows as **Timeline** after In brief: dated entries, newest first, each date linking to the entry's own place on the page (`#<id>`), each body citing as a section's does. The report counts as updated at its newest entry, in its header and the report list. Each entry is a slide too, and the HTML, PDF and Markdown downloads carry the Timeline. On a site with a public URL, the report publishes its timeline as feeds — `feed.xml` (RSS) and `feed.json` (JSON Feed) beside its page — and its header reads **Subscribe: RSS · JSON**; a feed reader finds them from the page too. A site with no public URL publishes no feed. The MCP's `get_report` reads the timeline, and its `section` may name an entry. Needs a rebuild and deploy of each site with reports. - **A report reads as an article, as slides, or in an overview of both.** A report page's header has a switch, **Article · Slides · Overview**. **Slides** shows the report one 16:9 slide at a time, fitted to the screen in the site's accent and light or dark: a title slide, In brief, What the check found, one slide per section and per claim — its verdict, its finding and the evidence it rests on (a clip's still, a post's screenshot, the article's sentence, with its quote) — and a sources slide. ← → (and Page Up, Page Down, Home, End) or a swipe step through them; a citation's number opens its card; every slide has **Read this in the article**, and Esc goes back to the article where the slide stands. On a phone a slide reads top to bottom, its evidence under its words. **Overview** lays the article's parts and their slides side by side on one tilted plane, each part beside its slide: ↑ ↓ walk them, a part opens the article there and a slide opens the slides there; with reduced motion, on a narrow screen, arriving by keyboard or with **Flat**, the same pairs lie flat. Switching keeps your place: the part of the article on screen opens its slide, and back. The view and the place are in the address (`?rv=slides#s-4`, `?rv=iso#<part>`), so a shared link opens the same slide; the article itself is unchanged for search engines and readers without script. A report's author can shape its slides in `report.json` (`slides`, and a section's or claim's `slide`: its own title, up to five short points, which evidence to show, a layout, or hidden); a report without them still has slides, made from its text. Needs a rebuild and deploy of each site with reports. - **A report can be saved as slides.** The download line adds **Slides** (`slides.html`, one file that opens with no network: the slides with their pictures inside, the arrow keys stepping through them, each citation numbered to a reference list) and **Slides PDF** (one page per slide), and the evidence pack carries the slides too. Needs `reports export` (or prepare) and a rebuild and deploy of each site with reports. - **Posts withdrawn from a site leave a stand-in, not a stale copy.** When an X channel's posts stop being published on a site — while X posts are private, say — the site's build ships an empty posts manifest and an empty page at every address they were served from, sent with `Cache-Control: no-store`, so a reader (or the hub) asking for them gets "no posts" instead of the copy Cloudflare's edge kept for up to a week. The hub does the same for every X channel a public site carries. diff --git a/export/app/lib/reports.test.ts b/export/app/lib/reports.test.ts @@ -228,6 +228,44 @@ test("a titled claim with no sentence of the document: its text follows plain, u assert.doesNotMatch(claim, /data-source-sentence|says:|<q[^>]*>He opened/); }); +test("a report's timeline: after In brief, before the claims, newest first, dated, each its anchor; Subscribe and the alternates with feeds", async () => { + const view = fixture.buildFixtureReportView(); + const feeds = { + rss: "https://reports.example.org/reports/demo-factcheck/feed.xml", + json: "https://reports.example.org/reports/demo-factcheck/feed.json", + }; + const entries = [ + { id: "update-2", date: "2026-10-06T09:30:00Z", title: "A second update", body: "More [on stream](cite:c01)." }, + { id: "update-1", date: "2026-10-05", updated: "2026-10-06", title: "A first update", body: "Something new." }, + ]; + const html = render(React.createElement(ReportArticle, { view: { ...view, entries, feeds } })); + const at = (s: string) => html.indexOf(s); + assert.ok(at('id="in-brief"') < at("data-report-timeline")); + assert.ok(at("data-report-timeline") < at('id="found"')); + assert.ok(at('data-entry="update-2"') < at('data-entry="update-1"')); + const newest = html.slice(at('<li id="update-2"'), html.indexOf("</li>", at('<li id="update-2"'))); + assert.match(newest, /<a href="#update-2" data-entry-date=""[^>]*><time dateTime="2026-10-06T09:30:00Z">2026-10-06<\/time><\/a>/); + assert.match(newest, /<h3[^>]*>A second update<\/h3>/); + // Its body cites as a section's does: the citation's number. + assert.match(newest, /on stream<\/a><sup[^>]*><a href="#c-c01" data-cite-number="1"/); + assert.match(html, /updated <time dateTime="2026-10-06">2026-10-06<\/time>/); + // The header: Subscribe, to both feeds. + const header = html.slice(at("<header"), at("</header>")); + assert.match(header, /data-report-feeds=""[^]*?Subscribe:[^]*?<a href="https:\/\/reports\.example\.org\/reports\/demo-factcheck\/feed\.xml" type="application\/rss\+xml" data-feed="rss"/); + assert.match(header, /<a href="https:\/\/reports\.example\.org\/reports\/demo-factcheck\/feed\.json" type="application\/feed\+json" data-feed="json"/); + const { reportMetadata } = await import("./reports"); + assert.deepEqual(reportMetadata({ ...view, entries, feeds }).alternates, { + types: { + "application/rss+xml": [{ url: feeds.rss, title: "Demo Checks: Checking an example article" }], + "application/feed+json": [{ url: feeds.json, title: "Demo Checks: Checking an example article" }], + }, + }); + // No timeline, no feeds: none of it. + const plain = render(React.createElement(ReportArticle, { view })); + assert.doesNotMatch(plain, /data-report-timeline|data-report-feeds/); + assert.equal(reportMetadata(view).alternates, undefined); +}); + test("a video moment: the clip, quote, verification, cue lines, original link, cited in", async () => { const html = render( await momentPage.default({ params: Promise.resolve({ moment: ["demo-channel", "abc123", "3126.00-3151.00"] }) }),