Navigation

When reading through articles often it is useful to see a table of contents first to see if it is event worth your time reading. Additionally, when navigating an article you may want to link others to a certain section instead of the entire page with instructions on what section to look at.

Therefore to make a better experience when navigating these blog posts I want to add a table of contents, which also comes with adding anchor links to each header.

I want the process of adding the table of contents be automated. I don't want to construct it in the markdown. Which leaves me two choices that I can think of:

  • Find an markdown to HTML converter that auto creates the table of contents
  • Write JavaScript to create the table of contents on page load

I'm going to go with the ladder

Creating a Table of Contents

The JavaScript to create the HTML needed is pretty straight forward. We know we only want to look at the HTML elements that are headers (h1, h2, h3, etc). Once we get all that exist we'll just iterate and on each iteration update the header to be a link and append to the table of contents div.

The JavaScript looks as follows

// lib/sour_shark/templates/blog_head.html.eex
<script>
  // Heavily influenced from: https://stackoverflow.com/a/41085566
  document.addEventListener('DOMContentLoaded', htmlTableOfContents);

  function htmlTableOfContents() {
    var tableOfContentsElement = document.getElementById("table-of-contents");
    var headings = [].slice.call(document.body.querySelectorAll('h1, h2, h3, h4, h5, h6'));

    if (!tableOfContentsElement || headings.length < 3)
      return;
    tableOfContentsElement.innerHTML = `<p class="text-2xl">Table of Contents</p>`;

    headings.forEach(function (headerTag, index) {
      const identifier = headerTag.textContent.toLowerCase().replace(/\s/g, "-") + "-" + index;
      headerTag.setAttribute("id", identifier);
      headerTag.innerHTML = `<a class="no-underline" href="#${identifier}">${headerTag.textContent}</a>`;
      const headerTableOfContentsItem = tableOfContentsElement.appendChild(document.createElement("div"));
      headerTableOfContentsItem.setAttribute("class", headerTag.tagName.toLowerCase());
      const headerAnchor = headerTableOfContentsItem.appendChild(document.createElement("a"));
      headerAnchor.setAttribute("href", `#${identifier}`);
      headerAnchor.textContent = headerTag.textContent;
    });
  }
</script>
<style>
  #table-of-contents div.h1 { margin-left: 1em }
  #table-of-contents div.h2 { margin-left: 2em }
  #table-of-contents div.h3 { margin-left: 3em }
  #table-of-contents div.h4 { margin-left: 4em }
</style>

There are a few things I want to call out:

if (!tableOfContentsElement || headings.length < 3)
  return;

No need for a table of contents if there is no element for a table of contents or if there are less than three headings.

const headerTableOfContentsItem = tableOfContentsElement.appendChild(document.createElement("div"));
headerTableOfContentsItem.setAttribute("class", headerTag.tagName.toLowerCase());
<style>
  #table-of-contents div.h1 { margin-left: 1em }
  #table-of-contents div.h2 { margin-left: 2em }
  #table-of-contents div.h3 { margin-left: 3em }
  #table-of-contents div.h4 { margin-left: 4em }
</style>

This allows for content items to be nested, giving the reader a visualization of a hierarchy of topics

Updating the Blog Templates

The remaining thing to do now is update the templating to include a <div> with the id table-of-contents and inject the new JavaScript <script> on blog pages. The new JavaScript should already be injected with what is in place today.

To make it easier I'll add a new template for blog pages, lib/sour_shark/template/blog_post.html.eex

<article class="prose dark:prose-invert">
  <%= @post_body %>
</article>

I'll then update lib/mix/tasks/build.ex to use this new template

defp build_blog(add_hot_reload?) do
  posts = SourShark.Blog.all_posts()

  extra_head =
    eval_file("lib/sour_shark/templates/blog_head.html.eex",
      assigns: [languages: @highlight_js_languages]
    )

  hot_reload =
    if add_hot_reload?,
      do: File.read!("lib/sour_shark/templates/hot_reload.html.eex"),
      else: nil

  for post <- posts do
    if Path.dirname(post.path) != ".",
      do: File.mkdir_p!(Path.join([@output_dir, Path.dirname(post.path)]))

    IO.puts("Building #{post.path}")

    File.write!(
      "#{@output_dir}/#{post.path}",
      eval_file("lib/sour_shark/templates/root.html.eex",
        assigns: [
          hot_reload: hot_reload,
          extra_head: extra_head,
          content:
            eval_file("lib/sour_shark/templates/pages/blog_post.html.eex",
              assigns: [post_body: post.body]
            )
        ]
      )
    )
  end
end

Extra Change

While doing these changes I noticed the the markdown files that are converted into HTML still have the .md file extension in their name, which also means it shows up in the URL. I find that to be a big ugly so I made the following change in lib/sour_shark/post.ex

From:

path = "#{String.replace(filename, "priv/", "")}.html"

To:

path =
  filename
  |> String.replace("priv/", "")
  |> String.replace(".md", ".html")

And then finally to remove the .html in the links, I updated lib/sour_shark/templates/index.html.eex

<a href="<%= post.path %>" class="...">

To:

<a href="<%= String.replace(post.path, ".html", "")%>" class="...">