I created Table of Contents using Floki, with Header nesting. How to simplify the logic?

Full Implementation

Table of contents, component:

attr :headers, :list

def table_of_contents(assigns) do
  ~H"""
  <div
    id="table-of-contents"
    class="sticky top-[calc(var(--header-height))] py-1 pr-5"
    data-file={__ENV__.file}
    data-line={__ENV__.line}
    phx-hook={Application.fetch_env!(:derpy_tools, :show_inspector?) && "SourceInspector"}
  >
    <h5
      id="toc"
      class="text-slate-900 font-semibold mb-2 text-sm leading-6 dark:text-slate-100"
      phx-hook="TableOfContents"
    >
      On this page
    </h5>
    <a
      href="#"
      class="block py-1 font-medium hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-300"
    >
      <i class="hero-chevron-up w-5.5 h-5.5 text-slate-500 dark:text-navy-100" /> Top
    </a>
     <.nested_header
      headers={@headers}
      class="max-h-[calc(100svh-(var(--header-height)))] overflow-auto"
    />
  </div>
  """
end

attr :id, :string, default: nil
attr :headers, :list
attr :class, :string, default: nil
attr :parent, :list, default: []

def nested_header(assigns) do
  ~H"""
  <ul id={@id} class={["space-y-1 font-inter font-medium list-none not-prose", @class]}>
    <li
      :for={%{header: header, id: id, title: title, children: children} <- @headers}
      class="not-prose"
    >
      <a
        id={"#{id}-link"}
        key={id}
        href={"##{id}"}
        tabindex="0"
        data-parent={@parent |> Enum.join(">")}
        class={[
          "block py-1 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-300",
          case header do
            "h2" -> "font-semibold"
            "h3" -> "font-medium"
            "h4" -> "font-normal"
            "h5" -> "font-normal text-xs"
          end
        ]}
        phx-click={JS.toggle(to: "##{id}-container")}
      >
        <i :if={header in ~w{h3 h4 h5}} class="hero-chevron-right-mini" />
        <span><%= title %></span>
      </a>

      <.nested_header
        :if={children}
        id={"#{id}-container"}
        headers={children |> Enum.reverse()}
        class="pl-4 hidden"
        parent={[id, @parent |> Enum.join(">")]}
      />
    </li>
  </ul>
  """
end

Using the TOC component in Left Nav

Pass any function component to the parse_blog function, and it’ll work.

attr :post, :map
attr :class, :string, default: nil

def left_nav(assigns) do
  parsed_blog =
    __MODULE__
    |> apply(assigns.post.body, [assigns])
    |> parse_blog()

  assigns =
    assigns
    |> assign(
      headers: extract_headers(parsed_blog),
      reading_time: reading_time(parsed_blog)
    )

  ~H"""
  <aside class={["flex flex-col", @class]}>
    <span><%= @reading_time %></span>
    <.table_of_contents headers={@headers} />
  </aside>
  """
end

Parsing-related code in private functions:

defp parse_blog(function_component) do
  function_component.static
  |> Floki.parse_fragment!()
  |> Floki.find("article")
end

defp extract_headers([parsed_blog]) do
  parsed_blog
  |> Floki.children()
  |> Enum.filter(&match?({name, _, _} when name in ~w(h2 h3 h4 h5), &1))
  |> Enum.map(fn
    {header, meta, [{"a", _, [title | _]} | _rest]} ->
      {_, id} = Enum.find(meta, &match?({"id", _}, &1))
      {header, id, title |> String.trim()}

     {header, meta, [title | _rest]} ->
      {_, id} = Enum.find(meta, &match?({"id", _}, &1))
      {header, id, title |> String.trim()}
  end)
  |> Enum.reduce([], fn
    {"h2", id, title}, acc ->
      [%{header: "h2", id: id, title: title, children: []} | acc]

    {"h3", id, title}, acc ->
      children = path(0 / :children)

      Pathex.set!(acc, children, [
        %{header: "h3", id: id, title: title, children: []} | Pathex.get(acc, children)
      ])

    {"h4", id, title}, acc ->
      children = path(0 / :children / 0 / :children)

      Pathex.set!(acc, children, [
        %{header: "h4", id: id, title: title, children: []} | Pathex.get(acc, children)
      ])

    {"h5", id, title}, acc ->
      children = path(0 / :children / 0 / :children / 0 / :children)

      Pathex.set!(acc, children, [
        %{header: "h5", id: id, title: title, children: nil} | Pathex.get(acc, children)
      ])
  end)
  |> Enum.reverse()
end

