Archilyzer · Source

archilyzer

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

commit 3249397137d83010f00c82fe2c8b4cf575f1e548
parent 21f0421be4dce83ac18500225306ebe0a161bd18
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri,  9 Oct 2026 18:37:20 -0400

export: the report fixture keeps a timeline; timeline.spec; serve-out applies _headers

The fixture report gains two entries (2026-10-02, updated 2026-10-04, and
2026-10-04), each citing only c01, so no citation number and no header date
moves; its view names the feeds on the fixture site's URL and
fixtures/report-site/write.ts rewrote page.json and c01's moment. The e2e
stage writes feed.xml and feed.json as compose would (contract.ts).
scripts/serve-out.mjs applies the bundle's _headers as Pages does (literal,
splat and :placeholder paths), so a spec sees the feeds' media types and CORS.
e2e-report/timeline.spec.ts: the Timeline's place, order, dates and anchors,
Subscribe and the alternates, both feeds served and their items, an entry's
slide, a phone. slides.spec and overview.spec renumbered for the two entry
slides (11 → 13).

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

Diffstat:
Mexport/app/lib/reports.test.ts | 23++++++++++++++++++-----
Mexport/e2e-report/contract.ts | 17+++++++++++++++--
Mexport/e2e-report/overview.spec.ts | 6+++---
Mexport/e2e-report/slides.spec.ts | 58++++++++++++++++++++++++++++++++--------------------------
Aexport/e2e-report/timeline.spec.ts | 113+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mexport/fixtures/report-site/fixture.ts | 7+++++++
Mexport/fixtures/report-site/public/m/demo-channel/abc123/3126.00-3151.00/moment.json | 22++++++++++++++++++++++
Mexport/fixtures/report-site/public/reports/demo-factcheck/page.json | 19+++++++++++++++++++
Mexport/fixtures/report-site/source/demo-factcheck/report.json | 15+++++++++++++++
Mexport/scripts/serve-out.mjs | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
10 files changed, 300 insertions(+), 40 deletions(-)

