lib/openagents_web/docs_catalog.ex

main at 58e6347eeb72 · 10 KB

defmodule OpenAgentsWeb.DocsCatalog do
  @moduledoc """
  The documentation's table of contents, and the loader for its pages.

  Pages are Markdown files under `priv/docs`, embedded as one immutable
  compile-time snapshot, and rendered through `OpenAgents.Markdown`, which is
  the same safe CommonMark path the chat surface uses. Each source is an
  external compiler resource, so editing Markdown recompiles this allowlisted
  module and the Forge can deploy the complete snapshot transactionally.
  Documentation remains content that a writer can edit rather than HEEx that a
  writer cannot.

  Every page here documents something a visitor can actually reach today. A
  documentation site that describes features that do not exist is worse than
  one that is missing pages, because a reader cannot tell which half they are
  in. `route` is the surface each page describes, and `DocsCatalogTest`
  asserts every one of them resolves in the router.
  """

  @docs_dir Path.expand("../../priv/docs", __DIR__)
  @doc_files Path.wildcard(Path.join(@docs_dir, "*.md"))
  for path <- @doc_files, do: @external_resource(path)

  @pages Map.new(@doc_files, fn path ->
           {path |> Path.basename() |> Path.rootname(), File.read!(path)}
         end)

  @sections [
    %{
      title: "Getting started",
      items: [
        %{slug: "welcome", title: "Welcome", icon: "book", route: "/"},
        %{slug: "signing-in", title: "Signing in", icon: "user", route: "/"},
        %{slug: "api-tokens", title: "API tokens", icon: "key", route: "/settings/api-tokens"}
      ]
    },
    %{
      title: "Repositories",
      items: [
        %{
          slug: "repositories",
          title: "Repository hosting",
          icon: "folder",
          route: "/repositories"
        },
        %{
          slug: "create-repository",
          title: "Create a repository",
          icon: "square-plus",
          route: "/repositories/new"
        },
        %{
          slug: "import-github",
          title: "Import from GitHub",
          icon: "download",
          route: "/repositories/import/github"
        },
        %{
          slug: "clone-push-pull",
          title: "Clone, push, and pull",
          icon: "code",
          route: "/repositories"
        },
        %{
          slug: "delete-repository",
          title: "Delete a repository",
          icon: "trash",
          route: "/repositories"
        }
      ]
    },
    %{
      title: "CLI",
      items: [
        %{
          slug: "openagents-cli",
          title: "The OpenAgents CLI",
          icon: "terminal",
          route: "/repositories"
        },
        %{
          slug: "install-cli",
          title: "Install the CLI",
          icon: "download",
          route: "/repositories"
        },
        %{
          slug: "cli-command-reference",
          title: "CLI command reference",
          icon: "square-code",
          route: "/repositories"
        },
        %{
          slug: "cli-api",
          title: "Call the API with the CLI",
          icon: "square-code",
          route: "/api/v1/repos/:owner/:repo/issues"
        }
      ]
    },
    %{
      title: "Issues",
      items: [
        %{
          slug: "issues",
          title: "Issue tracking",
          icon: "file-document",
          route: "/:owner/:repo/issues"
        },
        %{
          slug: "creating-issues",
          title: "Creating issues",
          icon: "square-plus",
          route: "/:owner/:repo/issues/new"
        },
        %{slug: "labels", title: "Labels", icon: "tag", route: "/:owner/:repo/labels"},
        %{
          slug: "milestones",
          title: "Milestones",
          icon: "flag",
          route: "/:owner/:repo/milestones"
        },
        %{
          slug: "assignees",
          title: "Assignees",
          icon: "user",
          route: "/:owner/:repo/assignees"
        },
        %{
          slug: "do-not-build-register",
          title: "Do-not-build register",
          icon: "info",
          route: "/api/contracts/do-not-build-v1.json"
        }
      ]
    },
    %{
      title: "Pull requests",
      items: [
        %{
          slug: "pull-requests",
          title: "Proposing and merging changes",
          icon: "pull-request-open",
          route: "/:owner/:repo/pulls"
        },
        %{
          slug: "stacked-pull-requests",
          title: "Stacked pull requests",
          icon: "stack",
          route: "/:owner/:repo/pulls/:number"
        },
        %{
          slug: "stack-actions",
          title: "Rebase and restructure a stack",
          icon: "reload",
          route: "/:owner/:repo/pulls/:number"
        },
        %{
          slug: "merging-stacks",
          title: "Merging stacks",
          icon: "check-circle",
          route: "/api/v1/repos/:owner/:repo/stacks"
        }
      ]
    },
    %{
      title: "Projects",
      items: [
        %{
          slug: "projects",
          title: "Project boards",
          icon: "grid",
          route: "/:owner/:repo/projects"
        }
      ]
    },
    %{
      title: "Forum",
      items: [
        %{
          slug: "forum",
          title: "Boards, topics, and posts",
          icon: "comment",
          route: "/forum"
        },
        %{
          slug: "claim-legacy-identity",
          title: "Claim a legacy identity",
          icon: "user",
          route: "/forum/claim"
        }
      ]
    },
    %{
      title: "Code",
      items: [
        %{
          slug: "browsing-code",
          title: "Browsing code",
          icon: "code",
          route: "/OpenAgentsInc/:repo"
        },
        %{
          slug: "commits",
          title: "Commits",
          icon: "cube",
          route: "/OpenAgentsInc/:repo/commit/:sha"
        }
      ]
    },
    %{
      title: "Inference",
      items: [
        %{
          slug: "models",
          title: "Models and pricing",
          icon: "dollar-circle",
          route: "/models"
        }
      ]
    },
    %{
      title: "Transparency",
      items: [
        %{slug: "changelog", title: "Changelog", icon: "text", route: "/docs/changelog"},
        %{slug: "status", title: "Status", icon: "check-circle", route: "/status"},
        %{slug: "leaderboard", title: "Leaderboard", icon: "star", route: "/leaderboard"}
      ]
    },
    %{
      title: "API",
      items: [
        %{
          slug: "rest-api",
          title: "REST API",
          icon: "square-code",
          route: "/api/v1/repos/:owner/:repo/issues"
        },
        %{
          slug: "stacks-api",
          title: "Stacks API",
          icon: "square-code",
          route: "/api/v1/repos/:owner/:repo/stacks"
        },
        %{slug: "status-api", title: "Status API", icon: "info", route: "/api/status"}
      ]
    }
  ]

  @doc "Sidebar sections, in reading order."
  def sections, do: @sections

  @doc "Every page, flattened."
  def items, do: Enum.flat_map(@sections, & &1.items)

  @doc "Every slug."
  def slugs, do: Enum.map(items(), & &1.slug)

  @doc "Look up one page by slug, or nil."
  def fetch(slug), do: Enum.find(items(), &(&1.slug == slug))

  @doc "The section title a page belongs to."
  def section_title(slug) do
    Enum.find_value(@sections, fn section ->
      if Enum.any?(section.items, &(&1.slug == slug)), do: section.title
    end)
  end

  @doc "Directory holding the Markdown sources."
  def source_dir do
    Application.get_env(
      :openagents,
      :docs_source_dir,
      Application.app_dir(:openagents, "priv/docs")
    )
  end

  @doc """
  Render one page from the compiled documentation snapshot.

  Returns the rendered HTML, the headings found in it, and the Markdown source
  it came from, so a page, its table of contents, and the text the copy button
  hands over all come from one read rather than several that can disagree.
  """
  def render(slug) do
    with %{} = item <- fetch(slug),
         {:ok, markdown} <- Map.fetch(@pages, slug) do
      toc = headings(markdown)
      # Authored prose, not a message: the source is wrapped for editing, and
      # those wraps are not line breaks the reader should see.
      html =
        markdown
        |> OpenAgents.Markdown.to_html(hardbreaks: false)
        |> anchor_headings(toc)

      {:ok, %{item: item, html: html, toc: toc, markdown: markdown}}
    else
      _ -> :error
    end
  end

  # The shared Markdown renderer emits no heading ids, and it should not start:
  # it is the path untrusted model output takes, and its output validation is a
  # security boundary rather than a formatting choice. Docs need anchors, so
  # they are added here, from the same headings/1 result that builds the table
  # of contents -- one source, so the rail cannot link to an id the body lacks.
  defp anchor_headings({:safe, html}, toc), do: {:safe, anchor_headings(html, toc)}

  defp anchor_headings(html, toc) when is_binary(html) do
    Enum.reduce(toc, html, fn %{title: title, level: level, id: id}, acc ->
      String.replace(
        acc,
        "<h#{level}>#{Phoenix.HTML.html_escape(title) |> Phoenix.HTML.safe_to_string()}</h#{level}>",
        ~s(<h#{level} id="#{id}">#{Phoenix.HTML.html_escape(title) |> Phoenix.HTML.safe_to_string()}</h#{level}>),
        global: false
      )
    end)
  end

  @doc """
  The `##` and `###` headings of a Markdown source, with anchor ids.

  Parsed from the source rather than the rendered HTML: the renderer escapes
  and rewrites, and a table of contents that disagrees with the anchors it
  links to is worse than none.
  """
  def headings(markdown) do
    markdown
    |> String.split("\n")
    |> Enum.reduce({[], false}, fn line, {acc, in_fence} ->
      cond do
        String.starts_with?(line, "```") -> {acc, not in_fence}
        in_fence -> {acc, in_fence}
        true -> {collect_heading(acc, line), in_fence}
      end
    end)
    |> elem(0)
    |> Enum.reverse()
  end

  defp collect_heading(acc, "### " <> title), do: [heading(title, 3) | acc]
  defp collect_heading(acc, "## " <> title), do: [heading(title, 2) | acc]
  defp collect_heading(acc, _line), do: acc

  defp heading(title, level) do
    title = String.trim(title)
    %{title: title, level: level, id: anchor(title)}
  end

  @doc "The anchor id for a heading, matching what the renderer emits."
  def anchor(title) do
    title
    |> String.downcase()
    |> String.replace(~r/[^a-z0-9\s-]/u, "")
    |> String.trim()
    |> String.replace(~r/\s+/, "-")
  end
end