defp reading_time(parsed_blog) do
  parsed_blog
  |> Floki.text()
  |> String.replace(~r/@|#|\$|%|&|\^|:|_|!|,/u, " ")
  |> String.split()
  |> Enum.count()
  |> div(@wpm)
  |> Timex.Duration.from_minutes()
  |> Timex.Format.Duration.Formatter.format(:humanized)
end

JS Hook

const TableOfContents = {
  mounted() {
    if (location.hash) {
      const hash = location.hash.replace("#", "");
      const header = document.getElementById(hash);

      header &&
        header.scrollIntoView({
          behavior: "instant",
          block: "start",
          inline: "end",
        });

      highlightNav(
        hash,
        [
          "hover:text-slate-900",
          "dark:text-slate-400",
          "dark:hover:text-slate-300",
        ],
        ["text-sky-500", "dark:text-sky-400"]
      );
    }
    window.addEventListener("hashchange", handleHashChange);
  },
  destroyed() {
    window.removeEventListener("hashchange", handleHashChange);
  },
};

function handleHashChange(event) {
  if (event.oldURL.includes("#")) {
    const hash = event.oldURL.split("#").pop();

    highlightNav(
      hash,
      ["text-sky-500", "dark:text-sky-400"],
      [
        "hover:text-slate-900",
        "dark:text-slate-400",
        "dark:hover:text-slate-300",
      ]
    );
  }

  if (location.hash) {
    const hash = location.hash.replace("#", "");

    highlightNav(
      hash,
      [
        "hover:text-slate-900",
        "dark:text-slate-400",
        "dark:hover:text-slate-300",
      ],
      ["text-sky-500", "dark:text-sky-400"]
    );
  }
}

function highlightNav(hash, remove, add) {
  const nav = document.getElementById(`${hash}-link`);

  if (nav) {
    nav.classList.remove(...remove);
    nav.classList.add(...add);

    const { parent } = nav.dataset;

    if (parent) {
      parent.split(">").forEach((parentId) => {
        const parent = document.getElementById(`${parentId}-link`);

        if (parent) {
          parent.classList.remove(...remove);
          parent.classList.add(...add);
        }

        const parentContainer = document.getElementById(
          `${parentId}-container`
        );

        if (parentContainer) {
          parentContainer.classList.remove("hidden");
          if (location.hash == `#${hash}`) parentContainer.style.display = null;
        }
      });
    }
  }
}

export default TableOfContents;

Example headers, with self linking.

<h2 id="installation" class="group flex whitespace-nowrap not-prose">
    <a href="#installation" class="relative flex items-center">
      Installation
      <span class="absolute -ml-8 opacity-0 group-hover:opacity-100 transition-opacity duration-500 group-focus:opacity-100 flex h-6 w-6 items-center justify-center rounded-md text-slate-400 shadow-sm ring-1 ring-slate-900/5 hover:text-slate-700 hover:shadow hover:ring-slate-900/10 dark:bg-slate-700 dark:text-slate-300 dark:shadow-none dark:ring-0">
        <svg width="12" height="12" fill="none" aria-hidden="true">
          <path
            d="M3.75 1v10M8.25 1v10M1 3.75h10M1 8.25h10"
            stroke="currentColor"
            stroke-width="1.5"
            stroke-linecap="round"
          >
          </path>
        </svg>
      </span>
    </a>
  </h2>
  <h3 id="linux">Linux</h3>
  <h4 id="ubuntu" class="group whitespace-nowrap not-prose">
    <a href="#ubuntu" class="relative flex items-center">
      Ubuntu
      <span class="absolute -ml-8 opacity-0 group-hover:opacity-100 transition-opacity duration-500 group-focus:opacity-100 flex h-6 w-6 items-center justify-center rounded-md text-slate-400 shadow-sm ring-1 ring-slate-900/5 hover:text-slate-700 hover:shadow hover:ring-slate-900/10 dark:bg-slate-700 dark:text-slate-300 dark:shadow-none dark:ring-0">
        <svg width="12" height="12" fill="none" aria-hidden="true">
          <path
            d="M3.75 1v10M8.25 1v10M1 3.75h10M1 8.25h10"
            stroke="currentColor"
            stroke-width="1.5"
            stroke-linecap="round"
          >
          </path>
        </svg>
      </span>
    </a>
  </h4>

Sorry about the wall of text, but I don’t know how to string some nice description for the code.
Perhaps I’ll write a blog post about it.

Features:

  1. The nav stays selected, even on page refresh.
  2. Only the highlight part is JS, rest is happening on the Elixir side.
  3. Even the parent nav-links, light/open up, when it’s child is selected.

Rest you can figure out the features and kinks.

Thanks everyone. :upside_down_face:


P.S. I left the reading time estimator in the code, in case someone finds better way to do that thing. :hatching_chick:

4 Likes