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:
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(`<a href="${SITE}${REPORT}#c-c01">[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);