// Feeds and read-later (docs/feeds.md): the list a note keeps // (shared/feeds.ts), the three formats and discovery (server/feedParse.ts), // the sanitiser (shared/feedHtml.ts), and the whole round against a LOCAL // server serving the fixtures under tests/fixtures/feeds — nothing here // touches the network beyond 127.0.0.1. Every fixture's own host is rewritten // to the local origin as it is served, so an item's link, and the page Keep // fetches for it, are local too. import assert from "node:assert/strict"; import { after, before, describe, it } from "node:test"; import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { createServer, type Server } from "node:http"; import type { AddressInfo } from "node:net"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { FEEDS_DEFAULT_MINUTES, feedsCadenceMinutes, groupItems, keptNote, parseFeedList, READING_FOLDER } from "../shared/feeds.ts"; import { htmlExcerpt, sanitizeFeedHtml } from "../shared/feedHtml.ts"; import { discoverFeeds, parseFeed, parseFeedDate } from "../server/feedParse.ts"; import { closeFeeds, feedsState, initFeeds, keepItem, markRead, openItem, runRound } from "../server/feeds.ts"; import { decks, deckCards, indexFile, initIndexer, isNotePublished } from "../server/indexer.ts"; import { patchSettings } from "../server/settings.ts"; import { initSite } from "../server/site.ts"; import { initVault } from "../server/vault.ts"; import { makeDir, makeVault, removeVault } from "./helpers/vault.ts"; const FIX = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures", "feeds"); const fixture = (name: string): string => readFileSync(path.join(FIX, name), "utf8"); describe("the list (shared/feeds.ts)", () => { it("reads addresses, folders and tags from every feeds fence, and nothing outside them", () => { const md = [ "# Feeds", "", "https://outside.example/rss is prose, not a feed", "", "```feeds", "#reading", "https://a.example/feed.xml → Reading/Essays", "#essays #long-form", "// a comment line", "https://b.example/rss -> Clips/B", "", "https://c.example/atom", "```", "", "```js", "https://d.example/not-a-feed", "```", "", "```feeds", "https://a.example/feed.xml", "ftp://nope.example/", "https://e.example/x → ../escape", "```", ].join("\n"); const { feeds, problems } = parseFeedList(md); assert.deepEqual( feeds.map((f) => [f.url, f.folder, f.tags]), [ ["https://a.example/feed.xml", "Reading/Essays", ["reading", "essays", "long-form"]], ["https://b.example/rss", "Clips/B", ["reading"]], ["https://c.example/atom", READING_FOLDER, ["reading"]], ["https://e.example/x", READING_FOLDER, []], ], ); assert.deepEqual(problems.map((p) => p.reason), ["duplicate", "notAnAddress", "badFolder"]); assert.equal(problems[0].line, 20); }); it("an unclosed fence runs to the end; a heading is not a tag line", () => { const { feeds, problems } = parseFeedList("```feeds\nhttps://x.example/f\n# Not tags\n"); assert.equal(feeds.length, 1); assert.deepEqual(problems.map((p) => p.reason), ["notAnAddress"]); }); it("the cadence is sync's when sync runs on a timer, else the hour", () => { assert.equal(feedsCadenceMinutes({ enabled: true, remote: "git@x:y", intervalMinutes: 15 }), 15); assert.equal(feedsCadenceMinutes({ enabled: true, remote: null, intervalMinutes: 15 }), FEEDS_DEFAULT_MINUTES); assert.equal(feedsCadenceMinutes({ enabled: false, remote: "git@x:y", intervalMinutes: 15 }), FEEDS_DEFAULT_MINUTES); }); it("a kept note names its source, feed, dates and tags, and carries no publish key", () => { const text = keptNote({ title: "On: Things", url: "https://x.example/a", feed: 'The "Feed"', published: "2026-09-20", kept: "2026-09-23", tags: ["reading", "long-form", "2026", "true"], body: "# On: Things\n\nBody.", }); assert.equal( text, '---\nsource: "https://x.example/a"\nfeed: "The \\"Feed\\""\npublished: 2026-09-20\nkept: 2026-09-23\ntags: [reading, long-form, "2026", "true"]\n---\n\n# On: Things\n\nBody.\n', ); assert.doesNotMatch(text, /publish:/); }); it("groups unread items by feed in the list's order, newest first", () => { const feed = (url: string) => ({ url, title: url, site: null, folder: "R", tags: [], unread: 0, checked: null, error: null }); const item = (f: string, guid: string, published: number) => ({ feed: f, guid, title: guid, url: null, author: null, published, read: false, kept: null, excerpt: "" }); const out = groupItems([feed("b"), feed("a"), feed("empty")], [item("a", "a1", 1), item("b", "b1", 1), item("a", "a2", 5)]); assert.deepEqual(out.map((g) => [g.feed.url, g.items.map((i) => i.guid)]), [["b", ["b1"]], ["a", ["a2", "a1"]]]); }); }); describe("the sanitiser (shared/feedHtml.ts)", () => { it("keeps prose, drops script and handlers, resolves and fences links and images", () => { const out = sanitizeFeedHtml( '

