Public: anyone can read this; the crew can change it.
A repository's activity.atom and waves.atom parse as Atom: each has a feed id, updated, self link and entries with id, title, updated and link #5967
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5958-a-repository-s-activity-atom-and-waves-a → main 1 commit fork 1 thread · 1 unresolved
3 files
changed +155
−1
src/web/feeds.ts +5 −1
| @@ -37,8 +37,12 @@ const FEED_KINDS = new Set([ | ||
| 37 | 37 | ]); |
| 38 | 38 | const FEED_ENTRIES = 40; |
| 39 | 39 | |
| 40 | // eslint-disable-next-line no-control-regex | |
| 41 | const NOT_XML = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\ufffe\uffff]/g; | |
| 42 | ||
| 43 | /** Escapes for XML text and attributes; characters XML 1.0 forbids are dropped. */ | |
| 40 | 44 | export function xml(s: string): string { |
| 41 | return s.replace(/[<>&'"]/g, (c) => | |
| 45 | return s.replace(NOT_XML, "").replace(/[<>&'"]/g, (c) => | |
| 42 | 46 | c === "<" |
| 43 | 47 | ? "<" |
| 44 | 48 | : c === ">" |
test/atom-feeds.test.ts added +78
| @@ -0,0 +1,78 @@ | ||
| 1 | /** Both of a repository's feeds parse as Atom, round-trip awkward titles, and cache. */ | |
| 2 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { openIssue } from "../src/actions/issues.ts"; | |
| 7 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 8 | import { createApp, type App } from "../src/app.ts"; | |
| 9 | import { createUser } from "../src/core/users.ts"; | |
| 10 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 11 | import { writeWave } from "../src/story/wave.ts"; | |
| 12 | import { xml } from "../src/web/feeds.ts"; | |
| 13 | import { checkAtom } from "./helpers/atom.ts"; | |
| 14 | ||
| 15 | const TITLE = "Fish & <chips> 🍟 \"fresh\""; | |
| 16 | ||
| 17 | describe("atom feeds", () => { | |
| 18 | let dataDir: string; | |
| 19 | let app: App; | |
| 20 | let servers: Servers; | |
| 21 | ||
| 22 | beforeAll(async () => { | |
| 23 | dataDir = await mkdtemp(join(tmpdir(), "forge-atom-")); | |
| 24 | app = createApp({ dataDir, port: 0 }); | |
| 25 | servers = await serve(app); | |
| 26 | const u = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 27 | if (!u.ok) throw new Error(u.error); | |
| 28 | const made = await createRepo(app, u.value, { name: "r", visibility: "public", description: "d & <e> 🍟" }); | |
| 29 | if (!made.ok) throw new Error(String(made.error)); | |
| 30 | const wave = writeWave({ | |
| 31 | version: "0.1.0", | |
| 32 | date: "2026-01-02", | |
| 33 | headline: TITLE, | |
| 34 | highlights: [1, 2, 3].map((n) => ({ title: `T${n} & 🍟`, why: "a < b" })), | |
| 35 | pulls: [], | |
| 36 | }); | |
| 37 | if (!wave.ok) throw wave.error; | |
| 38 | await commitToBranch(app, u.value, made.value, { | |
| 39 | message: "seed", | |
| 40 | files: [{ path: "README.md", content: "hi\n" }, { path: "waves/0.1.0.md", content: wave.value }], | |
| 41 | }); | |
| 42 | const issue = openIssue(app, u.value, made.value, { title: TITLE }); | |
| 43 | if (!issue.ok) throw new Error(String(issue.error)); | |
| 44 | }, 30_000); | |
| 45 | ||
| 46 | afterAll(async () => { | |
| 47 | servers.stop(); | |
| 48 | app.close(); | |
| 49 | await rm(dataDir, { recursive: true, force: true }); | |
| 50 | }); | |
| 51 | ||
| 52 | for (const feed of ["activity.atom", "waves.atom"]) { | |
| 53 | describe(feed, () => { | |
| 54 | const url = () => `${servers.url}/alice/r/${feed}`; | |
| 55 | ||
| 56 | test("parses as Atom and an awkward title reads back unchanged", async () => { | |
| 57 | const res = await fetch(url()); | |
| 58 | expect(res.status).toBe(200); | |
| 59 | const { titles } = checkAtom(await res.text(), url()); | |
| 60 | expect(titles.length).toBeGreaterThan(0); | |
| 61 | expect(titles.some((t) => t.includes(TITLE))).toBe(true); | |
| 62 | }); | |
| 63 | ||
| 64 | test("is application/atom+xml with an ETag, and a repeat is a 304", async () => { | |
| 65 | const res = await fetch(url()); | |
| 66 | expect(res.headers.get("content-type")).toStartWith("application/atom+xml"); | |
| 67 | const etag = res.headers.get("etag"); | |
| 68 | expect(etag).toBeTruthy(); | |
| 69 | const again = await fetch(url(), { headers: { "if-none-match": etag! } }); | |
| 70 | expect(again.status).toBe(304); | |
| 71 | }); | |
| 72 | }); | |
| 73 | } | |
| 74 | ||
| 75 | test("characters XML forbids are dropped, not emitted", () => { | |
| 76 | expect(xml("a\u0000b\u0008c\u001fd&e")).toBe("abcd&e"); | |
| 77 | }); | |
| 78 | }); |
test/helpers/atom.ts added +72
| @@ -0,0 +1,72 @@ | ||
| 1 | /** A strict little XML reader for tests: well-formedness is checked, not assumed. */ | |
| 2 | export type XNode = { name: string; attrs: Record<string, string>; kids: XNode[]; text: string }; | |
| 3 | ||
| 4 | const ENTITIES: Record<string, string> = { lt: "<", gt: ">", amp: "&", apos: "'", quot: '"' }; | |
| 5 | const unescape = (s: string): string => | |
| 6 | s.replace(/&(#x[0-9a-fA-F]+|#\d+|\w+);/g, (m, e: string) => { | |
| 7 | if (e[0] === "#") return String.fromCodePoint(e[1] === "x" ? parseInt(e.slice(2), 16) : Number(e.slice(1))); | |
| 8 | const v = ENTITIES[e]; | |
| 9 | if (v === undefined) throw new Error(`unknown entity ${m}`); | |
| 10 | return v; | |
| 11 | }); | |
| 12 | ||
| 13 | export function parseXml(src: string): XNode { | |
| 14 | const s = src.replace(/^<\?xml[^>]*\?>\s*/, ""); | |
| 15 | // eslint-disable-next-line no-control-regex | |
| 16 | if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f\ufffe\uffff]/.test(s)) throw new Error("illegal XML character"); | |
| 17 | const root: XNode = { name: "#root", attrs: {}, kids: [], text: "" }; | |
| 18 | const stack = [root]; | |
| 19 | const re = /<(\/?)([A-Za-z][\w:.-]*)((?:\s+[\w:.-]+="[^"<]*")*)\s*(\/?)>|([^<]+)/gy; | |
| 20 | let at = 0; | |
| 21 | for (let m: RegExpExecArray | null; at < s.length; at = re.lastIndex) { | |
| 22 | re.lastIndex = at; | |
| 23 | m = re.exec(s); | |
| 24 | if (!m) throw new Error(`not well-formed at ${at}: ${s.slice(at, at + 30)}`); | |
| 25 | const top = stack[stack.length - 1]!; | |
| 26 | if (m[5] !== undefined) { | |
| 27 | if (/[<>]/.test(m[5]) || /&(?![#\w]+;)/.test(m[5])) throw new Error("bad text"); | |
| 28 | top.text += unescape(m[5]); | |
| 29 | } else if (m[1] === "/") { | |
| 30 | if (stack.pop()!.name !== m[2]) throw new Error(`mismatched </${m[2]}>`); | |
| 31 | } else { | |
| 32 | const attrs: Record<string, string> = {}; | |
| 33 | for (const a of (m[3] ?? "").matchAll(/([\w:.-]+)="([^"]*)"/g)) { | |
| 34 | if (a[1]! in attrs) throw new Error("duplicate attribute"); | |
| 35 | attrs[a[1]!] = unescape(a[2]!); | |
| 36 | } | |
| 37 | const node: XNode = { name: m[2]!, attrs, kids: [], text: "" }; | |
| 38 | top.kids.push(node); | |
| 39 | if (m[4] !== "/") stack.push(node); | |
| 40 | } | |
| 41 | } | |
| 42 | if (stack.length !== 1 || root.kids.length !== 1) throw new Error("unclosed or multiple roots"); | |
| 43 | return root.kids[0]!; | |
| 44 | } | |
| 45 | ||
| 46 | const RFC3339 = /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)?(Z|[+-]\d\d:\d\d)$/; | |
| 47 | const one = (n: XNode, name: string): XNode => { | |
| 48 | const k = n.kids.filter((c) => c.name === name); | |
| 49 | if (k.length < 1) throw new Error(`<${n.name}> lacks <${name}>`); | |
| 50 | return k[0]!; | |
| 51 | }; | |
| 52 | ||
| 53 | /** Checks the Atom shape and returns each entry's title, for round-trip checks. */ | |
| 54 | export function checkAtom(xml: string, url: string): { titles: string[] } { | |
| 55 | const feed = parseXml(xml); | |
| 56 | if (feed.name !== "feed" || feed.attrs["xmlns"] !== "http://www.w3.org/2005/Atom") throw new Error("not an Atom feed"); | |
| 57 | for (const f of ["id", "title", "updated"]) if (one(feed, f).text.trim() === "") throw new Error(`empty ${f}`); | |
| 58 | if (!RFC3339.test(one(feed, "updated").text)) throw new Error("feed updated not RFC 3339"); | |
| 59 | const self = feed.kids.find((c) => c.name === "link" && c.attrs["rel"] === "self"); | |
| 60 | if (!self || self.attrs["href"] !== url) throw new Error(`self link ${self?.attrs["href"]} is not ${url}`); | |
| 61 | const titles: string[] = []; | |
| 62 | const ids = new Set<string>(); | |
| 63 | for (const e of feed.kids.filter((c) => c.name === "entry")) { | |
| 64 | const id = one(e, "id").text; | |
| 65 | if (id === "" || ids.has(id)) throw new Error("entry id empty or repeated"); | |
| 66 | ids.add(id); | |
| 67 | if (!RFC3339.test(one(e, "updated").text)) throw new Error("entry updated not RFC 3339"); | |
| 68 | if (!one(e, "link").attrs["href"]) throw new Error("entry link has no href"); | |
| 69 | titles.push(one(e, "title").text); | |
| 70 | } | |
| 71 | return { titles }; | |
| 72 | } |
new above
offline
Same character class as NOT_XML in src/web/feeds.ts; deliberate independence is fine for a checker, but say so in a comment.