Getting a dynamic menu in Jekyll is apparently really hard.
All the examples available either use ruby plugins, have the menu hardcoded into the
_config.yml or just don’t work. Thinkshout made a good stab at it, wisely noting that urls provide an excelent hierachy for you to parse, but it doesn’t quite cut it. The menu dissapears if you’re on any page outside the menu, and a brief look brings to light a number of unhandled edge-cases.
This is the first time I’ve actually done anything with jekyll or liquid for that matter. I spent a whole weekend and 2 days after work learning the liquid idiosyncracies by trial and error and eventually stumbled upon a menu that seems to work.
My menu is:
- Loads the menu items with classes indicating their state (branch/leaf, open/closed, active/inactive)
- Pure liquid - should work on any Jekyll site (Including github pages)
The nitty gritty
I’m using my main include recursively, and another include as a boolean function. (With the slight difference from the linked article that I’m using jekyll’s global variables instead of capturing the output as a string)
The main include:
The boolean function:
Starting right off the bat we’ve got our menu and our loop through the
site.pages, and our function include:
The include sets the
retval global variable to either
false depending on whether or not the link in question should be shown at this point in the menu. We pass in the page in question and the current
include.url which acts as both a recursion depth check and a location. (So we don’t display menu items at the same depth but under a different tree)
Looking at the function include, we first set the global to
false - all these variables are global and we don’t want a
true from the previous iteration gumming up the works:
The next mouthful checks the current recursion url for an index file then strips it to make the matching easier. (
/folder/index.html is basically the same as
- I’d split the filename by
.but in the first iteration there’s a
nilafter the first split and jekyll will refuse to split a
- I’m not worried about not having slashes at the end, because without the slashes jekyll spawns an extensionless file and my browser tries to download it - the menu won’t show up right on that page, but neither will the rest of the page so you’ve got a bigger problem!
- I only check the name of the file because it’s entirely possible you might want jekyll to generate PHP files (Or some other extension)
- Liquid’s oh-so-wonderful consistency means that
'/' | split: '/' | sizeis 0 and
'/a' | split: '/' | sizeis 2! This is one of 2 reasons we append
'/'at the end.
What happens when you have a folder called
Well then you don’t have an actual index file in this folder (Conflicting names) and it can’t possibly go any deeper anyway. Besides before you get to the index check it already checks to see if the last character is a
Here we do the same thing to the node url, but now we’re just doing it to get the node’s depth. We get the recursion depth while we’re at it:
We manually assign
nodedepth to 1 if it’s below it. 1 is the depth of our
We do the same to
thisdepth if the recursion url is
''. If we did
'/' we’d have the same problem as with
'/index.html' where the index file is a child of itself and jekyll freaks out with a recursion depth error.
This way when jekyll iterates to
'/' and tries to find children the depth of 0 will result in none found. The root index is quite the odd exception.
nodedepth is an artifact of the fact that
include.node is a subpage (IE:
include.url is it’s parent (IE:
Now we calculate the size of the
levelpath (Which you might remember is the cleaned-up
include.url AKA recursion depth) and truncate the current page and current node urls so we can check for equality:
This is why we appended the
levelpath at the start - without that we might match
/sub2/page.html as being a child of
/sub since they both start the same.
include.children variable basically makes the function ignore the current page url in all this, but we’ll go into more depth on that later.
Finally the if statement:
- The standard check for a title is there
- Then we check whether the node path starts with the recursion path. (Whether the node is under the recursion path)
- Next we check whether the page is under the recursion path so we don’t open hierarchies we’re not currently visiting.
- Lastly we check that the depth of the item is correct so we don’t get any further nested pages.
If all these pass we set
The less gritty
Now that we’re done with the hard part we can get back to the actual menu.
We’ve established that this item needs to go here, now we need to establish what type it is.
As always we start off by assigning the variables to
false so holdovers from the last iteration don’t surprise us:
So we start looping through the sub pages to find out if there are any by passing the
child_node instead of the
include.url and the
Surprise! We’re going back to the function include!
Remember this? What the
include.children parameter determines is whether or not the viewer has to be viewing a page in the hierarchy to see the items.
When we set this to
true we can do several interesting things:
- Find out whether we would show menu items if we were on that page, as seen in the subloop:
- Show all menu items even when they are outside our current path based on a config variable, as seen in the first include:
We know this menu item (
node) will be shown, and we know it has sub items (
branch) but we don’t know whether it is open yet.
If it’s open we’re going to add a class to the
li so we can theme it appropriately, and (more importantly) we can decide whether or not to recurse and generate another menu.
We could use the global variable
levelpath for a simple equality check but taking that out of the function include feels even more icky than writing this thing in liquid, and recreating
pagebase just for an equality check feels ickier still.
DRY: We have a function that checks those already! Sure it’s doing the depth and node path checks twice over but we’re already looping over way too many nodes because liquid doesn’t have a break statment anyway:
Lastly we throw performance to the wind and do it again to determine whether this node is active - notably we leave
children off of this since even with
site.menu_show_all we don’t want active classes going on open branches that aren’t in this path.
We finish off by finally rendering the menu item complete with classes, and recursing to the next level with the
include.url set to
We finish the loop and finish the menu.
And that is why it took 5 days of trial and error :P