We're working on something new! Hook Relay gives you Stripe-quality webhooks in minutes. Sign up for free today! Check out Hook Relay

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.


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:

<p>Here are some kinds of animals.</p>
<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>
<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) }

  # 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("#")

  # 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
        next_el = next_el.next_sibling

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",
   [{"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 %}
      {% 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 }}
        {% assign next_level = include.level | plus: 1 %}
        {% include navigation_item.html collection=item.children level=next_level %}
    {% endfor %}
{% 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!

Honeybadger has your back when it counts. We're the only error tracker that combines exception monitoring, uptime monitoring, and cron monitoring into a single, simple to use platform.

Our mission: to tame production and make you a better, more productive developer. Learn more

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.

“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
Try Error Monitoring Free for 15 Days
Are you using Bugsnag, Rollbar, or Airbrake for your monitoring? Honeybadger includes exception, uptime, and check-in monitoring — all for probably less than you’re paying now. Discover why so many companies are switching to Honeybadger here.
Try Error Monitoring Free for 15 Days