Public: anyone can read this; the crew can change it.

Explore has an Atom feed: /explore.atom lists the apps rising and newly made, and the page advertises it #11044

Merged opened this

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

Merged

torque/forge:torque/10967-explore-has-an-atom-feed-explore-atom-li → main 4 commits fork 2 threads · 2 unresolved

  • Merged. torque/10967-explore-has-an-atom-feed-explore-atom-li was merged into main as 2204bf9.

Review

Sign in to review this change.
10 files changed +194 −4
docs/features/explore.md +6
@@ -48,3 +48,9 @@ An unknown filter word is refused the same way everywhere: `422` from the API (`
4848 ## Make it yours on an app row
4949 An app's row carries `action: { do: "make_it_yours", label: "Make it yours" }` exactly when `copyable` is true (`MAKE_IT_YOURS` in `actions/explore.ts`).
5050 `label` is the page button's text and `do` is the name of the MCP tool that forks; it plans unless called with `dryRun: false`.
51
52 ## As a feed (#10967)
53
54 `GET /explore.atom` is `/explore` as an Atom feed, public, for a feed reader. It holds the apps `/explore` ranks first for a stranger (default ranking, apps only, read through the page's own `explore`), at most 30 (`EXPLORE_ENTRIES` in `web/feeds.ts`), shown newest-made first, one entry each titled with the app's name, linked to its page (`rel="alternate"`) with its description as the summary. Private and archived apps are never in it. It is cached for five minutes (`cache-control: public, max-age=300`) and carries a weak `ETag`, so a reader that sends `If-None-Match` gets a 304. `/explore` links it with `<link rel="alternate" type="application/atom+xml">`. Server-Timing names `read` and `render`.
55
56 The terminal's palette has `Explore feed`, which opens or shows the feed's address (copy it into a reader).
src/core/reserved.ts +1
@@ -117,6 +117,7 @@ export const SYSTEM_NAMES: readonly string[] = [
117117 "releases",
118118 "releases.json",
119119 "changelog.atom",
120 "explore.atom",
120121 "sw.js",
121122 "tell",
122123 "talk",
src/http/server.ts +2 −1
@@ -161,7 +161,7 @@ import { confirmPage, searchPage } from "../web/pages/search.ts";
161161 import { chainPage, checkSubmit, modeSubmit, proposeParentSubmit, severSubmit, takeSubmit } from "../web/pages/chain.ts";
162162 import { followingSubmit } from "../web/pages/following.ts";
163163 import { reconnectSubmit } from "../web/pages/frozen.ts";
164 import { activityAtom, changelogAtom } from "../web/feeds.ts";
164 import { activityAtom, changelogAtom, exploreAtom } from "../web/feeds.ts";
165165 import { auditCsv, auditPage, auditProbeSubmit } from "../web/pages/audit.ts";
166166 import { apiPage, appApiPage } from "../web/pages/api.ts";
167167 import { flowsCheck, flowsPage, flowsProbe, flowsUpdate } from "../web/pages/flows.ts";
@@ -846,6 +846,7 @@ export function buildRouter(): Router<Handler> {
846846 router.add("GET", "/front", studioDoor);
847847 // Apps and people ranked by real numbers, narrowed in one line (#607).
848848 router.add("GET", "/explore", explorePage);
849 router.add("GET", "/explore.atom", exploreAtom);
849850 // Forge packs: premade forges to deploy, and the way to help build their apps.
850851 router.add("GET", "/packs", packsPage);
851852 router.add("POST", "/packs/mirror", packsMirror);
src/tui/forge.ts +8
@@ -231,6 +231,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
231231 hint: "on the web",
232232 run: () => openOnWeb(session, ctx, "/explore", "The explorer"),
233233 },
234 {
235 // The same list as an Atom feed (#10967): the terminal hands over
236 // the feed's address, to copy into a reader.
237 id: "explore-feed",
238 title: "Explore feed",
239 hint: "Atom address",
240 run: () => openOnWeb(session, ctx, "/explore.atom", "The explore feed"),
241 },
234242 packsCommand(session, ctx),
235243 ...dataCommand(session, ctx),
236244 fleetCommand(session, ctx),
src/web/feeds.ts +69 −1
@@ -19,7 +19,8 @@ import { eventActor, eventVerb } from "../design/activity.ts";
1919 import { audienceFilter } from "../core/event_audience.ts";
2020 import { listEvents, type ForgeEvent } from "../core/events.ts";
2121 import { getIssuesByRef } from "../core/issues.ts";
22 import { canRead, forkParentFor, getRepo } from "../core/repos.ts";
22 import { canRead, forkParentFor, getRepo, repoDatesFor } from "../core/repos.ts";
23 import { explore, type ExploreQuery, type ExploreRow } from "../actions/explore.ts";
2324 import { getUsersByIds } from "../core/users.ts";
2425 import { shippedAnswer } from "../release/answer.ts";
2526 import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../core/route_timing.ts";
@@ -247,3 +248,70 @@ ${entries.join("\n")}
247248 `;
248249 return new Response(body, { headers: { ...headers, "content-type": "application/atom+xml; charset=utf-8" } });
249250 }
251
252 const EXPLORE_ENTRIES = 30;
253
254 /** The list /explore opens on, for a stranger: ranked public apps, put-away ones left out. */
255 export const EXPLORE_FEED_QUERY: ExploreQuery = { kind: "app", sort: "score", text: "", archived: false, updated: "all" };
256
257 /** A plain-words summary of one ranked app: what it is, then the numbers that are not zero. */
258 export function exploreSummary(row: ExploreRow): string {
259 const n = row.numbers;
260 const facts = [
261 n.forks > 0 ? `${n.forks} fork${n.forks === 1 ? "" : "s"}` : "",
262 n.likes > 0 ? `${n.likes} like${n.likes === 1 ? "" : "s"}` : "",
263 n.ships > 0 ? `${n.ships} shipped this month` : "",
264 ].filter((f) => f !== "");
265 const what = row.description === "" ? `${row.name}, an app on the forge.` : row.description;
266 return facts.length === 0 ? what : `${what} ${facts.join(", ")}.`;
267 }
268
269 /**
270 * `GET /explore.atom`: the apps /explore ranks first for a stranger (read
271 * through the page's own `explore`, never a second query), the newest-made
272 * first, at most 30. Private and archived apps are not in that list, so they
273 * cannot be in this feed.
274 */
275 export async function exploreAtom(ctx: Ctx): Promise<Response> {
276 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
277 const origin = ctx.url.origin;
278 const rows = (await timePhaseAsync(ctx.request, "read", () => explore(ctx.app, undefined, EXPLORE_FEED_QUERY)))
279 .filter((r) => r.repo !== undefined)
280 .slice(0, EXPLORE_ENTRIES);
281 const renderStart = performance.now();
282 const made = rows.map((row) => {
283 const repo = getRepo(ctx.app.db, row.repo!.owner, row.repo!.name);
284 return { row, repo };
285 });
286 const dates = repoDatesFor(ctx.app.db, made.flatMap((m) => (m.repo === undefined ? [] : [m.repo.id])));
287 const dated = made
288 .map((m) => ({ ...m, at: (m.repo === undefined ? undefined : dates.get(m.repo.id)?.createdAt) ?? STARTED }))
289 .sort((a, b) => Date.parse(stamp(b.at)) - Date.parse(stamp(a.at)));
290 const etag = `W/"${Bun.hash(JSON.stringify(dated.map((m) => [m.row.url, m.row.description, m.row.numbers, m.at]))).toString(36)}"`;
291 const headers = { etag, "cache-control": "public, max-age=300" };
292 if (holds(ctx.request.headers.get("if-none-match"), etag)) return new Response(null, { status: 304, headers });
293 const entries = dated.map(({ row, at }) => ` <entry>
294 <id>urn:forge:app:${xml(`${row.repo!.owner}/${row.repo!.name}`)}</id>
295 <title>${xml(row.name)}</title>
296 <updated>${xml(stamp(at))}</updated>
297 <author><name>${xml(row.repo!.owner)}</name></author>
298 <link rel="alternate" type="text/html" href="${xml(`${origin}${row.url}`)}"/>
299 <summary>${xml(exploreSummary(row))}</summary>
300 </entry>`);
301 const updated = dated[0]?.at ?? STARTED;
302 const body = `<?xml version="1.0" encoding="utf-8"?>
303 <feed xmlns="http://www.w3.org/2005/Atom">
304 <id>${xml(origin)}/explore.atom</id>
305 <title>Explore</title>
306 <subtitle>Apps rising and newly made</subtitle>
307 <author><name>forge</name></author>
308 <updated>${xml(stamp(updated))}</updated>
309 <link rel="self" type="application/atom+xml" href="${xml(origin)}/explore.atom"/>
310 <link rel="alternate" type="text/html" href="${xml(origin)}/explore"/>
311 <generator>forge</generator>
312 ${entries.join("\n")}
313 </feed>
314 `;
315 notePhase(ctx.request, "render", performance.now() - renderStart);
316 return new Response(body, { headers: { ...headers, "content-type": "application/atom+xml; charset=utf-8" } });
317 }
src/web/studio/explore.ts +1 −1
@@ -231,7 +231,7 @@ export async function explorePage(ctx: Ctx): Promise<Response> {
231231 pad: false,
232232 extra: "bare",
233233 })}</div>`;
234 const drawn = page({ ctx, title: "Explore", body });
234 const drawn = page({ ctx, title: "Explore", body, feed: { href: "/explore.atom", title: "Explore" } });
235235 // An unknown filter is a 422 here as on the API and the MCP; the page still draws the note and the results.
236236 const response = unknown.length === 0 ? drawn : new Response(drawn.body, { status: 422, headers: drawn.headers });
237237 notePhase(ctx.request, "render", performance.now() - rendering);
test/design-audit.test.ts +1 −1
@@ -15,7 +15,7 @@ const doc = readFileSync("docs/design/audit.md", "utf8");
1515
1616 /** Routes that answer a file, a fragment, a redirect hop or a callback. */
1717 const NOT_PAGES =
18 /^\/(assets\/|\.well-known\/|icons\/|og\/|live\/|__forge\/|go\/|new\/issue$|events$|system\/stream$|talk\/stream$|settings\/teams\/stream$|sw\.js$|robots\.txt$|favicon\.ico$|apple-touch-icon(-precomposed)?\.png$|sitemap\.xml$|manifest\.webmanifest$|opensearch\.xml$|llms\.txt$|openapi\.json$|releases\.json$|changelog\.atom$|docs(\/[^/]*)?$|audit\.csv$|settings\/traffic\/scrapes\/export$|fleet\/graph\/live$|fleet\/graph\.csv$|settings\/origins\/callback$|home\/archived\/|settings\/backups\/live$|api\/v1(\/|$))/;
18 /^\/(assets\/|\.well-known\/|icons\/|og\/|live\/|__forge\/|go\/|new\/issue$|events$|system\/stream$|talk\/stream$|settings\/teams\/stream$|sw\.js$|robots\.txt$|favicon\.ico$|apple-touch-icon(-precomposed)?\.png$|sitemap\.xml$|manifest\.webmanifest$|opensearch\.xml$|llms\.txt$|openapi\.json$|releases\.json$|changelog\.atom$|explore\.atom$|docs(\/[^/]*)?$|audit\.csv$|settings\/traffic\/scrapes\/export$|fleet\/graph\/live$|fleet\/graph\.csv$|settings\/origins\/callback$|home\/archived\/|settings\/backups\/live$|api\/v1(\/|$))/;
1919
2020 const getRoutes = [
2121 ...server.matchAll(/router\.add\(\s*"GET",\s*"([^"]+)"/g),
test/explore-atom.test.ts added +101
@@ -0,0 +1,101 @@
1 /** /explore.atom: the ranked public apps as Atom; never a private or archived one; advertised on /explore. */
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 { createRepo, setArchived } from "../src/actions/repos.ts";
7 import { createApp, type App } from "../src/app.ts";
8 import { createApp as insertApp } from "../src/core/apps.ts";
9 import type { Repo } from "../src/core/repos.ts";
10 import { createUser } from "../src/core/users.ts";
11 import { serve, type Servers } from "../src/http/server.ts";
12 import { checkAtom, parseXml } from "./helpers/atom.ts";
13
14 describe("explore atom", () => {
15 let dataDir: string;
16 let app: App;
17 let servers: Servers;
18 let make: (name: string, visibility: "public" | "private", description?: string) => Promise<Repo>;
19 let user: NonNullable<Parameters<typeof createRepo>[1]>;
20
21 beforeAll(async () => {
22 dataDir = await mkdtemp(join(tmpdir(), "forge-explore-atom-"));
23 app = createApp({ dataDir, port: 0 });
24 servers = await serve(app);
25 const u = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
26 if (!u.ok) throw new Error(u.error);
27 user = u.value;
28 make = async (name: string, visibility: "public" | "private", description = "") => {
29 const r = await createRepo(app, u.value, { name, visibility, description });
30 if (!r.ok) throw new Error(String(r.error));
31 const a = insertApp(app.db, { repoId: r.value.id, slug: `alice-${name}`, access: visibility });
32 if (!a.ok) throw new Error(a.error);
33 return r.value;
34 };
35 await make("shown", "public", "Fish & <chips> \"fresh\"");
36 await make("hidden-private", "private");
37 const old = await make("hidden-archived", "public");
38 const done = await setArchived(app, u.value, old, true, false);
39 if (!done.ok) throw new Error("archive");
40 }, 30_000);
41
42 afterAll(async () => {
43 servers.stop();
44 app.close();
45 await rm(dataDir, { recursive: true, force: true });
46 });
47
48 test("answers Atom with the public app, its link and a plain summary, escaped; no private or archived app", async () => {
49 await fetch(`${servers.url}/explore.atom`); // warm: the first request pays for module and statement setup
50 const res = await fetch(`${servers.url}/explore.atom`);
outdated

500 ms does not prove the 50 ms budget; assert the app duration from Server-Timing instead. No test covers the 30 cap or newest-first ordering.

51 const text = await res.text();
52 expect(res.status).toBe(200);
53 expect(res.headers.get("content-type")).toContain("application/atom+xml");
54 const timing = res.headers.get("server-timing") ?? "";
55 // The app's own time, not `lag` (the test process's event loop, busy when suites run together).
56 const app50 = Number(/app;dur=([\d.]+)/.exec(timing)?.[1]);
57 expect(app50, timing).toBeLessThan(50);
58 const { titles } = checkAtom(text, `${servers.url}/explore.atom`);
59 expect(titles).toContain("alice/shown");
60 expect(titles.join()).not.toContain("hidden");
61 expect(text).not.toContain("hidden-private");
62 expect(text).not.toContain("hidden-archived");
outdated

This tests xml() alone, not a feed title containing <&". Drive it through /explore.atom with such a title.

63 expect(text).toContain("Fish &amp; &lt;chips&gt; &quot;fresh&quot;");
64 expect(text).toContain(`href="${servers.url}/alice/shown"`);
65 const entry = parseXml(text).kids.find((k) => k.name === "entry")!;
66 expect(entry.kids.find((k) => k.name === "summary")!.text).toBe('Fish & <chips> "fresh"');
67 });
68
69 test("an app's free text with <&\" is escaped end to end; a name with it is refused at creation", async () => {
70 const r = await make("tricky", "public", '<&"');
71 expect(r.id).toBeTruthy();
72 const text = await (await fetch(`${servers.url}/explore.atom`)).text();
73 expect(text).toContain("<summary>&lt;&amp;&quot;</summary>");
74 expect(text).not.toContain('<summary><&"');
75 const entry = parseXml(text).kids.find((k) => k.name === "entry" && k.kids.some((c) => c.name === "summary" && c.text === '<&"'));
76 expect(entry).toBeDefined();
77 // Titles are owner/name and names are validated, so no title can carry the characters.
78 const bad = await createRepo(app, user, { name: '<&"', visibility: "public", description: "" });
79 expect(bad.ok).toBe(false);
80 });
81
82 test("holds at most 30 entries, newest-made first", async () => {
83 for (let i = 0; i < 32; i++) await make(`bulk-${String(i).padStart(2, "0")}`, "public");
84 const rows = app.db.query<{ name: string }, []>("SELECT name FROM repo WHERE name LIKE 'bulk-%'").all();
85 rows.forEach((r, i) =>
86 app.db.run("UPDATE repo SET created_at = ? WHERE name = ?", [`2026-01-${String(i + 1).padStart(2, "0")}T00:00:00.000Z`, r.name]),
87 );
88 const text = await (await fetch(`${servers.url}/explore.atom`)).text();
89 const feed = parseXml(text);
90 const entries = feed.kids.filter((k) => k.name === "entry");
91 expect(entries.length).toBe(30);
92 const times = entries.map((e) => Date.parse(e.kids.find((k) => k.name === "updated")!.text));
93 expect(times.every((t, i) => i === 0 || times[i - 1]! >= t)).toBe(true);
94 expect(new Set(times).size).toBeGreaterThan(1);
95 }, 120_000);
96
97 test("/explore advertises the feed", async () => {
98 const html = await (await fetch(`${servers.url}/explore`)).text();
99 expect(html).toMatch(/<link\s+rel="alternate"\s+type="application\/atom\+xml"[^>]*href="\/explore\.atom"/);
100 });
101 });
test/forge-looks.test.ts +1
@@ -36,6 +36,7 @@ const NOT_A_PAGE: Record<string, string> = {
3636 "/releases.json": "JSON feed of /releases",
3737 "/docs": "a redirect to the forge's documentation",
3838 "/changelog.atom": "Atom feed of /changelog",
39 "/explore.atom": "Atom feed of /explore",
3940 "/sitemap.xml": "XML for crawlers",
4041 "/manifest.webmanifest": "the install manifest, JSON",
4142 "/opensearch.xml": "the browser search description, XML",
test/palette-roster.test.ts +4
@@ -47,6 +47,7 @@ test("a site-admin person is offered the whole roster", () => {
4747 expect(roster(admin)).toEqual([
4848 ["home", "Home", "your repositories"],
4949 ["explore", "Explore", "on the web"],
50 ["explore-feed", "Explore feed", "Atom address"],
5051 ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"],
5152 ["mirror-packs", "Mirror the packs' upstreams", "bring each pack's upstream projects in as read-only repositories"],
5253 ["pack-repos", "Pack repositories", "which repositories /packs lists"],
@@ -185,6 +186,7 @@ test("a site-admin agent is offered the roster without the person-only entries",
185186 expect(roster({ ...admin, kind: "agent" })).toEqual([
186187 ["home", "Home", "your repositories"],
187188 ["explore", "Explore", "on the web"],
189 ["explore-feed", "Explore feed", "Atom address"],
188190 ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"],
189191 ["data", "Data", "the SQLite files you may read as a tree; / filters, : asks in SQL, y yanks a row"],
190192 ["fleet", "Fleet rows", "the rows a fleet query line selects"],
@@ -264,6 +266,7 @@ test("a member person is offered the signed-in roster without the admin entries"
264266 expect(roster(member)).toEqual([
265267 ["home", "Home", "your repositories"],
266268 ["explore", "Explore", "on the web"],
269 ["explore-feed", "Explore feed", "Atom address"],
267270 ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"],
268271 ["fleet", "Fleet rows", "the rows a fleet query line selects"],
269272 ["profile", "Open a profile", "username or org"],
@@ -341,6 +344,7 @@ test("signed out, the palette offers sign-in and the public entries", () => {
341344 expect(roster()).toEqual([
342345 ["home", "Home", "your repositories"],
343346 ["explore", "Explore", "on the web"],
347 ["explore-feed", "Explore feed", "Atom address"],
344348 ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"],
345349 ["fleet", "Fleet rows", "the rows a fleet query line selects"],
346350 ["profile", "Open a profile", "username or org"],

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs