Replace the docs placeholder with real pages

fbf9cccdb9a5 · AtlantisPleb · · parent da37075f832d

Replace the docs placeholder with real pages

/docs was four cards describing sections that did not exist. It is now
sixteen Markdown pages, a sidebar, and a per-page table of contents.

Every page documents something a visitor can reach today: issues, creating
issues, labels, milestones, assignees, projects, code and commit browsing,
the changelog, status, the leaderboard, API tokens, and the REST and status
APIs. A docs site that mixes shipped features with planned ones is worse
than one missing pages, because the reader cannot tell which half they are
in -- so DocsCatalogTest resolves every page's documented route against the
router and fails when one stops existing. It earned that immediately by
catching my own error: code browsing lives under a literal owner scope, not
/:repo.

Pages are Markdown under priv/docs, because documentation is content and
belongs in files a writer can edit rather than in HEEx they cannot. They
render through OpenAgents.Markdown -- the same safe CommonMark path model
output takes.

That renderer emits no heading ids and should not start: its output
validation is a security boundary, not a formatting choice. Anchors are
added in the docs layer instead, derived from the same headings/1 result
that builds the rail, so the rail cannot link to an id the body lacks. A
test asserts exactly that.

/docs and /docs/:slug are one LiveView, so moving between pages patches and
the sidebar keeps its scroll and its collapsed sections. The sidebar reuses
sidebar_section/1 and sidebar_link/1, so both sidebars in the application
are one idea.

The REST API page lists the six known divergences from GitHub rather than
implying parity, so a client author finds them here instead of in
production.

Route authority: /docs/:slug is declared public, which the route authority
test required before it would pass.

mix precommit green: 1430 tests, 17 JS.

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified assets/css/openagents.css
  • modified lib/openagents_web/components/layouts/docs.html.heex
  • added lib/openagents_web/docs_catalog.ex
  • modified lib/openagents_web/live/docs_live.ex
  • modified lib/openagents_web/route_authority.ex
  • modified lib/openagents_web/router.ex
  • added priv/docs/api-tokens.md
  • added priv/docs/assignees.md
  • added priv/docs/browsing-code.md
  • added priv/docs/changelog.md
  • added priv/docs/commits.md
  • added priv/docs/creating-issues.md
  • added priv/docs/issues.md
  • added priv/docs/labels.md
  • added priv/docs/leaderboard.md
  • added priv/docs/milestones.md
  • added priv/docs/projects.md
  • added priv/docs/rest-api.md
  • added priv/docs/signing-in.md
  • added priv/docs/status-api.md
  • added priv/docs/status.md
  • added priv/docs/welcome.md
  • added test/openagents_web/docs_catalog_test.exs

Diff

23 files changed, +1018 -99

assets/css/openagents.css modified +203

@@ -2132,3 +2132,206 @@

2132 2132
    }
2133 2133
  }
2134 2134
}
2135
2136
/* ── Docs page: body and table of contents ────────────────────────────────── */
2137
2138
/* Two columns: the prose, and the page's own headings. The rail is fixed-width
2139
 * and the prose is capped, because a measure much past ~70 characters costs
2140
 * more in return sweeps than it gains in fewer lines. */
2141
2142
@layer components {
2143
  .docs-page {
2144
    display: flex;
2145
    align-items: flex-start;
2146
    gap: 48px;
2147
  }
2148
2149
  .docs-prose {
2150
    min-width: 0;
2151
    max-width: 68ch;
2152
    flex: 1;
2153
    color: var(--text-body);
2154
  }
2155
2156
  .docs-toc {
2157
    position: sticky;
2158
    top: 32px;
2159
    flex: none;
2160
    width: 200px;
2161
    border-left: 1px solid var(--line);
2162
    padding-left: 16px;
2163
  }
2164
2165
  .docs-toc__title {
2166
    margin: 0 0 8px;
2167
    color: var(--text-dim);
2168
    font-size: 0.75rem;
2169
  }
2170
2171
  .docs-toc ul {
2172
    display: flex;
2173
    flex-direction: column;
2174
    gap: 6px;
2175
    margin: 0;
2176
    padding: 0;
2177
    list-style: none;
2178
  }
2179
2180
  .docs-toc a {
2181
    color: var(--text-muted);
2182
    font-size: 0.8125rem;
2183
    line-height: 1.25;
2184
    text-decoration: none;
2185
    transition: color 150ms var(--ease);
2186
  }
2187
2188
  @media (hover: hover) {
2189
    .docs-toc a:hover {
2190
      color: var(--text-primary);
2191
    }
2192
  }
2193
2194
  /* Depth by indent rather than by size: a nested heading is subordinate, not
2195
     less important, and shrinking it would say the wrong thing. */
2196
  .docs-toc li[data-level="3"] {
2197
    padding-left: 12px;
2198
  }
2199
2200
  /* The rail costs more than it returns once it would crowd the prose. */
2201
  @media (max-width: 1100px) {
2202
    .docs-toc {
2203
      display: none;
2204
    }
2205
  }
2206
}
2207
2208
/* ── Rendered Markdown ────────────────────────────────────────────────────── */
2209
2210
@layer components {
2211
  .docs-prose h1 {
2212
    margin: 0 0 8px;
2213
    color: var(--text-primary);
2214
    font-size: 2rem;
2215
    font-weight: 600;
2216
    letter-spacing: -0.02em;
2217
    line-height: 1.2;
2218
  }
2219
2220
  .docs-prose h2 {
2221
    margin: 40px 0 12px;
2222
    color: var(--text-primary);
2223
    font-size: 1.375rem;
2224
    font-weight: 600;
2225
    letter-spacing: -0.01em;
2226
    scroll-margin-top: 24px;
2227
  }
2228
2229
  .docs-prose h3 {
2230
    margin: 28px 0 8px;
2231
    color: var(--text-primary);
2232
    font-size: 1.0625rem;
2233
    font-weight: 600;
2234
    scroll-margin-top: 24px;
2235
  }
2236
2237
  .docs-prose p {
2238
    margin: 0 0 16px;
2239
    line-height: 1.7;
2240
  }
2241
2242
  .docs-prose ul,
2243
  .docs-prose ol {
2244
    margin: 0 0 16px;
2245
    padding-left: 22px;
2246
    line-height: 1.7;
2247
  }
2248
2249
  .docs-prose ul {
2250
    list-style: disc;
2251
  }
2252
2253
  .docs-prose ol {
2254
    list-style: decimal;
2255
  }
2256
2257
  .docs-prose li {
2258
    margin-bottom: 6px;
2259
  }
2260
2261
  .docs-prose a {
2262
    color: var(--accent-bright);
2263
    text-decoration: underline;
2264
    text-underline-offset: 2px;
2265
  }
2266
2267
  @media (hover: hover) {
2268
    .docs-prose a:hover {
2269
      text-decoration: none;
2270
    }
2271
  }
2272
2273
  .docs-prose code {
2274
    padding: 1px 5px;
2275
    border: 1px solid var(--line);
2276
    border-radius: 4px;
2277
    background: var(--ink-raised);
2278
    color: var(--text-primary);
2279
    font-family: var(--font-mono);
2280
    font-size: 0.8125em;
2281
  }
2282
2283
  /* A fenced block is already a panel; the inline chrome would double it. */
2284
  .docs-prose pre {
2285
    margin: 0 0 16px;
2286
    padding: 14px 16px;
2287
    overflow-x: auto;
2288
    border: 1px solid var(--line);
2289
    border-radius: var(--radius-md);
2290
    background: var(--ink-raised);
2291
  }
2292
2293
  .docs-prose pre code {
2294
    padding: 0;
2295
    border: 0;
2296
    background: transparent;
2297
    font-size: 0.8125rem;
2298
    line-height: 1.6;
2299
  }
2300
2301
  .docs-prose blockquote {
2302
    margin: 0 0 16px;
2303
    padding-left: 16px;
2304
    border-left: 2px solid var(--line-strong);
2305
    color: var(--text-muted);
2306
  }
2307
2308
  .docs-prose hr {
2309
    margin: 32px 0;
2310
    border: 0;
2311
    border-top: 1px solid var(--line);
2312
  }
2313
2314
  .docs-prose strong {
2315
    color: var(--text-primary);
2316
    font-weight: 600;
2317
  }
2318
2319
  .docs-prose table {
2320
    width: 100%;
2321
    margin: 0 0 16px;
2322
    border-collapse: collapse;
2323
    font-size: 0.875rem;
2324
  }
2325
2326
  .docs-prose th,
2327
  .docs-prose td {
2328
    padding: 8px 10px;
2329
    border-bottom: 1px solid var(--line);
2330
    text-align: left;
2331
  }
2332
2333
  .docs-prose th {
2334
    color: var(--text-primary);
2335
    font-weight: 600;
2336
  }
2337
}
lib/openagents_web/components/layouts/docs.html.heex modified +46 -77

@@ -11,85 +11,54 @@

11 11
    </nav>
12 12
13 13
    <nav class="docs-sidebar__nav" aria-label="Documentation">
14
      <section class="docs-sidebar__section">
15
        <h3 class="sidebar-section-label">Get started</h3>
16
        <div class="sidebar-row">
17
          <a href="#" class="sidebar-row__hit" aria-label="Welcome"></a>
18
          <span class="sidebar-row__content">
19
            <span class="sidebar-row__icon"><.icon name="book" /></span>
20
            <span class="sidebar-row__label">Welcome</span>
21
          </span>
22
        </div>
23
        <div class="sidebar-row">
24
          <a href="#" class="sidebar-row__hit" aria-label="Quickstart"></a>
25
          <span class="sidebar-row__content">
26
            <span class="sidebar-row__icon"><.icon name="book-open" /></span>
27
            <span class="sidebar-row__label">Quickstart</span>
28
          </span>
29
        </div>
30
        <div class="sidebar-row">
31
          <a href="#" class="sidebar-row__hit" aria-label="Installation"></a>
32
          <span class="sidebar-row__content">
33
            <span class="sidebar-row__icon"><.icon name="book-closed" /></span>
34
            <span class="sidebar-row__label">Installation</span>
35
          </span>
36
        </div>
37
      </section>
14
      <Layouts.sidebar_link
15
        path={~p"/docs"}
16
        label="Overview"
17
        icon="book"
18
        selected={@active_page == :index}
19
        patchable={true}
20
      />
38 21
39
      <section class="docs-sidebar__section">
40
        <h3 class="sidebar-section-label">Guides</h3>
41
        <div class="sidebar-row">
42
          <a href="#" class="sidebar-row__hit" aria-label="Authentication"></a>
43
          <span class="sidebar-row__content">
44
            <span class="sidebar-row__icon"><.icon name="document" /></span>
45
            <span class="sidebar-row__label">Authentication</span>
46
          </span>
47
        </div>
48
        <div class="sidebar-row">
49
          <a href="#" class="sidebar-row__hit" aria-label="Agents"></a>
50
          <span class="sidebar-row__content">
51
            <span class="sidebar-row__icon"><.icon name="file-document" /></span>
52
            <span class="sidebar-row__label">Agents</span>
53
          </span>
54
        </div>
55
        <div class="sidebar-row">
56
          <a href="#" class="sidebar-row__hit" aria-label="Forge"></a>
57
          <span class="sidebar-row__content">
58
            <span class="sidebar-row__icon"><.icon name="folder" /></span>
59
            <span class="sidebar-row__label">Forge</span>
60
          </span>
61
        </div>
62
      </section>
63
64
      <section class="docs-sidebar__section">
65
        <h3 class="sidebar-section-label">Reference</h3>
66
        <div class="sidebar-row">
67
          <a href="#" class="sidebar-row__hit" aria-label="API"></a>
68
          <span class="sidebar-row__content">
69
            <span class="sidebar-row__icon"><.icon name="document" /></span>
70
            <span class="sidebar-row__label">API</span>
71
          </span>
72
        </div>
73
        <div class="sidebar-row">
74
          <a href="#" class="sidebar-row__hit" aria-label="CLI"></a>
75
          <span class="sidebar-row__content">
76
            <span class="sidebar-row__icon"><.icon name="document" /></span>
77
            <span class="sidebar-row__label">CLI</span>
78
          </span>
79
        </div>
80
        <div class="sidebar-row">
81
          <a href="#" class="sidebar-row__hit" aria-label="Config"></a>
82
          <span class="sidebar-row__content">
83
            <span class="sidebar-row__icon"><.icon name="document" /></span>
84
            <span class="sidebar-row__label">Config</span>
85
          </span>
86
        </div>
87
      </section>
22
      <Layouts.sidebar_section
23
        :for={section <- OpenAgentsWeb.DocsCatalog.sections()}
24
        title={section.title}
25
        open={Enum.any?(section.items, &(&1.slug == @active_page))}
26
      >
27
        <Layouts.sidebar_link
28
          :for={item <- section.items}
29
          path={~p"/docs/#{item.slug}"}
30
          label={item.title}
31
          icon={item.icon}
32
          selected={@active_page == item.slug}
33
          patchable={true}
34
        />
35
      </Layouts.sidebar_section>
88 36
    </nav>
89 37
  </aside>
90 38
91
  <main class="docs-main">
92
    <.flash_group flash={@flash} />
93
    {@inner_content}
94
  </main>
39
  <div class="docs-column">
40
    <header class="docs-header">
41
      <UI.breadcrumb label="Documentation">
42
        <:item navigate={~p"/docs"}>Docs</:item>
43
        <:item :if={assigns[:section_title]}>{@section_title}</:item>
44
        <:item :if={assigns[:page]}>{@page.item.title}</:item>
45
      </UI.breadcrumb>
46
47
      <div class="docs-header__controls">
48
        <Layouts.theme_toggle />
49
        <UI.copy_button
50
          :if={assigns[:page]}
51
          id="copy-docs-link"
52
          text={"#{OpenAgentsWeb.Endpoint.url()}/docs/#{@page.item.slug}"}
53
          label="Copy link"
54
          copied_label="Copied"
55
        />
56
      </div>
57
    </header>
58
59
    <main class="docs-main">
60
      <.flash_group flash={@flash} />
61
      {@inner_content}
62
    </main>
63
  </div>
95 64
</div>
lib/openagents_web/docs_catalog.ex added +193

@@ -0,0 +1,193 @@

1
defmodule OpenAgentsWeb.DocsCatalog do
2
  @moduledoc """
3
  The documentation's table of contents, and the loader for its pages.
4
5
  Pages are Markdown files under `priv/docs`, read at runtime and rendered
6
  through `OpenAgents.Markdown`, which is the same safe CommonMark path the
7
  chat surface uses. Documentation is content, not code, so it lives in files
8
  a writer can edit rather than in HEEx a writer cannot.
9
10
  Every page here documents something a visitor can actually reach today. A
11
  documentation site that describes features that do not exist is worse than
12
  one that is missing pages, because a reader cannot tell which half they are
13
  in. `route` is the surface each page describes, and `DocsCatalogTest`
14
  asserts every one of them resolves in the router.
15
  """
16
17
  @sections [
18
    %{
19
      title: "Getting started",
20
      items: [
21
        %{slug: "welcome", title: "Welcome", icon: "book", route: "/"},
22
        %{slug: "signing-in", title: "Signing in", icon: "user", route: "/"},
23
        %{slug: "api-tokens", title: "API tokens", icon: "key", route: "/settings/api-tokens"}
24
      ]
25
    },
26
    %{
27
      title: "Issues",
28
      items: [
29
        %{slug: "issues", title: "Issues", icon: "file-document", route: "/:owner/:repo/issues"},
30
        %{
31
          slug: "creating-issues",
32
          title: "Creating issues",
33
          icon: "square-plus",
34
          route: "/:owner/:repo/issues/new"
35
        },
36
        %{slug: "labels", title: "Labels", icon: "tag", route: "/:owner/:repo/labels"},
37
        %{
38
          slug: "milestones",
39
          title: "Milestones",
40
          icon: "flag",
41
          route: "/:owner/:repo/milestones"
42
        },
43
        %{
44
          slug: "assignees",
45
          title: "Assignees",
46
          icon: "user",
47
          route: "/:owner/:repo/assignees"
48
        }
49
      ]
50
    },
51
    %{
52
      title: "Projects",
53
      items: [
54
        %{slug: "projects", title: "Projects", icon: "grid", route: "/:owner/:repo/projects"}
55
      ]
56
    },
57
    %{
58
      title: "Code",
59
      items: [
60
        %{
61
          slug: "browsing-code",
62
          title: "Browsing code",
63
          icon: "code",
64
          route: "/OpenAgentsInc/:repo"
65
        },
66
        %{
67
          slug: "commits",
68
          title: "Commits",
69
          icon: "cube",
70
          route: "/OpenAgentsInc/:repo/commit/:sha"
71
        }
72
      ]
73
    },
74
    %{
75
      title: "Transparency",
76
      items: [
77
        %{slug: "changelog", title: "Changelog", icon: "text", route: "/changelog"},
78
        %{slug: "status", title: "Status", icon: "check-circle", route: "/status"},
79
        %{slug: "leaderboard", title: "Leaderboard", icon: "star", route: "/leaderboard"}
80
      ]
81
    },
82
    %{
83
      title: "API",
84
      items: [
85
        %{
86
          slug: "rest-api",
87
          title: "REST API",
88
          icon: "square-code",
89
          route: "/api/v3/repos/:owner/:repo/issues"
90
        },
91
        %{slug: "status-api", title: "Status API", icon: "info", route: "/api/status"}
92
      ]
93
    }
94
  ]
95
96
  @doc "Sidebar sections, in reading order."
97
  def sections, do: @sections
98
99
  @doc "Every page, flattened."
100
  def items, do: Enum.flat_map(@sections, & &1.items)
101
102
  @doc "Every slug."
103
  def slugs, do: Enum.map(items(), & &1.slug)
104
105
  @doc "Look up one page by slug, or nil."
106
  def fetch(slug), do: Enum.find(items(), &(&1.slug == slug))
107
108
  @doc "The section title a page belongs to."
109
  def section_title(slug) do
110
    Enum.find_value(@sections, fn section ->
111
      if Enum.any?(section.items, &(&1.slug == slug)), do: section.title
112
    end)
113
  end
114
115
  @doc "Directory holding the Markdown sources."
116
  def source_dir, do: Application.app_dir(:openagents, "priv/docs")
117
118
  @doc """
119
  Read and render one page.
120
121
  Returns the rendered HTML and the headings found in it, so a page and its
122
  table of contents come from one parse rather than two that can disagree.
123
  """
124
  def render(slug) do
125
    with %{} = item <- fetch(slug),
126
         path = Path.join(source_dir(), "#{slug}.md"),
127
         {:ok, markdown} <- File.read(path) do
128
      toc = headings(markdown)
129
      html = markdown |> OpenAgents.Markdown.to_html() |> anchor_headings(toc)
130
131
      {:ok, %{item: item, html: html, toc: toc}}
132
    else
133
      _ -> :error
134
    end
135
  end
136
137
  # The shared Markdown renderer emits no heading ids, and it should not start:
138
  # it is the path untrusted model output takes, and its output validation is a
139
  # security boundary rather than a formatting choice. Docs need anchors, so
140
  # they are added here, from the same headings/1 result that builds the table
141
  # of contents -- one source, so the rail cannot link to an id the body lacks.
142
  defp anchor_headings({:safe, html}, toc), do: {:safe, anchor_headings(html, toc)}
143
144
  defp anchor_headings(html, toc) when is_binary(html) do
145
    Enum.reduce(toc, html, fn %{title: title, level: level, id: id}, acc ->
146
      String.replace(
147
        acc,
148
        "<h#{level}>#{Phoenix.HTML.html_escape(title) |> Phoenix.HTML.safe_to_string()}</h#{level}>",
149
        ~s(<h#{level} id="#{id}">#{Phoenix.HTML.html_escape(title) |> Phoenix.HTML.safe_to_string()}</h#{level}>),
150
        global: false
151
      )
152
    end)
153
  end
154
155
  @doc """
156
  The `##` and `###` headings of a Markdown source, with anchor ids.
157
158
  Parsed from the source rather than the rendered HTML: the renderer escapes
159
  and rewrites, and a table of contents that disagrees with the anchors it
160
  links to is worse than none.
161
  """
162
  def headings(markdown) do
163
    markdown
164
    |> String.split("\n")
165
    |> Enum.reduce({[], false}, fn line, {acc, in_fence} ->
166
      cond do
167
        String.starts_with?(line, "```") -> {acc, not in_fence}
168
        in_fence -> {acc, in_fence}
169
        true -> {collect_heading(acc, line), in_fence}
170
      end
171
    end)
172
    |> elem(0)
173
    |> Enum.reverse()
174
  end
175
176
  defp collect_heading(acc, "### " <> title), do: [heading(title, 3) | acc]
177
  defp collect_heading(acc, "## " <> title), do: [heading(title, 2) | acc]
178
  defp collect_heading(acc, _line), do: acc
179
180
  defp heading(title, level) do
181
    title = String.trim(title)
182
    %{title: title, level: level, id: anchor(title)}
183
  end
184
185
  @doc "The anchor id for a heading, matching what the renderer emits."
186
  def anchor(title) do
187
    title
188
    |> String.downcase()
189
    |> String.replace(~r/[^a-z0-9\s-]/u, "")
190
    |> String.trim()
191
    |> String.replace(~r/\s+/, "-")
192
  end
193
end
lib/openagents_web/live/docs_live.ex modified +72 -22

@@ -1,44 +1,94 @@

1 1
defmodule OpenAgentsWeb.DocsLive do
2 2
  @moduledoc """
3
  A placeholder docs landing page.
3
  Renders the Markdown pages catalogued in `OpenAgentsWeb.DocsCatalog`.
4
5
  `/docs` is the index and `/docs/:slug` is a page; both are this LiveView, so
6
  moving between them patches rather than remounts, and the sidebar keeps its
7
  scroll position and whatever the reader collapsed.
8
9
  Markdown goes through `OpenAgents.Markdown`, the same safe CommonMark path
10
  the chat surface uses. Documentation is authored content, but it renders with
11
  the same guarantees as content from a model: no raw HTML passthrough.
4 12
  """
5 13
6 14
  use OpenAgentsWeb, :live_view
7 15
16
  alias OpenAgentsWeb.DocsCatalog
17
8 18
  @impl true
9 19
  def mount(_params, _session, socket) do
10
    {:ok, assign(socket, :page_title, "Docs")}
20
    {:ok, assign(socket, :sections, DocsCatalog.sections())}
11 21
  end
12 22
13 23
  @impl true
14
  def render(assigns) do
24
  def handle_params(_params, _uri, %{assigns: %{live_action: :index}} = socket) do
25
    {:noreply,
26
     socket
27
     |> assign(:page_title, "Docs")
28
     |> assign(:active_page, :index)
29
     |> assign(:section_title, nil)
30
     |> assign(:page, nil)}
31
  end
32
33
  def handle_params(%{"slug" => slug}, _uri, socket) do
34
    case DocsCatalog.render(slug) do
35
      {:ok, page} ->
36
        {:noreply,
37
         socket
38
         |> assign(:page_title, page.item.title)
39
         |> assign(:active_page, page.item.slug)
40
         |> assign(:section_title, DocsCatalog.section_title(page.item.slug))
41
         |> assign(:page, page)}
42
43
      :error ->
44
        {:noreply,
45
         socket
46
         |> put_flash(:error, "No such page: #{slug}")
47
         |> push_navigate(to: ~p"/docs")}
48
    end
49
  end
50
51
  @impl true
52
  def render(%{live_action: :index} = assigns) do
15 53
    ~H"""
16
    <div class="max-w-3xl mx-auto">
17
      <h1 class="text-3xl font-semibold mb-4">Documentation</h1>
18
      <p class="text-muted-foreground mb-8">
19
        The OpenAgents docs are a work in progress. This placeholder shows the structure we will build out next.
54
    <div id="docs-index" class="docs-prose">
55
      <h1>Documentation</h1>
56
      <p>
57
        Everything documented here is something you can reach today. Where a page
58
        describes a surface, that surface exists and is clickable.
20 59
      </p>
21 60
22
      <div class="grid gap-4 sm:grid-cols-2">
23
        <.docs_card title="Get started" description="Install, configure, and run your first agent." />
24
        <.docs_card title="Guides" description="Authentication, agents, and the forge pipeline." />
25
        <.docs_card title="API reference" description="HTTP endpoints and schemas." />
26
        <.docs_card title="CLI reference" description="Mix tasks and release commands." />
27
      </div>
61
      <section :for={section <- @sections}>
62
        <h2 id={DocsCatalog.anchor(section.title)}>{section.title}</h2>
63
        <ul>
64
          <li :for={item <- section.items}>
65
            <.link patch={~p"/docs/#{item.slug}"}>{item.title}</.link>
66
          </li>
67
        </ul>
68
      </section>
28 69
    </div>
29 70
    """
30 71
  end
31 72
32
  defp docs_card(assigns) do
73
  def render(assigns) do
33 74
    ~H"""
34
    <%!-- `.card` brings its own padding and lift; the surrounding grid supplies
35
    the gaps, so the panel margin is dropped here. --%>
36
    <article class="card !m-0">
37
      <header>
38
        <h2 class="card-title">{@title}</h2>
39
        <p>{@description}</p>
40
      </header>
41
    </article>
75
    <div class="docs-page">
76
      <article class="docs-prose" id={"docs-#{@page.item.slug}"}>
77
        {Phoenix.HTML.raw(@page.html)}
78
      </article>
79
80
      <%!-- The rail is the page's own structure, so it comes from the same
81
      parse that produced the body rather than being maintained beside it.
82
      Hidden when a page has too few headings to be worth navigating. --%>
83
      <nav :if={length(@page.toc) > 1} class="docs-toc" aria-label="On this page">
84
        <p class="docs-toc__title">On this page</p>
85
        <ul>
86
          <li :for={heading <- @page.toc} data-level={heading.level}>
87
            <a href={"##{heading.id}"}>{heading.title}</a>
88
          </li>
89
        </ul>
90
      </nav>
91
    </div>
42 92
    """
43 93
  end
44 94
end
lib/openagents_web/route_authority.ex modified +1

@@ -26,6 +26,7 @@ defmodule OpenAgentsWeb.RouteAuthority do

26 26
    "/components/icons",
27 27
    "/components/:slug",
28 28
    "/docs",
29
    "/docs/:slug",
29 30
    "/healthz"
30 31
  ]
31 32
lib/openagents_web/router.ex modified +1

@@ -74,6 +74,7 @@ defmodule OpenAgentsWeb.Router do

74 74
      layout: {OpenAgentsWeb.Layouts, :docs},
75 75
      on_mount: [{OpenAgentsWeb.UserAuth, :mount_current_user}] do
76 76
      live "/docs", DocsLive, :index
77
      live "/docs/:slug", DocsLive, :show
77 78
    end
78 79
79 80
    post "/auth/github", AuthController, :start
priv/docs/api-tokens.md added +27

@@ -0,0 +1,27 @@

1
# API tokens
2
3
Tokens authenticate programmatic access to the REST API. Manage them at
4
[API tokens](/settings/api-tokens).
5
6
## Creating a token
7
8
Create a token and copy it immediately. Only a hash is stored, so the value
9
cannot be shown again — if you lose it, revoke it and create another.
10
11
## Using a token
12
13
Send it as a bearer token:
14
15
```
16
curl -H "Authorization: Bearer $OPENAGENTS_TOKEN" \
17
  https://openagents.com/api/v3/repos/OpenAgentsInc/openagents.com/issues
18
```
19
20
## Revoking
21
22
Revoking takes effect immediately. Any request in flight with that token fails
23
on its next call.
24
25
## Scope
26
27
A token acts as you. It reaches what your account reaches and nothing more.
priv/docs/assignees.md added +20

@@ -0,0 +1,20 @@

1
# Assignees
2
3
An assignee is the person responsible for an issue. See
4
`/:owner/:repo/assignees`.
5
6
## Assigning
7
8
An issue may have more than one assignee. Assigning does not notify anyone
9
today; there is no notification system yet.
10
11
## Unassigning
12
13
Removing an assignee leaves no trace on the issue. If you need the history, say
14
so in a comment.
15
16
## A current limitation
17
18
The assignable-users endpoint returns an empty list, so no user is reported as
19
assignable even though assignment itself accepts any login. This is a known gap
20
rather than a permission rule.
priv/docs/browsing-code.md added +22

@@ -0,0 +1,22 @@

1
# Browsing code
2
3
Any repository the forge hosts can be read in the browser at `/:repo`.
4
5
## Files
6
7
`/:repo/blob/:ref/*path` renders one file at one ref. The ref is part of the
8
URL, so a link to a file is a link to that file *at that revision* and does not
9
drift as the branch moves.
10
11
## What is public
12
13
Repositories are served at a disclosure level, not simply public or private. A
14
repository may expose its history and metadata while serving only an allow-list
15
of file paths. A path outside that list is not found rather than forbidden —
16
the distinction between "no such file" and "you may not see this file" is
17
itself a disclosure.
18
19
## Size limits
20
21
Rendering is bounded. Very large files and very large diffs are truncated
22
rather than streamed in full.
priv/docs/changelog.md added +24

@@ -0,0 +1,24 @@

1
# Changelog
2
3
[The changelog](/changelog) lists every change to the application, in two
4
layers.
5
6
## Two layers
7
8
The top layer is a plain-language summary of what changed and why it matters.
9
Expanding an entry reveals the receipt chain underneath: the commit, the build,
10
the deploy, and the elapsed time from push to live.
11
12
Most changelogs ask you to trust a summary. This one lets you check it, which
13
is the entire point of publishing it.
14
15
## Verifying an entry
16
17
Each entry links to its commit. From the commit you can read the diff, and the
18
receipts tell you when that exact revision went live.
19
20
## What is not published
21
22
Disclosure is per-repository. An entry may show that a change happened and what
23
class it belonged to while withholding the diff, if that repository serves at a
24
lower level.
priv/docs/commits.md added +20

@@ -0,0 +1,20 @@

1
# Commits
2
3
`/:repo/commit/:sha` shows one commit: its message, its author, and the files
4
it changed.
5
6
## What a commit page shows
7
8
The changed-file list and each file's diff, both bounded. A commit touching
9
hundreds of files shows the list and truncates the diffs.
10
11
## Trailers
12
13
Commits carry trailers identifying the agent session that produced them, where
14
one did. This is what lets a change be traced from the [changelog](/changelog)
15
back to the conversation that caused it.
16
17
## Deploy history
18
19
Where a commit reached the fleet, its receipt chain is visible from the
20
changelog: pushed, built, deployed, and how long each step took.
priv/docs/creating-issues.md added +19

@@ -0,0 +1,19 @@

1
# Creating issues
2
3
Open the new-issue form at `/:owner/:repo/issues/new`.
4
5
## Required fields
6
7
Only a title. Everything else can be added later, because the cost of an
8
unfiled thought is higher than the cost of an under-specified issue.
9
10
## Setting labels and a milestone at creation
11
12
Labels and a milestone can be attached on the form rather than in a second
13
pass. Both must already exist in the repository — creating a label from the
14
issue form would let a typo become a permanent label.
15
16
## Numbering
17
18
The number is assigned on save and is sequential within the repository. It is
19
not global, so two repositories both have an issue 1.
priv/docs/issues.md added +27

@@ -0,0 +1,27 @@

1
# Issues
2
3
An issue is a unit of work in a repository. Browse them at
4
`/:owner/:repo/issues`.
5
6
## Filtering by state
7
8
The list shows open issues by default. Switch between open and closed with the
9
state filter; the choice is in the URL, so a filtered list is a link you can
10
send to someone.
11
12
## What an issue holds
13
14
A title, a description, a state, and the people and labels attached to it. Each
15
issue carries a number that is unique within its repository and never reused,
16
so a reference to an issue stays valid after it is closed.
17
18
## Comments
19
20
Comments are ordered and attributed. Editing one is limited to its author,
21
while an issue's own title and description may be edited by any workspace
22
member.
23
24
## Through the API
25
26
Every browser action here has a REST equivalent under
27
`/api/v3/repos/:owner/:repo/issues`. See [REST API](/docs/rest-api).
priv/docs/labels.md added +22

@@ -0,0 +1,22 @@

1
# Labels
2
3
Labels classify issues. Manage them at `/:owner/:repo/labels`.
4
5
## Creating a label
6
7
A label has a name, a colour, and an optional description. Names are unique
8
within a repository.
9
10
## Applying labels
11
12
Apply them from an issue, or when creating one. An issue may carry any number.
13
14
## Deleting a label
15
16
Deleting removes it from every issue that carries it. The issues themselves are
17
untouched.
18
19
## Names with spaces
20
21
A label named `good first issue` is valid. In a URL its spaces are encoded, so
22
prefer the API's label endpoints over hand-written URLs when scripting.
priv/docs/leaderboard.md added +19

@@ -0,0 +1,19 @@

1
# Leaderboard
2
3
[The leaderboard](/leaderboard) ranks contributors by tokens spent through the
4
application.
5
6
## What is shown
7
8
Rank, avatar, name, handle, and token total — public GitHub profile information
9
and one number. Nothing about what anyone worked on.
10
11
## Opting out
12
13
Excluding yourself is a setting on your account. Opting out removes you from
14
the board; it does not stop your usage being counted for billing.
15
16
## Updates
17
18
The board updates live. Open it in two windows and spend tokens in one; the
19
other moves without a reload.
priv/docs/milestones.md added +20

@@ -0,0 +1,20 @@

1
# Milestones
2
3
A milestone groups issues toward a target. Manage them at
4
`/:owner/:repo/milestones`.
5
6
## Creating a milestone
7
8
A title, an optional description, and an optional due date.
9
10
## Progress
11
12
Progress is the ratio of closed issues to total issues in the milestone. It is
13
a measured ratio, not an estimate, so it moves only when an issue actually
14
closes.
15
16
## Closing a milestone
17
18
Closing does not close the issues inside it. Open issues in a closed milestone
19
stay open and stay visible — hiding them would make the milestone look finished
20
when it is not.
priv/docs/projects.md added +25

@@ -0,0 +1,25 @@

1
# Projects
2
3
A project is a board of issues. Browse them at `/:owner/:repo/projects`.
4
5
## The board
6
7
Items are grouped into columns by status. Adding an issue to a project creates
8
an item that points at it — the issue itself is unchanged, so the same issue can
9
sit on several boards.
10
11
## Fields
12
13
A project carries fields beyond status. Field values live on the item, not the
14
issue, so two boards can hold different views of the same work.
15
16
## Through the API
17
18
Projects are exposed under `/users/:username/projectsV2`, shaped after GitHub's
19
ProjectsV2. See [REST API](/docs/rest-api).
20
21
## A current limitation
22
23
The project endpoints do not scope by the `:username` in the path, so a project
24
is reachable under any username. Treat project URLs as unguessable rather than
25
access-controlled until that is closed.
priv/docs/rest-api.md added +65

@@ -0,0 +1,65 @@

1
# REST API
2
3
The API is shaped after GitHub's REST API and served under `/api/v3`. An
4
existing client usually needs only a base URL change.
5
6
## Authentication
7
8
Bearer token. See [API tokens](/docs/api-tokens).
9
10
```
11
curl -H "Authorization: Bearer $OPENAGENTS_TOKEN" \
12
  https://openagents.com/api/v3/repos/OpenAgentsInc/openagents.com/issues
13
```
14
15
## Issues
16
17
```
18
GET    /api/v3/repos/:owner/:repo/issues
19
POST   /api/v3/repos/:owner/:repo/issues
20
GET    /api/v3/repos/:owner/:repo/issues/:issue_number
21
PATCH  /api/v3/repos/:owner/:repo/issues/:issue_number
22
```
23
24
## Comments
25
26
```
27
GET    /api/v3/repos/:owner/:repo/issues/:issue_number/comments
28
POST   /api/v3/repos/:owner/:repo/issues/:issue_number/comments
29
GET    /api/v3/repos/:owner/:repo/issues/comments/:id
30
```
31
32
## Labels, milestones, assignees
33
34
```
35
GET    /api/v3/repos/:owner/:repo/labels
36
POST   /api/v3/repos/:owner/:repo/labels
37
GET    /api/v3/repos/:owner/:repo/milestones
38
GET    /api/v3/repos/:owner/:repo/issues/:issue_number/labels
39
POST   /api/v3/repos/:owner/:repo/issues/:issue_number/assignees
40
```
41
42
## Projects
43
44
```
45
GET    /api/v3/users/:username/projectsV2
46
GET    /api/v3/users/:username/projectsV2/:project_number
47
GET    /api/v3/users/:username/projectsV2/:project_number/items
48
GET    /api/v3/users/:username/projectsV2/:project_number/fields
49
```
50
51
## Known differences from GitHub
52
53
These are gaps, not design decisions, and they are listed so a client author
54
finds them here rather than in production:
55
56
- Renaming a label via `new_name` is accepted and ignored; the path name wins.
57
- Applying a label that does not exist returns 404. GitHub creates it.
58
- Removing a label an issue does not carry succeeds silently. GitHub returns 404.
59
- The assignable-users endpoint always returns an empty list.
60
- Project endpoints ignore the `:username` in the path.
61
- A non-numeric issue, milestone, or project number is a 500 rather than a 404.
62
63
## What is not implemented
64
65
Pull requests, reviews, webhooks, releases, and Git LFS.
priv/docs/signing-in.md added +24

@@ -0,0 +1,24 @@

1
# Signing in
2
3
Identity comes from GitHub. There is no separate OpenAgents password.
4
5
## How it works
6
7
Choose **Sign in with GitHub** and approve the authorization. You return to the
8
application signed in.
9
10
Your account is keyed to your numeric GitHub id, not your login name. Renaming
11
yourself on GitHub therefore keeps your account, your history, and your
12
attribution intact — a login name is a label, not an identity.
13
14
## What is stored
15
16
A scoped GitHub access token is retained so the application can read
17
repositories on your behalf. It is encrypted at rest and never sent to the
18
browser.
19
20
## Signing out
21
22
**Log out** from the account menu ends the session. It does not revoke the
23
GitHub authorization; do that from GitHub's application settings if you want
24
the grant removed entirely.
priv/docs/status-api.md added +25

@@ -0,0 +1,25 @@

1
# Status API
2
3
[`/api/status`](/api/status) returns the same projection the
4
[status page](/status) renders, as JSON.
5
6
## Shape
7
8
A schema version, fleet node states, and the forge pipeline with its loop
9
metric. The schema version is part of the contract: a consumer should read it
10
and refuse a version it does not know rather than guess.
11
12
## Disclosure
13
14
Identical to the status page. Counts, states, and durations; no operator
15
identities, module names, hostnames, or repository paths. It is unauthenticated,
16
so it is safe to poll from anywhere.
17
18
## Polling
19
20
There is no rate limit today. The projection is cached, so polling more often
21
than it refreshes returns the same payload.
22
23
## Related
24
25
[`/api/changelog`](/api/changelog) serves the changelog under the same rules.
priv/docs/status.md added +20

@@ -0,0 +1,20 @@

1
# Status
2
3
[Status](/status) reports whether the system is healthy, without disclosing its
4
internals.
5
6
## What it shows
7
8
Fleet nodes and their convergence, the forge pipeline, and the push-to-live
9
loop time.
10
11
## Content-free by design
12
13
The page reports counts, states, and durations. It does not name operators,
14
modules, hostnames, or repositories. A status page is read by people who are
15
not signed in, so it must be safe for the least-trusted reader.
16
17
## The API
18
19
The same projection is available as JSON at [`/api/status`](/api/status), with
20
the same disclosure rules.
priv/docs/welcome.md added +29

@@ -0,0 +1,29 @@

1
# Welcome
2
3
OpenAgents is a software forge with an agent layer on top of it. This
4
documentation covers the parts you can use today.
5
6
## What is here now
7
8
The issue tracker is the surface furthest along: issues, labels, milestones,
9
assignees, and projects, each with a browser view and a GitHub-compatible REST
10
endpoint. Code browsing renders any file or commit in a repository the forge
11
hosts.
12
13
Three surfaces exist to show the system's own work rather than yours. The
14
[changelog](/changelog) lists every change with the receipt chain that took it
15
live. [Status](/status) reports fleet health. The
16
[leaderboard](/leaderboard) ranks contributors by tokens.
17
18
## What is not here yet
19
20
Pull requests, code review, and webhooks are not built. Where a page in these
21
docs describes something, that thing exists and you can click it — a
22
documentation site that mixes shipped features with planned ones leaves you
23
unable to tell which half you are reading.
24
25
## Compatibility
26
27
The REST API is shaped after GitHub's, so an existing client usually needs only
28
a base URL change. See [REST API](/docs/rest-api) for what is implemented and
29
what differs.
test/openagents_web/docs_catalog_test.exs added +94

@@ -0,0 +1,94 @@

1
defmodule OpenAgentsWeb.DocsCatalogTest do
2
  @moduledoc """
3
  Documentation drifts silently. These assert the two ways it goes wrong: a
4
  catalogued page with no source file, and a page describing a surface the
5
  application no longer serves.
6
  """
7
8
  use OpenAgentsWeb.ConnCase, async: true
9
10
  import Phoenix.LiveViewTest
11
12
  alias OpenAgentsWeb.DocsCatalog
13
14
  test "slugs are unique" do
15
    slugs = DocsCatalog.slugs()
16
    assert length(slugs) == length(Enum.uniq(slugs))
17
  end
18
19
  test "every catalogued page has a Markdown source that renders" do
20
    for item <- DocsCatalog.items() do
21
      assert {:ok, page} = DocsCatalog.render(item.slug),
22
             "#{item.slug} is catalogued but priv/docs/#{item.slug}.md does not render"
23
24
      assert page.item.slug == item.slug
25
    end
26
  end
27
28
  test "every Markdown source is catalogued" do
29
    orphans =
30
      DocsCatalog.source_dir()
31
      |> Path.join("*.md")
32
      |> Path.wildcard()
33
      |> Enum.map(&(&1 |> Path.basename() |> Path.rootname()))
34
      |> Enum.reject(&(&1 in DocsCatalog.slugs()))
35
36
    assert orphans == [],
37
           "these pages exist but are unreachable from the sidebar: #{Enum.join(orphans, ", ")}"
38
  end
39
40
  test "every page documents a route the application actually serves" do
41
    # A docs site that describes surfaces which do not exist is worse than one
42
    # missing pages: the reader cannot tell which half they are reading.
43
    for item <- DocsCatalog.items() do
44
      path = String.replace(item.route, ~r/:[a-z_]+/, "placeholder")
45
46
      assert Phoenix.Router.route_info(OpenAgentsWeb.Router, "GET", path, "openagents.com") !=
47
               :error,
48
             "#{item.slug} documents #{item.route}, which no longer resolves"
49
    end
50
  end
51
52
  test "headings become the table of contents, ignoring fenced code" do
53
    toc = DocsCatalog.headings("# Title\n\n## Real\n\n```\n## Not a heading\n```\n\n### Nested\n")
54
55
    assert toc == [
56
             %{title: "Real", level: 2, id: "real"},
57
             %{title: "Nested", level: 3, id: "nested"}
58
           ]
59
  end
60
61
  test "every table-of-contents entry links to an id the page contains" do
62
    for item <- DocsCatalog.items() do
63
      {:ok, page} = DocsCatalog.render(item.slug)
64
      html = page.html |> Phoenix.HTML.safe_to_string()
65
66
      for heading <- page.toc do
67
        assert html =~ ~s(id="#{heading.id}"),
68
               "#{item.slug} lists #{heading.title} in its rail, but the body has no ##{heading.id}"
69
      end
70
    end
71
  end
72
73
  describe "the docs surface" do
74
    test "the index lists every page", %{conn: conn} do
75
      {:ok, view, _html} = live(conn, ~p"/docs")
76
77
      for item <- DocsCatalog.items() do
78
        assert has_element?(view, ~s{a[href="/docs/#{item.slug}"]}),
79
               "the index does not link to #{item.slug}"
80
      end
81
    end
82
83
    test "every page renders its Markdown as HTML", %{conn: conn} do
84
      for item <- DocsCatalog.items() do
85
        {:ok, _view, html} = live(conn, ~p"/docs/#{item.slug}")
86
        assert html =~ ~s(id="docs-#{item.slug}")
87
      end
88
    end
89
90
    test "an unknown page redirects to the index", %{conn: conn} do
91
      assert {:error, {:live_redirect, %{to: "/docs"}}} = live(conn, ~p"/docs/not-a-page")
92
    end
93
  end
94
end

This page updates live while a promote is in flight · changelog