diff --git a/export/app/lib/reports.test.ts b/export/app/lib/reports.test.ts @@ -83,7 +83,8 @@ test("the report page: header, tally, sections and claims, inline cites, referen const params = Promise.resolve({ reportId: "demo-factcheck" }); const html = render(await reportPage.default({ params })); // the header: the series on one line and the title on the next, one muted - // line of dates and the revision linked to its history, the document under + // line of dates and the revision linked to its history, its timeline's + // feeds (the fixture keeps a timeline), the document under // review as a card, the subtitle const header = html.slice(html.indexOf("<header"), html.indexOf("</header>")); assert.match( @@ -92,7 +93,7 @@ test("the report page: header, tally, sections and claims, inline cites, referen ); assert.match( header, - /<\/h1><p data-report-dates=""[^>]*>2026-10-01 · updated 2026-10-04 · <a href="\/reports\/demo-factcheck\/history\/" data-report-revision="2"[^>]*>revision 2<\/a><\/p><div data-report-views=""[^>]*><div role="group" aria-label="Read as" data-view-switch="article"[^]*?<\/div><\/div><div id="source-s0" data-subject-source="s0"/, + /<\/h1><p data-report-dates=""[^>]*>2026-10-01 · updated 2026-10-04 · <a href="\/reports\/demo-factcheck\/history\/" data-report-revision="2"[^>]*>revision 2<\/a><\/p><p data-report-feeds=""[^>]*>[^]*?Subscribe:[^]*?<\/p><div data-report-views=""[^>]*><div role="group" aria-label="Read as" data-view-switch="article"[^]*?<\/div><\/div><div id="source-s0" data-subject-source="s0"/, ); // the view switch (release 22): Article pressed, then Slides and Overview assert.deepEqual( @@ -175,7 +176,8 @@ test("the report page: header, tally, sections and claims, inline cites, referen assert.doesNotMatch(html, />\s*\d+ min\s*<|\d+ minutes?\b/, "no reading time"); assert.equal([...tier2.matchAll(/data-depth-dot="filled"/g)].length, 2); assert.match(tier1, /data-verdict-tally=""[^]*?The article makes four claims/); - const jumps = [...tier1.slice(tier1.indexOf("data-report-jumps")).matchAll(/<a href="([^"]+)"[^>]*>([^<]+)<\/a>/g)].map((m) => `${m[1]} ${m[2]}`); + const jumpsAt = tier1.indexOf("data-report-jumps"); + const jumps = [...tier1.slice(jumpsAt, tier1.indexOf("</p>", jumpsAt)).matchAll(/<a href="([^"]+)"[^>]*>([^<]+)<\/a>/g)].map((m) => `${m[1]} ${m[2]}`); assert.deepEqual(jumps, ["#found What the check found", "#claims Every claim", "#downloads Downloads"]); assert.match(html, /id="downloads"/); // what the check found: groups in a fixed order, every row a link to its claim @@ -228,6 +230,16 @@ 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("the fixture's own timeline: newest first, its feeds on the site's URL", () => { + const view = fixture.buildFixtureReportView(); + assert.deepEqual(view.entries?.map((e) => e.id), ["update-mail", "update-opening"]); + assert.equal(view.updated, "2026-10-04"); + assert.deepEqual(view.feeds, { + rss: `${fixture.FIXTURE_SITE_URL}/reports/demo-factcheck/feed.xml`, + json: `${fixture.FIXTURE_SITE_URL}/reports/demo-factcheck/feed.json`, + }); +}); + 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 = { @@ -261,9 +273,10 @@ test("a report's timeline: after In brief, before the claims, newest first, date }, }); // No timeline, no feeds: none of it. - const plain = render(React.createElement(ReportArticle, { view })); + const bare = { ...view, entries: undefined, feeds: undefined }; + const plain = render(React.createElement(ReportArticle, { view: bare })); assert.doesNotMatch(plain, /data-report-timeline|data-report-feeds/); - assert.equal(reportMetadata(view).alternates, undefined); + assert.equal(reportMetadata(bare).alternates, undefined); }); test("a video moment: the clip, quote, verification, cue lines, original link, cited in", async () => { diff --git a/export/e2e-report/contract.ts b/export/e2e-report/contract.ts @@ -1,6 +1,7 @@ // The cited fixture site's compose step (stage.ts runs it, in a child process // with the stage's environment): what compose's reports stage writes beside -// the views — the citation downloads, the report's exports (HTML, PDF, +// the views — the citation downloads, its timeline's feeds (lib/report/feeds.ts, +// on the site's public URL), the report's exports (HTML, PDF, // Markdown, evidence pack, by publish/reportExports.ts's writer), its two // revisions (fixture.ts FIXTURE_REVISIONS, committed by that writer into a // store in the stage) and their published history (history.json and the @@ -20,7 +21,9 @@ const { getPaths } = await import("yt-dlp-transcript-common/lib/paths"); const { getSite } = await import("yt-dlp-transcript-common/lib/site"); const { emitAiFiles, emitFederationFiles } = await import("yt-dlp-transcript-common/bin/compose-site"); const { citationSet, citationsCsv } = await import("yt-dlp-transcript-common/publish/composeReports"); -const { MOMENTS_INDEX_PATH, REPORTS_INDEX_PATH, reportCitationsDownloadPath, reportViewPath } = await import( +const { renderReportJsonFeed, renderReportRss, reportFeedUrls } = await import("yt-dlp-transcript-common/lib/report/feeds"); +const { parseSiteUrl } = await import("yt-dlp-transcript-common/lib/siteSchema"); +const { MOMENTS_INDEX_PATH, REPORTS_INDEX_PATH, reportCitationsDownloadPath, reportFeedPath, reportViewPath } = await import( "yt-dlp-transcript-common/lib/report/views" ); const { FIXTURE_REVISIONS, buildFixtureReportView, fixtureReportFile, readFixtureReport } = await import( @@ -54,6 +57,16 @@ for (const entry of index.reports) { ); fs.writeFileSync(pub(reportCitationsDownloadPath(entry.id, "csv")), citationsCsv(view)); + // Its timeline's feeds, as compose writes them: on the site's public URL, + // which the view must already name (fixture.ts FIXTURE_SITE_URL). + const siteUrl = parseSiteUrl(site.siteUrl); + if (!siteUrl) throw new Error("contract.ts: the fixture site has no public siteUrl"); + if (JSON.stringify(view.feeds) !== JSON.stringify(reportFeedUrls(siteUrl, entry.id))) { + throw new Error(`contract.ts: the view's feeds ${JSON.stringify(view.feeds)} are not the site's (${siteUrl})`); + } + fs.writeFileSync(pub(reportFeedPath(entry.id, "rss")), renderReportRss(view, { siteUrl })); + fs.writeFileSync(pub(reportFeedPath(entry.id, "json")), `${JSON.stringify(renderReportJsonFeed(view, { siteUrl }), null, 2)}\n`); + // The report's exports, by the host step's own writer, from the files // already in public/ (its stills, the post's shot, the clips), published // where compose publishes them. Each earlier revision is exported first (its diff --git a/export/e2e-report/overview.spec.ts b/export/e2e-report/overview.spec.ts @@ -18,7 +18,7 @@ test("overview: each part beside its slide; arrows walk the pairs; a card opens await expect(page).toHaveURL(`${REPORT}?rv=iso#bridge`); const ov = overview(page); await expect(ov).toHaveAttribute("aria-roledescription", "overview"); - await expect(ov.locator("[data-pair]")).toHaveCount(11); + await expect(ov.locator("[data-pair]")).toHaveCount(13); await expect(ov.locator('[data-pair="section:bridge"]')).toHaveAttribute("data-active", "true"); await ov.locator('[data-pair="section:bridge"] [data-ov-card]').focus(); await page.keyboard.press("ArrowDown"); @@ -32,7 +32,7 @@ test("overview: each part beside its slide; arrows walk the pairs; a card opens await expect(ov.locator('[data-ov-block][tabindex="0"], [data-ov-card][tabindex="0"]')).toHaveCount(2); await page.keyboard.press("ArrowRight"); await page.keyboard.press("Enter"); - await expect(page).toHaveURL(`${REPORT}?rv=slides#s-6`); + await expect(page).toHaveURL(`${REPORT}?rv=slides#s-8`); await expect(stage(page).locator('[data-slide="claim:claim-2"]')).toBeVisible(); await page.goBack(); @@ -67,7 +67,7 @@ test("overview: tilted for a pointer; flat for reduced motion; the Flat toggle l test("a phone: the overview lies flat, each part stacked over its slide; nothing scrolls sideways", async ({ page }) => { await page.setViewportSize({ width: 375, height: 800 }); - await page.goto(`${REPORT}?rv=slides#s-5`); + await page.goto(`${REPORT}?rv=slides#s-7`); await page.locator("[data-reader-overlay] [data-view-switch]").getByRole("button", { name: "Overview" }).click(); await expect(page).toHaveURL(`${REPORT}?rv=iso#claim-1`); await expect(overview(page)).toHaveAttribute("data-report-overview", "flat"); diff --git a/export/e2e-report/slides.spec.ts b/export/e2e-report/slides.spec.ts @@ -6,12 +6,14 @@ import type { Page } from "@playwright/test"; // (stage.ts). The // fixture's deck, from its report.json and its slide fields: // -// 1 title 2 In brief 3 What the check found -// 4 The bridge (points, the city's record as its evidence) -// 5 claim-1 (evidence: the stream) 6 claim-2 ("Every year?", points) -// 7 Moving away (no body: its claims) -// 8 claim-3 (quote: the post) 9 claim-4 (statement) 10 claim-5 -// 11 sources (the closing line) +// 1 title 2 In brief +// 3 update-mail 4 update-opening (the timeline, newest first: timeline.spec.ts) +// 5 What the check found +// 6 The bridge (points, the city's record as its evidence) +// 7 claim-1 (evidence: the stream) 8 claim-2 ("Every year?", points) +// 9 Moving away (no body: its claims) +// 10 claim-3 (quote: the post) 11 claim-4 (statement) 12 claim-5 +// 13 sources (the closing line) // // Every test also holds the page's requests to the cited promise (helpers.ts): // the slides are built from the report's own page.json, nothing else. @@ -29,7 +31,7 @@ test("the switch: Article pressed in the header; every place a slide stands for await expect(sw.getByRole("button")).toHaveText(["Article", "Slides", "Overview"]); await expect(sw.getByRole("button", { name: "Article" })).toHaveAttribute("aria-pressed", "true"); await expect(sw.getByRole("button", { name: "Slides" })).toHaveAttribute("aria-pressed", "false"); - for (const id of ["report-head", "in-brief", "found", "bridge", "claim-1", "claim-2", "moving", "claim-3", "claim-4", "claim-5", "report-end"]) { + for (const id of ["report-head", "in-brief", "update-mail", "update-opening", "found", "bridge", "claim-1", "claim-2", "moving", "claim-3", "claim-4", "claim-5", "report-end"]) { await expect(page.locator(`[id="${id}"]`), id).toHaveCount(1); } }); @@ -38,14 +40,14 @@ test("slides: the switch opens the slide for the part in view, and Esc goes back await page.goto(`${REPORT}#claim-3`); await expect(page.locator("article#claim-3")).toBeInViewport(); await switchIn(page, "header").getByRole("button", { name: "Slides" }).click(); - await expect(page).toHaveURL(`${REPORT}?rv=slides#s-8`); - await expect(stage(page)).toHaveAttribute("data-report-slides", "8"); + await expect(page).toHaveURL(`${REPORT}?rv=slides#s-10`); + await expect(stage(page)).toHaveAttribute("data-report-slides", "10"); await expect(stage(page).locator('[data-slide="claim:claim-3"]')).toBeVisible(); // A region with a live label; the focus is in it; the article behind is inert. await expect(stage(page)).toHaveAttribute("aria-roledescription", "slides"); await expect(stage(page)).toBeFocused(); await expect(page.locator("[data-slide-label]")).toHaveAttribute("aria-live", "polite"); - await expect(page.locator("[data-slide-label]")).toContainText("Slide 8 of 11: Claim — He has said many times that he would move away."); + await expect(page.locator("[data-slide-label]")).toContainText("Slide 10 of 13: Claim — He has said many times that he would move away."); await expect(page.locator("main")).toHaveAttribute("inert", ""); await expect(switchIn(page, "overlay").getByRole("button", { name: "Slides" })).toHaveAttribute("aria-pressed", "true"); @@ -69,22 +71,26 @@ test("slides: ← → PgUp PgDn Home End step; the rail and the hash follow; Bac await expect(page).toHaveURL(`${REPORT}?rv=slides#s-2`); await page.keyboard.press("PageDown"); await expect(page).toHaveURL(`${REPORT}?rv=slides#s-3`); + await expect(stage(page).locator('[data-slide="entry:update-mail"]')).toBeVisible(); + await page.keyboard.press("ArrowRight"); + await page.keyboard.press("ArrowRight"); + await expect(page).toHaveURL(`${REPORT}?rv=slides#s-5`); await expect(stage(page).locator("[data-found-group]")).toHaveCount(4); await page.keyboard.press("End"); - await expect(page).toHaveURL(`${REPORT}?rv=slides#s-11`); + await expect(page).toHaveURL(`${REPORT}?rv=slides#s-13`); await expect(stage(page).locator('[data-slide="sources"]')).toContainText("Every citation opens the moment it quotes."); await page.keyboard.press("ArrowRight"); - await expect(page).toHaveURL(`${REPORT}?rv=slides#s-11`); + await expect(page).toHaveURL(`${REPORT}?rv=slides#s-13`); await page.keyboard.press("PageUp"); - await expect(page).toHaveURL(`${REPORT}?rv=slides#s-10`); + await expect(page).toHaveURL(`${REPORT}?rv=slides#s-12`); await page.keyboard.press("Home"); await expect(page).toHaveURL(`${REPORT}?rv=slides#s-1`); - await expect(page.locator(".rs-rail button")).toHaveCount(11); - await page.locator(".rs-rail button").nth(5).click(); - await expect(page).toHaveURL(`${REPORT}?rv=slides#s-6`); + await expect(page.locator(".rs-rail button")).toHaveCount(13); + await page.locator(".rs-rail button").nth(7).click(); + await expect(page).toHaveURL(`${REPORT}?rv=slides#s-8`); await expect(stage(page).locator('[data-slide="claim:claim-2"]')).toContainText("Every year?"); await page.getByRole("button", { name: "Previous slide" }).click(); - await expect(page).toHaveURL(`${REPORT}?rv=slides#s-5`); + await expect(page).toHaveURL(`${REPORT}?rv=slides#s-7`); // Stepping replaces the entry: one Back leaves the slides. await page.goBack(); await expect(page).toHaveURL(REPORT); @@ -92,19 +98,19 @@ test("slides: ← → PgUp PgDn Home End step; the rail and the hash follow; Bac }); test("slides: a shared link opens the same slide; a section's link opens its slide", async ({ page }) => { - await page.goto(`${REPORT}?rv=slides#s-4`); + await page.goto(`${REPORT}?rv=slides#s-6`); await expect(stage(page).locator('[data-slide="section:bridge"]')).toBeVisible(); await expect(stage(page).locator('[data-slide="section:bridge"] .rs-points li')).toHaveText([ "The article dates the opening to 2018", /He says 2019 on stream, and the city's record\s*\[4\]\s*agrees/, ]); await page.goto(`${REPORT}?rv=slides#moving`); - await expect(page).toHaveURL(`${REPORT}?rv=slides#s-7`); + await expect(page).toHaveURL(`${REPORT}?rv=slides#s-9`); await expect(stage(page).locator('[data-slide="section:moving"] .rs-claimlist li')).toHaveCount(3); }); test("slides: a citation's number opens its card; the evidence shows the clip's poster", async ({ page }) => { - await page.goto(`${REPORT}?rv=slides#s-5`); + await page.goto(`${REPORT}?rv=slides#s-7`); const slide = stage(page).locator('[data-slide="claim:claim-1"]'); await expect(slide.locator('[data-verdict="CONTRADICTED"]')).toBeVisible(); const pic = slide.locator('[data-slide-evidence="c01"] img'); @@ -115,11 +121,11 @@ test("slides: a citation's number opens its card; the evidence shows the clip's await expect(page.locator('[data-cite-preview="c01"]')).toBeVisible(); await expect(page.locator('[data-cite-preview="c01"]')).toContainText("The bridge opened in the spring of twenty nineteen"); // The click opened the card; it did not leave the slide. - await expect(page).toHaveURL(`${REPORT}?rv=slides#s-5`); + await expect(page).toHaveURL(`${REPORT}?rv=slides#s-7`); }); test("slides: Read this in the article opens the article at the slide's place", async ({ page }) => { - await page.goto(`${REPORT}?rv=slides#s-6`); + await page.goto(`${REPORT}?rv=slides#s-8`); await stage(page).locator('[data-slide="claim:claim-2"] [data-slide-read]').click(); await expect(page).toHaveURL(`${REPORT}#claim-2`); await expect(page.locator("article#claim-2")).toBeInViewport(); @@ -128,7 +134,7 @@ test("slides: Read this in the article opens the article at the slide's place", test("a phone: the slide reflows, its evidence under its words; nothing scrolls sideways", async ({ page }) => { await page.setViewportSize({ width: 375, height: 800 }); - await page.goto(`${REPORT}?rv=slides#s-5`); + await page.goto(`${REPORT}?rv=slides#s-7`); const slide = stage(page).locator('[data-slide="claim:claim-1"]'); await expect(slide).toHaveAttribute("data-fit", "reflow"); const words = (await slide.locator("h2").boundingBox())!; @@ -140,7 +146,7 @@ test("a phone: the slide reflows, its evidence under its words; nothing scrolls }); test("light and dark: the slides follow the base and wear the site's accent", async ({ page }) => { - await page.goto(`${REPORT}?rv=slides#s-4`); + await page.goto(`${REPORT}?rv=slides#s-6`); const slide = stage(page).locator('[data-slide="section:bridge"]'); const accent = await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue("--brand").trim()); const band = await slide.locator(".rs-band").evaluate((el) => getComputedStyle(el).backgroundColor); @@ -172,7 +178,7 @@ test("slides.html opens with no network: one page per slide, its pictures inline }); await page.setViewportSize({ width: 1280, height: 720 }); await page.goto(url); - await expect(page.locator(".rs-page")).toHaveCount(11); + await expect(page.locator(".rs-page")).toHaveCount(13); await expect(page.locator('#s-1 [data-slide="title"]')).toContainText("Checking an example article"); const imgs = page.locator("img"); expect(await imgs.count()).toBeGreaterThan(0); @@ -183,6 +189,6 @@ test("slides.html opens with no network: one page per slide, its pictures inline await page.keyboard.press("ArrowRight"); await expect(page).toHaveURL(`${url}#s-2`); await expect(page.locator("#s-2")).toBeInViewport(); - await expect(page.locator("#s-11 [data-export-footer]")).toContainText("Revision 2"); + await expect(page.locator("#s-13 [data-export-footer]")).toContainText("Revision 2"); expect(asked).toEqual([]); }); diff --git a/export/e2e-report/timeline.spec.ts b/export/e2e-report/timeline.spec.ts @@ -0,0 +1,113 @@ +import { expect, test } from "./helpers"; + +// A REPORT'S TIMELINE (report.json `entries`): dated entries newest first, +// after In brief and before what the check found, each its own anchor; and, +// on a site with a public URL, its feeds — linked from the header and named +// in the page's metadata, served beside the page with their own media types +// (the `_headers` compose writes, which serve-out applies as Pages does). +// +// The fixture's timeline, newest first: +// update-mail 2026-10-04 "A reader asked about another year" +// update-opening 2026-10-02 "The opening date, said on air" (updated 2026-10-04) +// Both cite only c01, the summary's first citation, so no number moves. + +const REPORT = "/reports/demo-factcheck/"; +const SITE = "https://reports.example.org"; +const RSS = `${SITE}${REPORT}feed.xml`; +const JSON_FEED = `${SITE}${REPORT}feed.json`; + +test("the Timeline: after In brief, before what the check found, newest first, each dated and anchored", async ({ page }) => { + await page.goto(REPORT); + const timeline = page.locator("[data-report-timeline]"); + await expect(timeline.locator("h2")).toHaveText("Timeline"); + const order = await page.evaluate(() => + ["#in-brief", "[data-report-timeline]", "#found", "#claims"].map((s) => { + const el = document.querySelector(s)!; + return [...document.querySelectorAll("#in-brief, [data-report-timeline], #found, #claims")].indexOf(el); + }), + ); + expect(order).toEqual([0, 1, 2, 3]); + + const entries = timeline.locator("li[data-entry]"); + expect(await entries.evaluateAll((els) => els.map((e) => e.id))).toEqual(["update-mail", "update-opening"]); + const newest = entries.nth(0); + await expect(newest.locator("[data-entry-date]")).toHaveAttribute("href", "#update-mail"); + await expect(newest.locator("[data-entry-date] time")).toHaveText("2026-10-04"); + await expect(newest.locator("[data-entry-date] time")).toHaveAttribute("datetime", "2026-10-04"); + await expect(newest.locator("h3")).toHaveText("A reader asked about another year"); + // Its body cites as a section's does: c01 keeps its number. + await expect(newest.locator('[data-inline-cite="c01"] [data-cite-number]')).toHaveText("[1]"); + await expect(entries.nth(1)).toContainText("updated 2026-10-04"); + // The header's update is the report's own: no entry is newer. + await expect(page.locator("[data-report-dates]")).toHaveText("2026-10-01 · updated 2026-10-04 · revision 2"); + + // An entry's date is its permalink. + await entries.nth(1).locator("[data-entry-date]").click(); + await expect(page).toHaveURL(`${REPORT}#update-opening`); + await expect(page.locator("#update-opening")).toBeInViewport(); +}); + +test("Subscribe: RSS and JSON in the header, and the page's alternates name both", async ({ page }) => { + await page.goto(REPORT); + const feeds = page.locator("header [data-report-feeds]"); + await expect(feeds).toContainText("Subscribe:"); + await expect(feeds.locator('a[data-feed="rss"]')).toHaveAttribute("href", RSS); + await expect(feeds.locator('a[data-feed="rss"]')).toHaveAttribute("type", "application/rss+xml"); + await expect(feeds.locator('a[data-feed="json"]')).toHaveAttribute("href", JSON_FEED); + await expect(feeds.locator('a[data-feed="json"]')).toHaveAttribute("type", "application/feed+json"); + await expect(page.locator('head link[rel="alternate"][type="application/rss+xml"]')).toHaveAttribute("href", RSS); + await expect(page.locator('head link[rel="alternate"][type="application/feed+json"]')).toHaveAttribute("href", JSON_FEED); +}); + +test("feed.xml: RSS 2.0, served as application/rss+xml, one item per entry newest first", async ({ request }) => { + const res = await request.get(`${REPORT}feed.xml`); + expect(res.status()).toBe(200); + expect(res.headers()["content-type"]).toMatch(/^application\/rss\+xml\b/); + expect(res.headers()["access-control-allow-origin"]).toBe("*"); + const xml = await res.text(); + expect(xml).toMatch(/^<\?xml version="1\.0" encoding="UTF-8"\?>\n<rss version="2\.0"/); + expect(xml).toContain(`<atom:link href="${RSS}" rel="self" type="application/rss+xml"/>`); + const links = [...xml.matchAll(/<guid isPermaLink="true">([^<]+)<\/guid>/g)].map((m) => m[1]); + expect(links).toEqual([`${SITE}${REPORT}#update-mail`, `${SITE}${REPORT}#update-opening`]); + expect(xml).toContain("<pubDate>Sun, 04 Oct 2026 00:00:00 GMT</pubDate>"); + // A citation's marker links its reference on the page, absolutely. + expect(xml).toContain(`&lt;a href=&quot;${SITE}${REPORT}#c-c01&quot;&gt;[1]`); +}); + +test("feed.json: JSON Feed 1.1, served as application/feed+json, the same items", async ({ request }) => { + const res = await request.get(`${REPORT}feed.json`); + expect(res.status()).toBe(200); + expect(res.headers()["content-type"]).toMatch(/^application\/feed\+json\b/); + expect(res.headers()["access-control-allow-origin"]).toBe("*"); + const feed = (await res.json()) as { + version: string; + home_page_url: string; + feed_url: string; + items: { id: string; title: string; date_published: string; date_modified?: string }[]; + }; + expect(feed.version).toBe("https://jsonfeed.org/version/1.1"); + expect(feed.home_page_url).toBe(`${SITE}${REPORT}`); + expect(feed.feed_url).toBe(JSON_FEED); + expect(feed.items.map((i) => [i.id, i.title, i.date_published, i.date_modified])).toEqual([ + [`${SITE}${REPORT}#update-mail`, "A reader asked about another year", "2026-10-04T00:00:00.000Z", undefined], + [`${SITE}${REPORT}#update-opening`, "The opening date, said on air", "2026-10-02T00:00:00.000Z", "2026-10-04T00:00:00.000Z"], + ]); +}); + +test("the timeline in the other shapes: a slide per entry, and an entry's link opens its slide", async ({ page }) => { + await page.goto(`${REPORT}?rv=slides#update-opening`); + await expect(page).toHaveURL(`${REPORT}?rv=slides#s-4`); + const slide = page.locator('[data-report-slides] [data-slide="entry:update-opening"]'); + await expect(slide).toBeVisible(); + await expect(slide.locator("time")).toHaveText("2026-10-02"); + await expect(slide.locator("h2")).toHaveText("The opening date, said on air"); +}); + +test("a phone: the Timeline reads in the column; nothing scrolls sideways", async ({ page }) => { + await page.setViewportSize({ width: 375, height: 800 }); + await page.goto(`${REPORT}#update-mail`); + const entry = (await page.locator("#update-mail").boundingBox())!; + expect(entry.x).toBeGreaterThanOrEqual(0); + expect(entry.x + entry.width).toBeLessThanOrEqual(375); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(375); +}); diff --git a/export/fixtures/report-site/fixture.ts b/export/fixtures/report-site/fixture.ts @@ -22,6 +22,7 @@ import { buildCitedIn } from "yt-dlp-transcript-common/lib/report/citedIn"; import { parseReport } from "yt-dlp-transcript-common/lib/report/validate"; import type { Report } from "yt-dlp-transcript-common/lib/report/schema"; import { reportHistoryPagePath } from "yt-dlp-transcript-common/lib/report/revisions"; +import { reportFeedUrls } from "yt-dlp-transcript-common/lib/report/feeds"; import { momentKeyOf, parseMomentKey, type SpanMoment } from "yt-dlp-transcript-common/lib/citations/moments"; import { MOMENT_INDEX_FORMAT, @@ -48,6 +49,9 @@ import { export const FIXTURE_DIR = path.dirname(fileURLToPath(import.meta.url)); export const FIXTURE_PUBLIC_DIR = path.join(FIXTURE_DIR, "public"); export const FIXTURE_REPORT_ID = "demo-factcheck"; +// The fixture site's public URL (e2e-report/fixtures/sites/reportsite/site.json +// `siteUrl`): the report's timeline feeds are named by it, as compose names them. +export const FIXTURE_SITE_URL = "https://reports.example.org"; // The report's revisions, oldest first: the file under source/<id>/ and the // commit's date. @@ -125,6 +129,9 @@ export function buildFixtureReportView(report = readFixtureReport()): ReportPage json: reportCitationsDownloadPath(report.id, "json"), csv: reportCitationsDownloadPath(report.id, "csv"), }, + // Its timeline's feeds, as compose names them on a site with a public URL + // (contract.ts writes them). + feeds: reportFeedUrls(FIXTURE_SITE_URL, report.id), // The newest revision, as compose names it (contract.ts commits both). history: { revision: FIXTURE_REVISIONS.length, diff --git a/export/fixtures/report-site/public/m/demo-channel/abc123/3126.00-3151.00/moment.json b/export/fixtures/report-site/public/m/demo-channel/abc123/3126.00-3151.00/moment.json @@ -77,6 +77,28 @@ }, { "reportId": "demo-factcheck", + "sectionId": null, + "claimId": null, + "entryId": "update-mail", + "citationId": "c01", + "href": "/reports/demo-factcheck/#update-mail", + "reportTitle": "Demo Checks: Checking an example article", + "entryTitle": "A reader asked about another year", + "number": 1 + }, + { + "reportId": "demo-factcheck", + "sectionId": null, + "claimId": null, + "entryId": "update-opening", + "citationId": "c01", + "href": "/reports/demo-factcheck/#update-opening", + "reportTitle": "Demo Checks: Checking an example article", + "entryTitle": "The opening date, said on air", + "number": 1 + }, + { + "reportId": "demo-factcheck", "sectionId": "bridge", "claimId": "claim-1", "citationId": "c01", diff --git a/export/fixtures/report-site/public/reports/demo-factcheck/page.json b/export/fixtures/report-site/public/reports/demo-factcheck/page.json @@ -157,6 +157,21 @@ "shot": "/media/posts/demo-social/1234567890/shot.png" } }, + "entries": [ + { + "id": "update-mail", + "date": "2026-10-04", + "title": "A reader asked about another year", + "body": "No other year turns up: every time he dates the opening it is the year he said [on stream](cite:c01)." + }, + { + "id": "update-opening", + "date": "2026-10-02", + "updated": "2026-10-04", + "title": "The opening date, said on air", + "body": "The host gave the year of the opening [on stream](cite:c01), the year the city's record names." + } + ], "sections": [ { "id": "bridge", @@ -252,6 +267,10 @@ "json": "/reports/demo-factcheck/citations.json", "csv": "/reports/demo-factcheck/citations.csv" }, + "feeds": { + "rss": "https://reports.example.org/reports/demo-factcheck/feed.xml", + "json": "https://reports.example.org/reports/demo-factcheck/feed.json" + }, "history": { "revision": 2, "date": "2026-10-04T12:00:00Z", diff --git a/export/fixtures/report-site/source/demo-factcheck/report.json b/export/fixtures/report-site/source/demo-factcheck/report.json @@ -85,6 +85,21 @@ "quote": "Not cited anywhere." } }, + "entries": [ + { + "id": "update-opening", + "date": "2026-10-02", + "updated": "2026-10-04", + "title": "The opening date, said on air", + "body": "The host gave the year of the opening [on stream](cite:c01), the year the city's record names." + }, + { + "id": "update-mail", + "date": "2026-10-04", + "title": "A reader asked about another year", + "body": "No other year turns up: every time he dates the opening it is the year he said [on stream](cite:c01)." + } + ], "sections": [ { "id": "bridge", diff --git a/export/scripts/serve-out.mjs b/export/scripts/serve-out.mjs @@ -11,6 +11,12 @@ // // Byte ranges are honoured (a media element asks for them). // +// The directory's `_headers` (compose writes it: common/lib/archive/headers.ts) +// is applied as Pages applies it — a rule's path a literal, a trailing `*` +// splat or `:name` placeholders, each of its headers set on what it matches, +// a `Content-Type` over the extension's — so what a spec sees (CORS, a feed's +// media type) is what a deployed site sends. Read once, at start. +// // node scripts/serve-out.mjs <dir> <port> // // Every interface, as `serve` did; `HOST=127.0.0.1` keeps a private site on this machine. @@ -57,6 +63,45 @@ const TYPES = { ".wasm": "application/wasm", }; +// `_headers` as [{ re, headers: [[name, value]] }], in file order. +function readHeaderRules(root) { + let text; + try { + text = fs.readFileSync(path.join(root, "_headers"), "utf8"); + } catch { + return []; + } + const rules = []; + for (const line of text.split("\n")) { + if (!line.trim() || line.trimStart().startsWith("#")) continue; + if (/^\s/.test(line)) { + const at = line.indexOf(":"); + if (at > 0 && rules.length > 0) rules[rules.length - 1].headers.push([line.slice(0, at).trim(), line.slice(at + 1).trim()]); + continue; + } + const pattern = line + .trim() + .replace(/[.+?^${}()|[\]\\]/g, "\\$&") + .replace(/\*/g, ".*") + .replace(/:[A-Za-z_]\w*/g, "[^/]+"); + rules.push({ re: new RegExp(`^${pattern}$`), headers: [] }); + } + return rules; +} +const HEADER_RULES = readHeaderRules(ROOT); + +function ruleHeaders(pathname) { + const out = {}; + for (const rule of HEADER_RULES) { + if (!rule.re.test(pathname)) continue; + for (const [name, value] of rule.headers) { + const key = Object.keys(out).find((k) => k.toLowerCase() === name.toLowerCase()) ?? name; + out[key] = value; + } + } + return out; +} + function statOrNull(p) { try { return fs.statSync(p); @@ -65,12 +110,19 @@ function statOrNull(p) { } } -function sendFile(req, res, file, status = 200) { +function sendFile(req, res, file, status = 200, pathname) { const size = fs.statSync(file).size; + const fromRules = status === 200 && pathname ? ruleHeaders(pathname) : {}; + const type = Object.keys(fromRules).find((k) => k.toLowerCase() === "content-type"); const headers = { "Content-Type": TYPES[path.extname(file).toLowerCase()] ?? "application/octet-stream", "Accept-Ranges": "bytes", + ...fromRules, }; + if (type && type !== "Content-Type") { + headers["Content-Type"] = fromRules[type]; + delete headers[type]; + } const range = status === 200 ? /^bytes=(\d*)-(\d*)$/.exec(req.headers.range ?? "") : null; if (range && (range[1] || range[2])) { let start = range[1] ? Number(range[1]) : size - Number(range[2]); @@ -108,11 +160,11 @@ http return void res.writeHead(308, { Location: `${url.pathname}/${url.search}` }).end(); } const index = path.join(file, "index.html"); - if (statOrNull(index)?.isFile()) return sendFile(req, res, index); + if (statOrNull(index)?.isFile()) return sendFile(req, res, index, 200, pathname); } else if (st?.isFile()) { - return sendFile(req, res, file); + return sendFile(req, res, file, 200, pathname); } else if (!pathname.endsWith("/") && statOrNull(`${file}.html`)?.isFile()) { - return sendFile(req, res, `${file}.html`); + return sendFile(req, res, `${file}.html`, 200, pathname); } const notFound = path.join(ROOT, "404.html"); if (statOrNull(notFound)?.isFile()) return sendFile(req, res, notFound, 404);