Multiple Levels of Subnavigation with Jekyll

In this post, we'll discuss how to separate an HTML document into logical sections based on heading tags. I'll all also show you a cool trick for rendering arbitrarily-deep subnavigation trees using Liquid templates.

In a previous post I showed you how to generate subnavigation links for each H2 in a Jekyll page. In this post, we'll build on that foundation and show you how you can add arbitrary levels of subnavigation based on H3, H4, etc.

Overview

I've broken this project down into a couple of steps:

  • First, we will use nokogiri to pull out the sections defined by H3 tags "inside" of H2 tags
  • Next, we'll use a cool trick to render arbitrary levels of sub navigation. We're going make a recursive template.

Before we get started, let's make something clear. When I refer to an H3 tag as being inside of an H2, I don't mean that it's literally nested. Instead, I'm referring to the situation that we see below:

<h2>Animals</h2>
<p>Here are some kinds of animals.</p>
<h3>Giraffe</h3>
<p>This section about giraffes logically belongs inside of the section about animals, even though the structure of the Dom doesn't define it as being nested</p>
<h3>Zebra</h3>
<p>Another section that logically belongs under "Animals"</p>

Breaking the Document into Sections

The obvious problem that we face when breaking an HTML document like the one above into sections, is that nothing is nested. Most of the tools for parsing HTML are built to work with nesting.

This isn't a deal breaker, but it does mean that we have to do a little bit more work. In the example below we find each H2 tag and then manually scan siblings for H3 tags.

I did get fancy and use a custom enumerator. If you have any questions about those, check out my blog post on them.

require "nokogiri"

class MySubnavGenerator < Jekyll::Generator
  def generate(site)
    parser = Jekyll::Converters::Markdown.new(site.config)

    site.pages.each do |page|
      if page.ext == ".md"
        doc = Nokogiri::HTML(parser.convert(page['content']))

        page.data["subnav"] = doc.css('h2').map do |h2|
          to_nav_item(page, h2).tap do |item|
            item["children"] = subheadings(h2).map { |h3| to_nav_item(page, h3) }
          end
        end
      end
    end
  end

  # Converts a heading into a hash of the info for a link
  def to_nav_item(page, heading)
    {
      "title" => heading.text,
      "url" => [page.url, heading['id']].join("#")
    }
  end

  # Returns an enumerator of all H3s "belonging" to an H2
  def subheadings(el)
    Enumerator.new do |y|
      next_el = el.next_sibling
      while next_el && next_el.name != "h2"
        if next_el.name == "h3"
          y << next_el
        end
        next_el = next_el.next_sibling
      end
    end
  end
end

I realize that this is quite a blob of code to throw at you, but it builds off of the work we did in a previous post. If you have any questions about the structure of Jekyll plug-ins, or the way we're using nokogiri please check that article.

When I run this code against our documentation site, I get a hash that looks something like this:

[{"title"=>"Getting Started",
  "url"=>"/lib/java.html#getting-started",
  "sub_subnav"=>
   [{"title"=>"Download / Maven", "url"=>"/lib/java.html#download-maven"},
    {"title"=>"Stand Alone Usage", "url"=>"/lib/java.html#stand-alone-usage"},
    {"title"=>"Servlet Usage", "url"=>"/lib/java.html#servlet-usage"},
    {"title"=>"Play Usage", "url"=>"/lib/java.html#play-usage"},
    {"title"=>"API Usage", "url"=>"/lib/java.html#api-usage"}]},
    ...

Now all that we have to do is figure out how to render this thing using liquid templates.

Rendering the Subnav

It's actually not that difficult to render an arbitrarily deep sub navigation using liquid templates. The trick is to use a partial that renders itself.

In my layout, I render the partial and pass in the collection of navigation items.

{% include navigation_item.html collection=page.subnav level=0 %}

The partial creates the links for this level of navigation, and then renders itself, passing in a list of children. Just like a recursive function, this can theoretically go on forever. Just for kicks, I've added a bit of code to give each level of the subnav a class like level-1 or level-2. This is really useful for styling.

{% if include.collection.size > 0 %}
<ul class="nav nav-list level-{{ include.level }}">
    {% for item in include.collection %}
      {% if item.url == page.url %}
      <li class="active">
      {% else %}
      <li>
      {% endif %}
        {% if item.subnav.size > 0 %}
          <a class="has-subnav" href="{{ item.url }}">
          <span class="glyphicon glyphicon-plus"></span>
          <span class="glyphicon glyphicon-minus"></span>
        {% else %}
          <a href="{{ item.url }}">
        {% endif %}
          {{ item.title }}
        </a>
        {% assign next_level = include.level | plus: 1 %}
        {% include navigation_item.html collection=item.children level=next_level %}
      </li>
    {% endfor %}
  </ul>
{% endif %}

That's it!

This concludes our brief foray into the wonderful world of Jekyll. In the next few days of the publishing a series of articles on Ruby internals, so stay tuned!

What to do next:
  1. Try Honeybadger for FREE
    Honeybadger helps you find and fix errors before your users can even report them. Get set up in minutes and check monitoring off your to-do list.
    Start free trial
    Easy 5-minute setup — No credit card required
  2. Get the Honeybadger newsletter
    Each month we share news, best practices, and stories from the DevOps & monitoring community—exclusively for developers like you.
    author photo

    Starr Horne

    Starr Horne is a Rubyist and Chief JavaScripter at Honeybadger.io. When she's not neck-deep in other people's bugs, she enjoys making furniture with traditional hand-tools, reading history and brewing beer in her garage in Seattle.

    More articles by Starr Horne
    Stop wasting time manually checking logs for errors!

    Try the only application health monitoring tool that allows you to track application errors, uptime, and cron jobs in one simple platform.

    • Know when critical errors occur, and which customers are affected.
    • Respond instantly when your systems go down.
    • Improve the health of your systems over time.
    • Fix problems before your customers can report them!

    As developers ourselves, we hated wasting time tracking down errors—so we built the system we always wanted.

    Honeybadger tracks everything you need and nothing you don't, creating one simple solution to keep your application running and error free so you can do what you do best—release new code. Try it free and see for yourself.

    Start free trial
    Simple 5-minute setup — No credit card required

    Learn more

    "We've looked at a lot of error management systems. Honeybadger is head and shoulders above the rest and somehow gets better with every new release."
    — Michael Smith, Cofounder & CTO of YvesBlue

    Honeybadger is trusted by top companies like:

    “Everyone is in love with Honeybadger ... the UI is spot on.”
    Molly Struve, Sr. Site Reliability Engineer, Netflix
    Start free trial