lib/openagents_web/live/docs_live.ex

436d37facc96 · 2 KB

defmodule OpenAgentsWeb.DocsLive do
  @moduledoc """
  Renders the Markdown pages catalogued in `OpenAgentsWeb.DocsCatalog`.

  `/docs` is the index and `/docs/:slug` is a page; both are this LiveView, so
  moving between them patches rather than remounts, and the sidebar keeps its
  scroll position and whatever the reader collapsed.

  Markdown goes through `OpenAgents.Markdown`, the same safe CommonMark path
  the chat surface uses. Documentation is authored content, but it renders with
  the same guarantees as content from a model: no raw HTML passthrough.
  """

  use OpenAgentsWeb, :live_view

  alias OpenAgentsWeb.DocsCatalog
  alias OpenAgentsWeb.OG

  @impl true
  def mount(_params, _session, socket) do
    {:ok, assign(socket, :sections, DocsCatalog.sections())}
  end

  @impl true
  def handle_params(_params, _uri, %{assigns: %{live_action: :index}} = socket) do
    {:noreply,
     socket
     |> assign(:page_title, "Docs")
     |> assign(:active_page, :index)
     |> assign(:section_title, nil)
     |> assign(:og, nil)
     |> assign(:page, nil)}
  end

  def handle_params(%{"slug" => slug}, _uri, socket) do
    case DocsCatalog.render(slug) do
      {:ok, page} ->
        {:noreply,
         socket
         |> assign(:page_title, page.item.title)
         |> assign(:active_page, page.item.slug)
         |> assign(:section_title, DocsCatalog.section_title(page.item.slug))
         |> assign(:og, OG.meta(OG.docs(page)))
         |> assign(:page, page)}

      :error ->
        {:noreply,
         socket
         |> put_flash(:error, "No such page: #{slug}")
         |> push_navigate(to: ~p"/docs")}
    end
  end

  @impl true
  def render(%{live_action: :index} = assigns) do
    ~H"""
    <div id="docs-index" class="docs-prose markdown">
      <h1>Documentation</h1>
      <p>
        Everything documented here is something you can reach today. Where a page
        describes a surface, that surface exists and is clickable.
      </p>

      <section :for={section <- @sections}>
        <h2 id={DocsCatalog.anchor(section.title)}>{section.title}</h2>
        <ul>
          <li :for={item <- section.items}>
            <.link patch={~p"/docs/#{item.slug}"}>{item.title}</.link>
          </li>
        </ul>
      </section>
    </div>
    """
  end

  def render(assigns) do
    ~H"""
    <div class="docs-page">
      <article class="docs-prose markdown" id={"docs-#{@page.item.slug}"}>
        {Phoenix.HTML.raw(@page.html)}
      </article>

      <%!-- The rail is the page's own structure, so it comes from the same
      parse that produced the body rather than being maintained beside it.
      Hidden when a page has too few headings to be worth navigating. --%>
      <nav :if={length(@page.toc) > 1} class="docs-toc" aria-label="On this page">
        <p class="docs-toc__title">On this page</p>
        <ul>
          <li :for={heading <- @page.toc} data-level={heading.level}>
            <a href={"##{heading.id}"}>{heading.title}</a>
          </li>
        </ul>
      </nav>
    </div>
    """
  end
end