Hi there

bad ok
', "https://site.example/post", ); assert.equal( out, '

Hi there

bad ok', ); }); it("escapes text and attribute values", () => { assert.equal(sanitizeFeedHtml('

a <b> & "c"

q', null), '

a <b> & "c"

q'); }); it("an excerpt is the first words, plain", () => { assert.equal(htmlExcerpt("

One two

three

"), "One two three"); assert.match(htmlExcerpt(`

${"word ".repeat(100)}

`, 40), /…$/); }); }); describe("the three formats (server/feedParse.ts)", () => { it("RSS 2.0: content:encoded over description, relative links resolved, a stable id for every item", () => { const feed = parseFeed(fixture("rss.xml"), "application/rss+xml", "https://marginal.example/rss.xml"); assert.ok(feed); assert.equal(feed.kind, "rss"); assert.equal(feed.title, "The Marginal Reader"); assert.equal(feed.site, "https://marginal.example/"); assert.equal(feed.items.length, 3); const [a, b, c] = feed.items; assert.equal(a.guid, "marginal-2026-09-20-commonplace"); assert.equal(a.url, "https://marginal.example/essays/commonplace"); assert.equal(a.author, "Ibn Muqla"); assert.equal(a.published, Date.parse("2026-09-20T08:30:00Z")); assert.match(a.content ?? "", /commonplace book is a reader's<\/strong>/); assert.equal(a.summary, "A short teaser about commonplace books."); assert.equal(b.guid, "https://marginal.example/essays/marginalia"); assert.equal(b.summary, null); assert.equal(c.guid, "https://marginal.example/notes/undated"); assert.equal(c.published, null); }); it("Atom: xhtml content printed back, html content unescaped, xml:base honoured", () => { const feed = parseFeed(fixture("atom.xml"), "application/atom+xml", "https://atlas.example/atom.xml"); assert.ok(feed); assert.equal(feed.kind, "atom"); assert.equal(feed.site, "https://atlas.example/"); const [a, b] = feed.items; assert.equal(a.title, "The Astrolabe’s Rete"); assert.equal(a.url, "https://atlas.example/posts/rete"); assert.equal(a.author, "Maryam al-Ijliya"); assert.match(a.content ?? "", /

The rete<\/em> is the pierced plate/); assert.match(a.content ?? "", /The rete/); assert.equal(b.content, "

The sighting rule on the back.

"); assert.equal(b.published, Date.parse("2026-09-19T08:00:00Z")); }); it("JSON Feed: html and text content, a numeric id as a string, Arabic intact", () => { const feed = parseFeed(fixture("feed.json"), "application/feed+json", "https://daftar.example/feed.json"); assert.ok(feed); assert.equal(feed.kind, "json"); assert.equal(feed.title, "دفتر القراءة"); const [a, b] = feed.items; assert.equal(a.title, "طوق الحمامة"); assert.equal(a.author, "القارئ"); assert.equal(b.guid, "42"); assert.equal(b.content, "

First paragraph.

Second paragraph with <angle> brackets.

"); }); it("an HTML page is not a feed, and names its feeds", () => { assert.equal(parseFeed(fixture("page.html"), "text/html", "https://marginal.example/"), null); assert.deepEqual(discoverFeeds(fixture("page.html"), "https://marginal.example/"), ["https://marginal.example/rss.xml", "https://marginal.example/atom.xml"]); }); it("dates in the shapes generators write", () => { assert.equal(parseFeedDate("Tue, 10 Jun 2003 04:00:00 GMT"), Date.parse("2003-06-10T04:00:00Z")); assert.equal(parseFeedDate("2026-09-21T09:00:00Z"), Date.parse("2026-09-21T09:00:00Z")); assert.equal(parseFeedDate("lundi, 10 Jun 2003 04:00:00 GMT"), Date.parse("2003-06-10T04:00:00Z")); assert.equal(parseFeedDate("nonsense"), null); assert.equal(parseFeedDate(""), null); }); }); describe("a round, Keep, and the vault (server/feeds.ts, against a local server)", () => { const data = makeDir(); let root = ""; let server: Server; let origin = ""; const hits: string[] = []; const revalidated: string[] = []; before(async () => { server = createServer((req, res) => { const url = req.url ?? "/"; hits.push(url); const local = (text: string): string => text.replaceAll("https://marginal.example", origin).replaceAll("https://atlas.example", origin).replaceAll("https://daftar.example", origin); const send = (type: string, body: string, etag?: string): void => { if (etag && req.headers["if-none-match"] === etag) { revalidated.push(url); res.writeHead(304, { ETag: etag }); res.end(); return; } res.writeHead(200, { "Content-Type": type, ...(etag ? { ETag: etag } : {}) }); res.end(body); }; if (url === "/rss.xml") return send("application/rss+xml; charset=utf-8", local(fixture("rss.xml")), '"rss-1"'); if (url === "/atom.xml") return send("application/atom+xml", local(fixture("atom.xml"))); if (url === "/feed.json") return send("application/feed+json", local(fixture("feed.json"))); if (url === "/blog/") return send("text/html", local(fixture("page.html"))); if (url === "/essays/marginalia") return send("text/html; charset=utf-8", local(fixture("article.html"))); if (url === "/broken.xml") return send("application/rss+xml", "not a feed"); res.writeHead(404); res.end(); }); await new Promise((r) => server.listen(0, "127.0.0.1", r)); origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; root = makeVault({ "Feeds.md": [ "# Feeds", "", "```feeds", "#reading", `${origin}/rss.xml → Reading/Marginal`, `${origin}/atom.xml`, "#essays", `${origin}/feed.json → Arabic`, `${origin}/blog/ → Discovered`, `${origin}/broken.xml`, "```", "", ].join("\n"), }); initSite({ ASTROLABE_DATA: data }); initVault(root); await initIndexer(); initFeeds({ dbFile: path.join(data, "feeds.db"), timer: false }); }); after(async () => { closeFeeds(); await new Promise((r) => server.close(() => r())); removeVault(root); removeVault(data); }); it("while fetching is off, reading the surface asks nobody", async () => { const state = await feedsState(); assert.equal(state.fetch, false); assert.equal(state.noteExists, true); assert.equal(state.feeds.length, 5); assert.equal(state.items.length, 0); await new Promise((r) => setTimeout(r, 50)); assert.deepEqual(hits, []); }); it("a round fetches every format, discovers the page's feed, and records the failure it met", async () => { patchSettings({ feeds: { fetch: true } }); await runRound(); const state = await feedsState(); const by = new Map(state.feeds.map((f) => [f.url, f])); assert.equal(by.get(`${origin}/rss.xml`)?.title, "The Marginal Reader"); assert.equal(by.get(`${origin}/rss.xml`)?.unread, 3); assert.equal(by.get(`${origin}/atom.xml`)?.unread, 2); assert.equal(by.get(`${origin}/feed.json`)?.unread, 2); // The page named /rss.xml first: discovered, followed, the same items. assert.equal(by.get(`${origin}/blog/`)?.unread, 3); assert.match(by.get(`${origin}/broken.xml`)?.error ?? "", /not a feed/); assert.equal(state.items.length, 10); assert.ok(hits.every((h) => !/^https?:/.test(h)), "every ask was local"); }); it("the next round revalidates with the ETag and brings nothing twice", async () => { await runRound(); assert.ok(revalidated.includes("/rss.xml"), "If-None-Match was sent and answered 304"); const state = await feedsState(); assert.equal(state.items.length, 10); }); it("an item opens sanitised; a full article is not a summary", () => { const full = openItem(`${origin}/rss.xml`, "marginal-2026-09-20-commonplace"); assert.equal(full.summaryOnly, false); assert.doesNotMatch(full.html, /script|onclick|onerror|javascript:/); assert.match(full.html, new RegExp(`]*loading="lazy"`)); const teaser = openItem(`${origin}/rss.xml`, `${origin}/essays/marginalia`); assert.equal(teaser.summaryOnly, true); }); it("Keep writes the article from the feed into the feed's folder, with its facts, and never publishes it", async () => { const out = await keepItem(`${origin}/rss.xml`, "marginal-2026-09-20-commonplace"); assert.equal(out.path, "Reading/Marginal/On Keeping a Commonplace Book.md"); assert.equal(out.fetched, false); const text = readFileSync(path.join(root, out.path), "utf8"); assert.match(text, new RegExp(`^---\\nsource: "${origin}/essays/commonplace"\\nfeed: "The Marginal Reader"\\npublished: 2026-09-2\\d\\nkept: \\d{4}-\\d\\d-\\d\\d\\ntags: \\[reading\\]\\n---\\n\\n# On Keeping a Commonplace Book\\n\\nA commonplace book is a \\*\\*reader's\\*\\* memory`)); assert.doesNotMatch(text, /alert|steal/); assert.equal(isNotePublished(out.path), false); // Kept is read, and a second Keep is the same note. const again = await keepItem(`${origin}/rss.xml`, "marginal-2026-09-20-commonplace"); assert.deepEqual(again, { path: out.path, fetched: false, already: true }); const state = await feedsState(); assert.ok(!state.items.some((i) => i.guid === "marginal-2026-09-20-commonplace" && i.feed === `${origin}/rss.xml`)); }); it("Keep of a teaser fetches the page and keeps its article, not its navigation", async () => { const out = await keepItem(`${origin}/rss.xml`, `${origin}/essays/marginalia`); assert.equal(out.fetched, true); assert.equal(out.path, "Reading/Marginal/Marginalia, Briefly.md"); const text = readFileSync(path.join(root, out.path), "utf8"); assert.match(text, /The margin is where a book talks back/); assert.match(text, /> A book is a machine to think with\./); assert.match(text, new RegExp(`\\[the commonplace book\\]\\(${origin}/essays/commonplace\\)`)); assert.doesNotMatch(text, /Home|©/); assert.ok(hits.includes("/essays/marginalia")); }); it("with fetching off, Keep keeps what the feed carried and asks nobody", async () => { patchSettings({ feeds: { fetch: false } }); const before = hits.length; const out = await keepItem(`${origin}/atom.xml`, "tag:atlas.example,2026:alidade"); assert.equal(out.fetched, false); assert.equal(hits.length, before); assert.match(readFileSync(path.join(root, out.path), "utf8"), /tags: \[reading, essays\]\n---\n\n# Alidade\n\nThe sighting rule on the back\.\n$/); patchSettings({ feeds: { fetch: true } }); }); it("a highlight made in a kept note is a card in Orbits' implicit deck", async () => { const rel = "Reading/Marginal/On Keeping a Commonplace Book.md"; const abs = path.join(root, rel); writeFileSync(abs, readFileSync(abs, "utf8").replace("the heading is yours", "==the heading is yours==")); await indexFile(rel); const implicit = decks("2026-09-23").find((d) => d.implicit); assert.ok(implicit && implicit.counts.total >= 1); const cards = deckCards("*", null) ?? []; assert.ok(cards.some((c) => c.path === rel), "the kept note's highlight is in the implicit deck"); }); it("read and unread round-trip; an unknown item is a 404", async () => { markRead(`${origin}/feed.json`, "42", true); assert.ok(!(await feedsState()).items.some((i) => i.guid === "42")); markRead(`${origin}/feed.json`, "42", false); assert.ok((await feedsState()).items.some((i) => i.guid === "42")); assert.throws(() => markRead(`${origin}/feed.json`, "nope", true), /No such item/); }); it("nothing of the feeds is in the vault but the kept notes", () => { assert.ok(existsSync(path.join(data, "feeds.db"))); assert.ok(!existsSync(path.join(root, "feeds.db"))); }); });