Public: anyone can read this; the crew can change it.
/docs reads the forge's documentation on the forge itself: an index and each doc rendered, with no hop to the code host #11081
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/11067-docs-reads-the-forge-s-documentation-on → main 5 commits fork 2 threads · 2 unresolved
8 files
changed +235
−59
docs/features/docs-link.md +2 −2
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | # /docs |
| 2 | 2 | |
| 3 | `GET /docs` redirects (302) to the `docs/` folder of the forge's own repository (`FORGE_SENTINEL_REPO`, default `trevato/forge`) when it is public here; otherwise to the same folder on the upstream that llms.txt's clone line names. `GET /docs/NAME.md` does the same for one file; NAME must match `[A-Za-z0-9_-]+`, anything else is a 404. The target comes from configuration only. Footer and the `?` sheet link "Docs". Code: `src/web/pages/docs_link.ts`. | |
| 3 | `GET /docs` is a server-rendered index of `docs/*.md` in the release's checkout: each file's title (its first `#` line) and a link. `GET /docs/NAME.md` renders that file with the forge's markdown renderer inside the layout. NAME must match `^[A-Za-z0-9][A-Za-z0-9._-]{0,80}\.md$` and be in the folder's listing; anything else, `..` included, is a 404. Links between docs (`OTHER.md`) resolve to `/docs/OTHER.md`. | |
| 4 | 4 | |
| 5 | Only files directly in `docs/` are reachable as `/docs/NAME.md`; files in subfolders (`design/`, `features/`) are not, since NAME cannot contain a slash. Reach them by browsing from `/docs`. | |
| 5 | The listing and each rendered page are kept in memory for the life of the process, since docs change only with the release. The folder is never read from FORGE_APP_DATA. Only files directly in `docs/` are served; subfolders (`design/`, `features/`) are not, and a link to a name outside the listing is left as written. The terminal has the same list: the `Docs` command (`src/tui/screens/docs.ts`); both read `src/view/docs.ts`. Footer and the `?` sheet link "Docs". Code: `src/web/pages/docs_link.ts`. |
src/tui/forge.ts +8
| @@ -81,6 +81,7 @@ import { noteRead, telemetryReader } from "../actions/telemetry.ts"; | ||
| 81 | 81 | import { queuesFor, systemFor } from "../system/telemetry.ts"; |
| 82 | 82 | import { apiScreen } from "./screens/api.ts"; |
| 83 | 83 | import { changelogScreen } from "./screens/changelog.ts"; |
| 84 | import { docsScreen } from "./screens/docs.ts"; | |
| 84 | 85 | import { keeperScreen } from "./screens/keeper.ts"; |
| 85 | 86 | import { releasesScreen } from "./screens/releases.ts"; |
| 86 | 87 | import { mayRead } from "../release/gate.ts"; |
| @@ -514,6 +515,13 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 514 | 515 | hint: "the forge's endpoints; run a GET", |
| 515 | 516 | run: () => ctx.push(apiScreen(session, ctx)), |
| 516 | 517 | }); |
| 518 | // The forge's documentation, as /docs lists it: for anyone. | |
| 519 | commands.push({ | |
| 520 | id: "docs", | |
| 521 | title: "Docs", | |
| 522 | hint: "the forge's documentation; Enter reads one", | |
| 523 | run: () => ctx.push(docsScreen(ctx)), | |
| 524 | }); | |
| 517 | 525 | // The last releases with their pulls, as /changelog shows them: for anyone. |
| 518 | 526 | commands.push({ |
| 519 | 527 | id: "changelog", |
src/tui/screens/docs.ts added +55
| @@ -0,0 +1,55 @@ | ||
| 1 | /** | |
| 2 | * The forge's documentation, the terminal's /docs: the same listing the page | |
| 3 | * makes (`docEntries`), for anyone, a guest included. Enter reads a document, | |
| 4 | * rendered as markdown, scrolled with j and k. | |
| 5 | */ | |
| 6 | ||
| 7 | import { docEntries, docText, type DocEntry } from "../../view/docs.ts"; | |
| 8 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 9 | import { binding } from "../app/keys.ts"; | |
| 10 | import { renderMarkdown } from "../ui/markdown.ts"; | |
| 11 | import { Scroll, ListState, drawLines, drawList, type ListRow } from "./common.ts"; | |
| 12 | ||
| 13 | export function docRows(entries: readonly DocEntry[]): ListRow[] { | |
| 14 | return entries.map((e) => ({ title: e.title })); | |
| 15 | } | |
| 16 | ||
| 17 | function docScreen(ctx: Ctx, entry: DocEntry): Screen { | |
| 18 | const scroll = new Scroll(); | |
| 19 | let cache: { w: number; lines: ReturnType<typeof renderMarkdown> } | undefined; | |
| 20 | const text = docText(entry); | |
| 21 | return { | |
| 22 | title: entry.title, | |
| 23 | keys: [binding(["j", "k"], "scroll"), binding(["esc", "q"], "back")], | |
| 24 | render(area, s) { | |
| 25 | const w = Math.max(20, area.w - 2); | |
| 26 | if (cache === undefined || cache.w !== w) cache = { w, lines: renderMarkdown(text, w, ctx.theme.c) }; | |
| 27 | drawLines(s, area, ctx.theme.c, scroll, cache.lines); | |
| 28 | }, | |
| 29 | key(e) { | |
| 30 | if (e.key === "esc" || e.key === "q") ctx.pop(); | |
| 31 | else scroll.key(e, cache?.lines.length ?? 0); | |
| 32 | return true; | |
| 33 | }, | |
| 34 | }; | |
| 35 | } | |
| 36 | ||
| 37 | export function docsScreen(ctx: Ctx): Screen { | |
| 38 | const state = new ListState(); | |
| 39 | const entries = docEntries(); | |
| 40 | return { | |
| 41 | title: "Docs", | |
| 42 | keys: [binding(["j", "k"], "move"), binding(["enter"], "read"), binding(["esc", "q"], "close")], | |
| 43 | render(area, s) { | |
| 44 | drawList(s, area, ctx.theme.c, state, docRows(entries)); | |
| 45 | }, | |
| 46 | key(e) { | |
| 47 | if (e.key === "esc" || e.key === "q") ctx.pop(); | |
| 48 | else if (e.key === "enter") { | |
| 49 | const entry = entries[state.index]; | |
| 50 | if (entry !== undefined) ctx.push(docScreen(ctx, entry)); | |
| 51 | } else state.key(e, entries.length); | |
| 52 | return true; | |
| 53 | }, | |
| 54 | }; | |
| 55 | } |
src/view/docs.ts added +47
| @@ -0,0 +1,47 @@ | ||
| 1 | /** | |
| 2 | * The forge's own documentation as plain data: the listing of `docs/*.md` in | |
| 3 | * the release's checkout and the text of one file. The page (/docs) and the | |
| 4 | * terminal screen read here, so both show the same list. A name is served | |
| 5 | * only when it is in the listing, so a request's text is looked up, never | |
| 6 | * joined into a path. Docs change only with the release: the listing is kept | |
| 7 | * for the life of the process. | |
| 8 | */ | |
| 9 | ||
| 10 | import { readdirSync, readFileSync } from "node:fs"; | |
| 11 | import { join } from "node:path"; | |
| 12 | ||
| 13 | const DOCS = join(import.meta.dir, "..", "..", "docs"); | |
| 14 | export const DOC_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,80}\.md$/; | |
| 15 | ||
| 16 | export interface DocEntry { | |
| 17 | readonly name: string; | |
| 18 | readonly title: string; | |
| 19 | } | |
| 20 | ||
| 21 | let listing: readonly DocEntry[] | undefined; | |
| 22 | ||
| 23 | /** The first `#` line of a document, or its file name. */ | |
| 24 | export function titleOf(name: string, text: string): string { | |
| 25 | const m = /^# +(.+?)\s*#*\s*$/m.exec(text); | |
| 26 | return m?.[1]?.replace(/[`*_]/g, "") ?? name.slice(0, -3); | |
| 27 | } | |
| 28 | ||
| 29 | export function docEntries(): readonly DocEntry[] { | |
| 30 | if (listing !== undefined) return listing; | |
| 31 | const out: DocEntry[] = []; | |
| 32 | for (const d of readdirSync(DOCS, { withFileTypes: true })) { | |
| 33 | if (!d.isFile() || !DOC_NAME.test(d.name)) continue; | |
| 34 | out.push({ name: d.name, title: titleOf(d.name, readFileSync(join(DOCS, d.name), "utf8")) }); | |
| 35 | } | |
| 36 | out.sort((a, b) => a.name.localeCompare(b.name)); | |
| 37 | return (listing = out); | |
| 38 | } | |
| 39 | ||
| 40 | /** The entry for a name, or undefined when the name is not in the listing. */ | |
| 41 | export function docEntry(name: string): DocEntry | undefined { | |
| 42 | return DOC_NAME.test(name) ? docEntries().find((e) => e.name === name) : undefined; | |
| 43 | } | |
| 44 | ||
| 45 | export function docText(entry: DocEntry): string { | |
| 46 | return readFileSync(join(DOCS, entry.name), "utf8"); | |
| 47 | } |
src/web/pages/docs_link.ts +55 −31
| @@ -1,45 +1,69 @@ | ||
| 1 | 1 | /** |
| 2 | * /docs and /docs/NAME.md: the way into the forge's own documentation. | |
| 3 | * The target is built from configuration alone (the own repository's path | |
| 4 | * and the upstream named in llms.txt), never from the request: only NAME, | |
| 5 | * checked against `[A-Za-z0-9_-]+`, is spliced in, so there is no open | |
| 6 | * redirect. A public own repository answers with a same-origin path; | |
| 7 | * otherwise the same folder on the upstream. | |
| 2 | * /docs and /docs/NAME.md: the forge's own documentation, read on the forge. | |
| 3 | * The folder is the release's checkout (`docs/` beside `src/`), never | |
| 4 | * FORGE_APP_DATA. A name is served only when it is in the listing of that | |
| 5 | * folder, so the request's text is looked up, never joined into a path. | |
| 6 | * Docs change only with the release, so the listing and each rendered doc | |
| 7 | * are kept in memory for the life of the process. | |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | import type { Database } from "bun:sqlite"; | |
| 11 | import { getRepo } from "../../core/repos.ts"; | |
| 12 | import { ownRepoPath } from "../../kernel/own_repo.ts"; | |
| 13 | 10 | import type { Ctx } from "../../http/context.ts"; |
| 11 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; | |
| 14 | 12 | import { notFound } from "../../http/respond.ts"; |
| 15 | import { UPSTREAM_REPO as UPSTREAM } from "../../core/upstream.ts"; | |
| 13 | import { docEntries, docEntry, docText } from "../../view/docs.ts"; | |
| 14 | import { html, raw, type Html } from "../html.ts"; | |
| 15 | import { renderMarkdown } from "../markdown.ts"; | |
| 16 | import { page } from "../layout.ts"; | |
| 17 | import { row } from "../ui.ts"; | |
| 16 | 18 | |
| 17 | const NAME = /^[A-Za-z0-9_-]+$/; | |
| 19 | const rendered = new Map<string, Html>(); | |
| 18 | 20 | |
| 19 | /** Where the docs live for this forge: a path here, or the upstream's URL. */ | |
| 20 | export function docsBase(db: Database, kind: "tree" | "blob"): string { | |
| 21 | const [owner = "", name = ""] = ownRepoPath().split("/"); | |
| 22 | const repo = owner === "" || name === "" ? undefined : getRepo(db, owner, name); | |
| 23 | if (repo !== undefined && repo.visibility === "public") | |
| 24 | return `/${repo.ownerName}/${repo.name}/${kind}/${encodeURIComponent(repo.defaultBranch)}/docs`; | |
| 25 | return `${UPSTREAM}/${kind}/main/docs`; | |
| 21 | /** `OTHER.md` and `./OTHER.md` (with an anchor) become `/docs/OTHER.md` when OTHER.md is in the listing; any other link stays as written. */ | |
| 22 | export function relinkDocs(markup: string, known: ReadonlySet<string> = new Set(docEntries().map((e) => e.name))): string { | |
| 23 | return markup.replace( | |
| 24 | /href="(?:\.\/)?([A-Za-z0-9][A-Za-z0-9._-]{0,80}\.md)(#[^"]*)?"/g, | |
| 25 | (m, name: string, tail: string | undefined) => (known.has(name) ? `href="/docs/${name}${tail ?? ""}"` : m), | |
| 26 | ); | |
| 26 | 27 | } |
| 27 | 28 | |
| 28 | /** The target for the folder (`file` absent) or one file; undefined for a bad NAME. */ | |
| 29 | export function docsTarget(db: Database, file?: string): string | undefined { | |
| 30 | if (file === undefined) return docsBase(db, "tree"); | |
| 31 | if (!file.endsWith(".md") || !NAME.test(file.slice(0, -3))) return undefined; | |
| 32 | return `${docsBase(db, "blob")}/${file}`; | |
| 33 | } | |
| 34 | ||
| 35 | const go = (to: string): Response => | |
| 36 | new Response(null, { status: 302, headers: { location: to, "cache-control": "no-store" } }); | |
| 37 | ||
| 38 | 29 | export function docsFolder(ctx: Ctx): Response { |
| 39 | return go(docsBase(ctx.app.db, "tree")); | |
| 30 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 31 | const began = performance.now(); | |
| 32 | const list = docEntries(); | |
| 33 | const res = page({ | |
| 34 | ctx, | |
| 35 | title: "Documentation", | |
| 36 | description: "How the forge works, read on the forge.", | |
| 37 | crumbs: [{ href: "/docs", label: "Docs" }], | |
| 38 | body: html`<h1>Documentation</h1> | |
| 39 | <ul class="list">${list.map((e) => row({ href: `/docs/${e.name}`, title: e.title }))}</ul>`, | |
| 40 | }); | |
| 41 | notePhase(ctx.request, "render", performance.now() - began); | |
| 42 | return res; | |
| 40 | 43 | } |
| 41 | 44 | |
| 42 | 45 | export function docsFile(ctx: Ctx): Response { |
| 43 | const to = docsTarget(ctx.app.db, ctx.params.file); | |
| 44 | return to === undefined ? notFound() : go(to); | |
| 46 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 47 | const began = performance.now(); | |
| 48 | const name = ctx.params.file ?? ""; | |
| 49 | const entry = docEntry(name); | |
| 50 | if (entry === undefined) return notFound(); | |
| 51 | let body = rendered.get(name); | |
| 52 | if (body === undefined) { | |
| 53 | const text = docText(entry); | |
| 54 | // renderMarkdown escapes every character of the source; relinking only rewrites hrefs it made. | |
| 55 | body = raw(`<div class="prose">${relinkDocs(String(renderMarkdown(text)))}</div>`); | |
| 56 | rendered.set(name, body); | |
| 57 | } | |
| 58 | const res = page({ | |
| 59 | ctx, | |
| 60 | title: entry.title, | |
| 61 | crumbs: [ | |
| 62 | { href: "/docs", label: "Docs" }, | |
| 63 | { href: `/docs/${name}`, label: name }, | |
| 64 | ], | |
| 65 | body, | |
| 66 | }); | |
| 67 | notePhase(ctx.request, "render", performance.now() - began); | |
| 68 | return res; | |
| 45 | 69 | } |
test/docs-link.test.ts +64 −25
| @@ -1,26 +1,22 @@ | ||
| 1 | /** /docs sends a reader to the forge's documentation, from configuration only. */ | |
| 1 | /** /docs reads the forge's documentation on the forge: an index, each doc rendered, nothing outside docs/. */ | |
| 2 | 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; |
| 3 | 3 | import { mkdtemp, rm } from "node:fs/promises"; |
| 4 | 4 | import { tmpdir } from "node:os"; |
| 5 | 5 | import { join } from "node:path"; |
| 6 | 6 | import { createApp } from "../src/app.ts"; |
| 7 | 7 | import { serve, type Servers } from "../src/http/server.ts"; |
| 8 | import { createUser } from "../src/core/users.ts"; | |
| 9 | import { createRepo } from "../src/core/repos.ts"; | |
| 10 | import { UPSTREAM_REPO as UPSTREAM } from "../src/core/upstream.ts"; | |
| 8 | import { relinkDocs } from "../src/web/pages/docs_link.ts"; | |
| 9 | import { docEntries, titleOf } from "../src/view/docs.ts"; | |
| 10 | import { docRows } from "../src/tui/screens/docs.ts"; | |
| 11 | 11 | |
| 12 | 12 | let dataDir: string; |
| 13 | 13 | let app: ReturnType<typeof createApp>; |
| 14 | 14 | let servers: Servers; |
| 15 | let ownerId: string; | |
| 16 | 15 | |
| 17 | 16 | beforeAll(async () => { |
| 18 | 17 | dataDir = await mkdtemp(join(tmpdir(), "forge-docs-")); |
| 19 | 18 | app = createApp({ dataDir, port: 0 }); |
| 20 | 19 | servers = await serve(app); |
| 21 | const made = await createUser(app.db, { origin: "seed", trusted: true, username: "trevato", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 22 | if (!made.ok) throw new Error(made.error); | |
| 23 | ownerId = made.value.id; | |
| 24 | 20 | }, 30_000); |
| 25 | 21 | |
| 26 | 22 | afterAll(async () => { |
| @@ -31,31 +27,74 @@ afterAll(async () => { | ||
| 31 | 27 | |
| 32 | 28 | const get = (path: string) => fetch(`${servers.url}${path}`, { redirect: "manual" }); |
| 33 | 29 | |
| 34 | test("without a public forge repository /docs goes to the upstream", async () => { | |
| 30 | test("/docs is a rendered index with each title and a link, no hop elsewhere", async () => { | |
| 35 | 31 | const res = await get("/docs"); |
| 36 | expect(res.status).toBe(302); | |
| 37 | expect(res.headers.get("location")).toBe(`${UPSTREAM}/tree/main/docs`); | |
| 38 | expect((await get("/docs/VISION.md")).headers.get("location")).toBe(`${UPSTREAM}/blob/main/docs/VISION.md`); | |
| 32 | expect(res.status).toBe(200); | |
| 33 | const text = await res.text(); | |
| 34 | expect(text).toContain('href="/docs/VISION.md"'); | |
| 35 | expect(text).toContain("What the forge is for"); | |
| 36 | expect(text).not.toContain("open-platform.sh/trevato/forge/tree"); | |
| 39 | 37 | }); |
| 40 | 38 | |
| 41 | test("with a public forge repository /docs goes to a same-origin path", async () => { | |
| 42 | const made = await createRepo(app.db, app.reposRoot, { ownerId, name: "forge", visibility: "public" }); | |
| 43 | if (!made.ok) throw new Error(String(made.error)); | |
| 44 | const res = await get("/docs"); | |
| 45 | expect(res.status).toBe(302); | |
| 46 | expect(res.headers.get("location")).toBe("/trevato/forge/tree/main/docs"); | |
| 47 | expect((await get("/docs/PRINCIPLES.md")).headers.get("location")).toBe("/trevato/forge/blob/main/docs/PRINCIPLES.md"); | |
| 39 | test("/docs/VISION.md renders the file in the layout", async () => { | |
| 40 | const res = await get("/docs/VISION.md"); | |
| 41 | expect(res.status).toBe(200); | |
| 42 | const text = await res.text(); | |
| 43 | expect(text).toContain("What the forge is for"); | |
| 44 | expect(text).toContain('class="topbar"'); | |
| 45 | expect(res.headers.get("x-content-type-options")).toBe("nosniff"); | |
| 46 | expect(res.headers.get("content-security-policy")).not.toBeNull(); | |
| 47 | const again = await get("/docs/VISION.md"); | |
| 48 | expect(await again.text()).toBe(text); | |
| 48 | 49 | }); |
| 49 | 50 | |
| 50 | test("a name outside [A-Za-z0-9_-]+ is a 404, never a redirect", async () => { | |
| 51 | for (const bad of ["..%2Fx", "..%2Fx.md", "a.b.md", "x", "%2F%2Fevil.md", ".md", "a%20b.md"]) | |
| 51 | test("traversal and unknown names are a 404", async () => { | |
| 52 | for (const bad of ["..%2FREADME.md", "..%2F..%2Fpackage.json", "%2e%2e%2fVISION.md", "NOPE-NOT-HERE.md", "VISION.txt", "a%20b.md", "features", ".md"]) | |
| 52 | 53 | expect((await get(`/docs/${bad}`)).status).toBe(404); |
| 53 | 54 | }); |
| 54 | 55 | |
| 55 | test("the footer links Docs", async () => { | |
| 56 | expect(await (await fetch(`${servers.url}/changelog`)).text()).toContain('<a href="/docs">Docs</a>'); | |
| 56 | test("both pages and a 404 carry the floor and name their time", async () => { | |
| 57 | for (const path of ["/docs", "/docs/VISION.md", "/docs/NOPE.md"]) { | |
| 58 | const res = await get(path); | |
| 59 | expect(res.headers.get("x-content-type-options")).toBe("nosniff"); | |
| 60 | expect(res.headers.get("content-security-policy")).not.toBeNull(); | |
| 61 | expect(res.headers.get("referrer-policy")).not.toBeNull(); | |
| 62 | if (res.status === 200) expect(res.headers.get("server-timing")).toContain("render"); | |
| 63 | } | |
| 57 | 64 | }); |
| 58 | 65 | |
| 59 | test("the ? sheet carries a Docs link", async () => { | |
| 60 | expect(await (await fetch(`${servers.url}/changelog`)).text()).toContain('<a class="btn" data-variant="link" href="/docs">Docs</a>'); | |
| 66 | test("the index is rows, the document is prose, neither is a card", async () => { | |
| 67 | const index = await (await get("/docs")).text(); | |
| 68 | expect(index).toContain('<ul class="list">'); | |
| 69 | expect(index).not.toContain("VISION.md</small>"); | |
| 70 | expect(index).not.toContain('class="card prose"'); | |
| 71 | const doc = await (await get("/docs/VISION.md")).text(); | |
| 72 | expect(doc).toContain('<div class="prose">'); | |
| 73 | expect(doc).not.toContain('class="card prose"'); | |
| 74 | }); | |
| 75 | ||
| 76 | test("the second request is served from the map: the same body", async () => { | |
| 77 | const a = await (await get("/docs/PRINCIPLES.md")).text(); | |
| 78 | const b = await (await get("/docs/PRINCIPLES.md")).text(); | |
| 79 | expect(b).toBe(a); | |
| 80 | expect(docEntries()).toBe(docEntries()); | |
| 81 | }); | |
| 82 | ||
| 83 | test("the footer and the ? sheet link Docs", async () => { | |
| 84 | const text = await (await fetch(`${servers.url}/changelog`)).text(); | |
| 85 | expect(text).toContain('<a href="/docs">Docs</a>'); | |
| 86 | expect(text).toContain('<a class="btn" data-variant="link" href="/docs">Docs</a>'); | |
| 87 | }); | |
| 88 | ||
| 89 | test("the terminal lists the same docs as the page", () => { | |
| 90 | expect(docRows(docEntries()).map((r) => r.title)).toEqual(docEntries().map((e) => e.title)); | |
| 91 | }); | |
| 92 | ||
| 93 | test("a link is rewritten only when the name is in the listing; titles come from the first # line", () => { | |
| 94 | const known = new Set(["OTHER.md", "B.md"]); | |
| 95 | expect(relinkDocs('<a href="OTHER.md#x" rel="n">a</a> <a href="./B.md">b</a> <a href="https://x/C.md">c</a> <a href="C.md">d</a>', known)).toBe( | |
| 96 | '<a href="/docs/OTHER.md#x" rel="n">a</a> <a href="/docs/B.md">b</a> <a href="https://x/C.md">c</a> <a href="C.md">d</a>', | |
| 97 | ); | |
| 98 | expect(titleOf("X.md", "intro\n# The `Title`\nbody")).toBe("The Title"); | |
| 99 | expect(titleOf("X.md", "no heading")).toBe("X"); | |
| 61 | 100 | }); |
test/palette-roster.test.ts +4
| @@ -120,6 +120,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 120 | 120 | ["scrapes", "Scrapes", "what was read by repository, and groups in a running capture"], |
| 121 | 121 | ["traffic", "Traffic", "who is using the forge's answering time, and the top readers"], |
| 122 | 122 | ["api", "API", "the forge's endpoints; run a GET"], |
| 123 | ["docs", "Docs", "the forge's documentation; Enter reads one"], | |
| 123 | 124 | ["changelog", "Changelog", "the last 30 releases and their pulls"], |
| 124 | 125 | ["releases", "Releases", "the live release and the kept ones"], |
| 125 | 126 | ["keeper", "Release keeper", "what holds the next cut, and each release it cut with its probes"], |
| @@ -225,6 +226,7 @@ test("a site-admin agent is offered the roster without the person-only entries", | ||
| 225 | 226 | ["resilience", "Resilience", "where snapshots go and the mirror; each protection green, red or untested"], |
| 226 | 227 | ["origin-apps", "Connection apps", "your OAuth apps for connecting accounts; a adds, d removes"], |
| 227 | 228 | ["api", "API", "the forge's endpoints; run a GET"], |
| 229 | ["docs", "Docs", "the forge's documentation; Enter reads one"], | |
| 228 | 230 | ["changelog", "Changelog", "the last 30 releases and their pulls"], |
| 229 | 231 | ["releases", "Releases", "the live release and the kept ones"], |
| 230 | 232 | ["keeper", "Release keeper", "what holds the next cut, and each release it cut with its probes"], |
| @@ -314,6 +316,7 @@ test("a member person is offered the signed-in roster without the admin entries" | ||
| 314 | 316 | ["signins", "Signed in", "your browsers and tokens; d signs a browser out"], |
| 315 | 317 | ["origin-apps", "Connection apps", "your OAuth apps for connecting accounts; a adds, d removes"], |
| 316 | 318 | ["api", "API", "the forge's endpoints; run a GET"], |
| 319 | ["docs", "Docs", "the forge's documentation; Enter reads one"], | |
| 317 | 320 | ["changelog", "Changelog", "the last 30 releases and their pulls"], |
| 318 | 321 | ["stalled", "Stalled steps", "pulls whose check is red or was never asked for; enter re-runs"], |
| 319 | 322 | ["file-holds", "Held files", "hold new work on a file, release it, or list the holds"], |
| @@ -370,6 +373,7 @@ test("signed out, the palette offers sign-in and the public entries", () => { | ||
| 370 | 373 | ["settings", "Repository settings", "owner/name"], |
| 371 | 374 | ["signin", "Sign in", undefined], |
| 372 | 375 | ["api", "API", "the forge's endpoints; run a GET"], |
| 376 | ["docs", "Docs", "the forge's documentation; Enter reads one"], | |
| 373 | 377 | ["changelog", "Changelog", "the last 30 releases and their pulls"], |
| 374 | 378 | ["bugs", "Bugs", "open bugs and the bug clock"], |
| 375 | 379 | ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"], |
test/server-timing-sweep.test.ts −1
| @@ -47,7 +47,6 @@ const REDIRECTS = new Set([ | ||
| 47 | 47 | "/alice/forge/tells", |
| 48 | 48 | "/alice/forge/tells/new", |
| 49 | 49 | "/big", |
| 50 | "/docs", | |
| 51 | 50 | "/login", |
| 52 | 51 | "/openapi.json", |
| 53 | 52 | "/settings/origins/callback", |
Principles 6 and 7:
card proseis a box around a list. Make the index rows, not a card; drop the repeated file name (principle 1).