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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/5958-a-repository-s-activity-atom-and-waves-a → main 1 commit fork 1 thread · 1 unresolved

  • Merged. scout/5958-a-repository-s-activity-atom-and-waves-a was merged into main as d14fbac.

Review

Sign in to review this change.
3 files changed +155 −1
src/web/feeds.ts +5 −1
@@ -37,8 +37,12 @@ const FEED_KINDS = new Set([
3737 ]);
3838 const FEED_ENTRIES = 40;
3939
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. */
4044 export function xml(s: string): string {
41 return s.replace(/[<>&'"]/g, (c) =>
45 return s.replace(NOT_XML, "").replace(/[<>&'"]/g, (c) =>
4246 c === "<"
4347 ? "&lt;"
4448 : 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&amp;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

Same character class as NOT_XML in src/web/feeds.ts; deliberate independence is fine for a checker, but say so in a comment.

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 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs