A table of contents (TOC) is the list of links to sections (headings) of a document. It helps to better navigate large documents. But it would be obviously painful to write this by hand, so here is a Phoenix Component to automatically generate and display a TOC for any HTML document.

High level overview

The idea is to parse the html document to produce a Toc Struct based on the document headings that have an id attribute (ids are required to build the anchor links to the headings).

The Toc Struct is a tree-like structure to reflect the levels of the headings in the table of content: for instance <h3> tags located after <h2> in the document will be represented as children Toc struct of the parent <h2>‘s Toc struct.

[
  %Toc{id: "foo", title: "Hello", toc_level: 1, children: [
    %Toc{id: "bar", title: "Crazy", toc_level: 2, children: []},
    %Toc{id: "baz", title: "World", toc_level: 2, children: []}
  ]},
  %Toc{id: "boo", title: "Byebye", toc_level: 1, children: []}
]

The TocComponent uses this Toc Struct to to build the HTML table of content.

The output of the component looks like this (notice the nested <ul> tags to reflect the different headings levels) :

<ul class="toc">
  <li><a href="#heading-1">Heading 1</a></li>
  <li><a href="#heading-2">Heading 2</a>
    <ul class="toc">
      <li><a href="#heading-2.1">Heading 2.1</a></li>
      <li><a href="#heading-2.2">Heading 2.2</a></li>
    </ul>
  </li>
  <li><a href="#heading-3">Heading 3</a></li>
</ul>

The code

The module that builds the Toc Struct from the HTML document.

defmodule CMS.Toc do
  @enforce_keys [:id, :title, :toc_level]
  defstruct [:id, :title, :toc_level, children: []]

  @doc """
  Build Table Of Content (TOC) from an html document.

  Parse all headings tags (h1, h2, ...) and produce a recursive
  TOC Struct that can be used to create a HTML TOC

  ## Examples:
      iex> build_from_html("<h1 id='foo'>Hello</h1><h1 id='bar'>World</h1>")
      [
        %Toc{id: "foo", title: "Hello", toc_level: 1, children: []},
        %Toc{id: "bar", title: "World", toc_level: 1, children: []}
      ]

      iex> build_from_html(~s(
      ...>   <h1 id='foo'>Hello</h1>
      ...>     <h2 id='bar'>Crazy</h2>
      ...>     <h2 id='baz'>World</h2>
      ...>   <h1 id='boo'>Byebye</h1>
      ...>  ))
      [
        %Toc{id: "foo", title: "Hello", toc_level: 1, children: [
          %Toc{id: "bar", title: "Crazy", toc_level: 2, children: []},
          %Toc{id: "baz", title: "World", toc_level: 2, children: []}
        ]},
        %Toc{id: "boo", title: "Byebye", toc_level: 1, children: []}
      ]
  """
  def build_from_html(html) do
    {:ok, document} = Floki.parse_document(html)

    find_headers(document)
    |> Enum.reduce([], &handle_heading/2)
  end

  defp find_headers(document) do
    document
    |> Floki.find("*")
    |> Enum.filter(&match?({name, _attrs, _nodes} when name in ~w(h1 h2 h3 h4 h5), &1))
  end

  defp handle_heading(heading_tuple, toc_items_list_acc)

  defp handle_heading({tag, attributes, [title]}, toc_items_list_acc) do
    case Enum.find(attributes, {:no_id, nil}, &match?({"id", _}, &1)) do
      {:no_id, nil} ->
        toc_items_list_acc

      {_, id} ->
        toc_item = %__MODULE__{
          id: id,
          title: title,
          toc_level: get_toc_level(tag),
          children: []
        }

        add_toc_item(toc_item, toc_items_list_acc)
    end
  end

  defp add_toc_item(toc_item, []), do: [toc_item]

  defp add_toc_item(toc_item, toc_items_list) do
    [last_toc_item | previous_toc_items] = toc_items_list |> Enum.reverse()

    last_toc_level = last_toc_item.toc_level

    case toc_item.toc_level do
      toc_level when toc_level > last_toc_level ->
        last_toc_item = %{
          last_toc_item
          | children: Map.get(last_toc_item, :children) ++ [toc_item]
        }

        previous_toc_items ++ [last_toc_item]

      toc_level when toc_level <= last_toc_level ->
        toc_items_list ++ [toc_item]
    end
  end

  defp get_toc_level(tag) do
    {heading_level, _} = Integer.parse(String.last(tag))
    heading_level
  end
end

And the related Phoenix component:

defmodule CMS.TocComponent do
  use Phoenix.Component

  @doc """
  Renders a Table of Content

  ## Examples

      <.toc toc_items={toc_items} />
      <.toc toc_items={toc_items} max_level={1} wrapper_class="toc" item_class="mb-2" />
  """
  attr(:toc_items, :any, required: true)
  attr(:max_heading_level, :any, default: 2)
  attr(:wrapper_class, :string, default: "")
  attr(:item_class, :string, default: "")

  def toc(assigns) do
    ~H"""
    <ul :if={!Enum.empty?(@toc_items)} class={[@wrapper_class]}>
      <%= for toc_item <- @toc_items do %>
        <%= render_toc_item(assigns, toc_item) %>
      <% end %>
    </ul>
    """
  end

  defp render_toc_item(assigns, toc_item) do
    assigns = assign(assigns, :toc_item, toc_item)

    ~H"""
    <li :if={@toc_item.toc_level <= @max_heading_level} class={[@item_class]}>
      <.link href={"#" <> @toc_item.id}><%= @toc_item.title %></.link>
      <ul
        :if={!Enum.empty?(@toc_item.children) and @toc_item.toc_level < @max_heading_level}
        class={[@wrapper_class]}
      >
        <%= for child_toc_item <- @toc_item.children do %>
          <%= render_toc_item(assigns, child_toc_item) %>
        <% end %>
      </ul>
    </li>
    """
  end
end

Note that it is somehow a “recursive component”.

Usage exmemple

Build the Toc in your controller:

defmodule MyAppWeb.ArticleController do
  use CancerConsultWeb, :controller
  # ...
  alias CancerConsult.Articles.Toc

  def show(conn, params) do
    acrticle_content = "<h1 ...>....<h1>"
    toc_items = Toc.build_from_html(acrticle_content)

    conn
    |> render(:show,
      content: content,
      toc_items: toc_items
    )
  end
end

And use the component in your heex template:

<TocComponent.toc toc_items={@toc_items} max_heading_level={3} />