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
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/10967-explore-has-an-atom-feed-explore-atom-li → main 4 commits fork 2 threads · 2 unresolved
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 (` | ||
| 48 | 48 | ## Make it yours on an app row |
| 49 | 49 | 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`). |
| 50 | 50 | `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[] = [ | ||
| 117 | 117 | "releases", |
| 118 | 118 | "releases.json", |
| 119 | 119 | "changelog.atom", |
| 120 | "explore.atom", | |
| 120 | 121 | "sw.js", |
| 121 | 122 | "tell", |
| 122 | 123 | "talk", |
src/http/server.ts +2 −1
| @@ -161,7 +161,7 @@ import { confirmPage, searchPage } from "../web/pages/search.ts"; | ||
| 161 | 161 | import { chainPage, checkSubmit, modeSubmit, proposeParentSubmit, severSubmit, takeSubmit } from "../web/pages/chain.ts"; |
| 162 | 162 | import { followingSubmit } from "../web/pages/following.ts"; |
| 163 | 163 | import { reconnectSubmit } from "../web/pages/frozen.ts"; |
| 164 | import { activityAtom, changelogAtom } from "../web/feeds.ts"; | |
| 164 | import { activityAtom, changelogAtom, exploreAtom } from "../web/feeds.ts"; | |
| 165 | 165 | import { auditCsv, auditPage, auditProbeSubmit } from "../web/pages/audit.ts"; |
| 166 | 166 | import { apiPage, appApiPage } from "../web/pages/api.ts"; |
| 167 | 167 | import { flowsCheck, flowsPage, flowsProbe, flowsUpdate } from "../web/pages/flows.ts"; |
| @@ -846,6 +846,7 @@ export function buildRouter(): Router<Handler> { | ||
| 846 | 846 | router.add("GET", "/front", studioDoor); |
| 847 | 847 | // Apps and people ranked by real numbers, narrowed in one line (#607). |
| 848 | 848 | router.add("GET", "/explore", explorePage); |
| 849 | router.add("GET", "/explore.atom", exploreAtom); | |
| 849 | 850 | // Forge packs: premade forges to deploy, and the way to help build their apps. |
| 850 | 851 | router.add("GET", "/packs", packsPage); |
| 851 | 852 | router.add("POST", "/packs/mirror", packsMirror); |
src/tui/forge.ts +8
| @@ -231,6 +231,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 231 | 231 | hint: "on the web", |
| 232 | 232 | run: () => openOnWeb(session, ctx, "/explore", "The explorer"), |
| 233 | 233 | }, |
| 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 | }, | |
| 234 | 242 | packsCommand(session, ctx), |
| 235 | 243 | ...dataCommand(session, ctx), |
| 236 | 244 | fleetCommand(session, ctx), |
src/web/feeds.ts +69 −1
| @@ -19,7 +19,8 @@ import { eventActor, eventVerb } from "../design/activity.ts"; | ||
| 19 | 19 | import { audienceFilter } from "../core/event_audience.ts"; |
| 20 | 20 | import { listEvents, type ForgeEvent } from "../core/events.ts"; |
| 21 | 21 | 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"; | |
| 23 | 24 | import { getUsersByIds } from "../core/users.ts"; |
| 24 | 25 | import { shippedAnswer } from "../release/answer.ts"; |
| 25 | 26 | import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../core/route_timing.ts"; |
| @@ -247,3 +248,70 @@ ${entries.join("\n")} | ||
| 247 | 248 | `; |
| 248 | 249 | return new Response(body, { headers: { ...headers, "content-type": "application/atom+xml; charset=utf-8" } }); |
| 249 | 250 | } |
| 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> { | ||
| 231 | 231 | pad: false, |
| 232 | 232 | extra: "bare", |
| 233 | 233 | })}</div>`; |
| 234 | const drawn = page({ ctx, title: "Explore", body }); | |
| 234 | const drawn = page({ ctx, title: "Explore", body, feed: { href: "/explore.atom", title: "Explore" } }); | |
| 235 | 235 | // An unknown filter is a 422 here as on the API and the MCP; the page still draws the note and the results. |
| 236 | 236 | const response = unknown.length === 0 ? drawn : new Response(drawn.body, { status: 422, headers: drawn.headers }); |
| 237 | 237 | 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"); | ||
| 15 | 15 | |
| 16 | 16 | /** Routes that answer a file, a fragment, a redirect hop or a callback. */ |
| 17 | 17 | 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(\/|$))/; | |
| 19 | 19 | |
| 20 | 20 | const getRoutes = [ |
| 21 | 21 | ...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`); | |
| 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 & <chips> "fresh""); | |
| 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><&"</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> = { | ||
| 36 | 36 | "/releases.json": "JSON feed of /releases", |
| 37 | 37 | "/docs": "a redirect to the forge's documentation", |
| 38 | 38 | "/changelog.atom": "Atom feed of /changelog", |
| 39 | "/explore.atom": "Atom feed of /explore", | |
| 39 | 40 | "/sitemap.xml": "XML for crawlers", |
| 40 | 41 | "/manifest.webmanifest": "the install manifest, JSON", |
| 41 | 42 | "/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", () => { | ||
| 47 | 47 | expect(roster(admin)).toEqual([ |
| 48 | 48 | ["home", "Home", "your repositories"], |
| 49 | 49 | ["explore", "Explore", "on the web"], |
| 50 | ["explore-feed", "Explore feed", "Atom address"], | |
| 50 | 51 | ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"], |
| 51 | 52 | ["mirror-packs", "Mirror the packs' upstreams", "bring each pack's upstream projects in as read-only repositories"], |
| 52 | 53 | ["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", | ||
| 185 | 186 | expect(roster({ ...admin, kind: "agent" })).toEqual([ |
| 186 | 187 | ["home", "Home", "your repositories"], |
| 187 | 188 | ["explore", "Explore", "on the web"], |
| 189 | ["explore-feed", "Explore feed", "Atom address"], | |
| 188 | 190 | ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"], |
| 189 | 191 | ["data", "Data", "the SQLite files you may read as a tree; / filters, : asks in SQL, y yanks a row"], |
| 190 | 192 | ["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" | ||
| 264 | 266 | expect(roster(member)).toEqual([ |
| 265 | 267 | ["home", "Home", "your repositories"], |
| 266 | 268 | ["explore", "Explore", "on the web"], |
| 269 | ["explore-feed", "Explore feed", "Atom address"], | |
| 267 | 270 | ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"], |
| 268 | 271 | ["fleet", "Fleet rows", "the rows a fleet query line selects"], |
| 269 | 272 | ["profile", "Open a profile", "username or org"], |
| @@ -341,6 +344,7 @@ test("signed out, the palette offers sign-in and the public entries", () => { | ||
| 341 | 344 | expect(roster()).toEqual([ |
| 342 | 345 | ["home", "Home", "your repositories"], |
| 343 | 346 | ["explore", "Explore", "on the web"], |
| 347 | ["explore-feed", "Explore feed", "Atom address"], | |
| 344 | 348 | ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"], |
| 345 | 349 | ["fleet", "Fleet rows", "the rows a fleet query line selects"], |
| 346 | 350 | ["profile", "Open a profile", "username or org"], |
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.