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!

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
    An advertisement for Honeybadger that reads 'Turn your logs into events.'

    "Splunk-like querying without having to sell my kidneys? nice"

    That’s a direct quote from someone who just saw Honeybadger Insights. It’s a bit like Papertrail or DataDog—but with just the good parts and a reasonable price tag.

    Best of all, Insights logging is available on our free tier as part of a comprehensive monitoring suite including error tracking, uptime monitoring, status pages, and more.

    Start logging for FREE
    Simple 5-minute setup — No credit card required