The story of adding tag navigation to my website.
Published on
And so begins the second week of the new website, and one feature has been missing for a while that I wanted to implement: tags. What is a tag? How are they made? Can we finally “word cloud” like many Blogspot blogs before us? Let’s find out.
A tag on blogging platforms is for when you have too many posts and nobody knows where and how they want to start. If they’re only interested in certain content, ie. you’re posting a tutorial, or a drawing you made, you tag your content with whatever your post can be associated to, and the tags will help you navigate the posts with those matching tags. In essence, each tag generates an index of posts that have a matching tag.
This was a feature I designed Zeus in mind with, so it should be relatively simple to add.
In order to build an index of posts with related tags, we need an associative hash of tags to a list of posts that match said tags.
tags = {
"life": [post1, ...],
"tech": [post500, post700, ...],
}
By storying a key/value pair of tags to posts that have said tag, it’s relatively simple to do that in any programming language. But… We’re in Racket, and we’re using immutable hashes. What now? Do we start mutating?
No! First we need a function that can build for us a hash mapping of tags to posts, and we can do it recursively with a function. From our list of posts (metadata only), we can inspect the tagging information and apply some recursive code to build up a hash piece-by-piece.
First, we kind of need a new function to interact with hashes. Something that says “if the key in the hash maps to a list, add to that list, and if it isn’t a list, then make it a list, else add the new key and the value as a list”. Using hash-update
we can do this with the following function.
(define (append H K V)
(define (updater old-data)
(if (list? old-data)
(cons V old-data)
(list V old-data)))
(if (hash-has-key? H K)
(hash-update H K updater)
(hash-set H K (list V))))
(Note: this overrides the default list append
function, but I import this hash library using a prefix ie. (prefix-in Hash: ...)
)
With this in play, we need an algorithm that matches this functionality:
Posts = GetPosts()
TagMap = MakeMap()
for Post in Posts:
for Tag in Post.Tags:
TagMap[Tag] += Post
To keep this a purely functional codebase, we cannot just mutate references casually. Every modification to a structure has to be tied to some function call. Instead of thinking of this in terms of for
loops, they should instead be thought of as folds over a list of data with an applicative function. The type signature of foldl
(using Haskell) is:
foldl :: Foldable t => (b -> a -> b) -> b -> t a -> b
For a foldl
to work (fold-left-associative), we need something that can be “folded” over, and a function to take some value and an accumulative new value we’re building. I’ve talked about foldl
so I don’t want to dwell on it for too long, but as a refresher, let’s visualize a sum
function to add all numbers in a given list.
(define (sum lst)
(define (inner lst acc)
(if (empty? lst)
acc
(inner (cdr lst) (+ acc (car lst)))))
(inner lst 0))
This recursive pattern of traversing a list is the core of a foldable item, and instead of writing it in a recursive function, we can simply use a call to foldl
instead. We can even define product
and factorial
while we’re at it.
(define (sum lst)
(foldl + 0 lst))
(define (product lst)
(foldl * 1 lst))
(define (factorial x)
(foldl * 1 (range 1 (add1 x))))
This foldl
method is pretty powerful and can drive a ton of functions and clean up code in many different places. No longer might you suffer from clumsy-looking for
loops when you can break a function down into a foldl
expression instead. It’s an interesting thing to try to reduce your code down into, and in some cases can simplify a lot, however that’s not to say it’s suitable to replace every for
loop in Racket.
In this instance, we need two loops, or two foldl
’s nested in each other, as weird as that might look. What we end up with is a function that takes a list of metadata from my posts, and we get a hash that maps tags to respective lists of posts with matching tags.
(define (Create-Tag-Map listof-posts)
(foldl
(λ (post H1)
(let ([tags (Page:Data-tags post)])
(if (empty? tags)
(Hash:append H1 "untagged" post)
(foldl (λ (tag H2) (Hash:append H2 tag post))
H1 (Page:Data-tags post)))))
(make-immutable-hash '()) listof-posts))
Does this look bizarre? Yes, yes it does. Can this be replaced with for/hash
? No, not really. The take-away here is that we want to gradually add one post at a time to each tag it belongs to, so we iterate over each post’s tags, add the post to the matching tag group, then move onto the next tag, until the post is fully exhausted. for/hash
collects a linear mapping of values into a hash, whereas this itself can fold over a hash and add data to it. Note how I mark the hash inputs as H1
and H2
to separate the hash reference getting passed around in the folds.
What we end up with is a hash that maps tags to posts, and from here, we can move onto the next part: generating new pages.
Thankfully this didn’t end up being too much work, but it still ended up being around roughly ~50 lines of code added.
Once Zeus is finished building out all the pages from a section, we’re at a crossroads where we need to ask ourselves if we want to generate tags to help navigate the website. This is something configurable in Zola, as not everyone may want tags enabled for everything. A setting should be wedged into the section.json
of each section of our website to dictate whether we want tags for that section. Simply by adding:
{
...
"tags_enabled": "true",
...
}
To our section.json
, that should be a good enough user customization and it’s now customizable at the section-level instead of a global config-level (like I did on my first go of doing this feature).
However, we need something else: we need a template for these tags! Tags are effectively a new data type being passed into the template rendering system. What does a tag look like on the first sketch? It’s a two-value key/map that we’ll build out from the Zeus layer.
{
"name": "tag_name",
"path": "/<section>/<tag_name>"
}
There might be more later as I build out features like tag clouding but for right now this is fine. We need a template that can iterate over a list of tags and generate some <a>
links for users to click on. Let’s make a new template called tag_list.html
.
{% extends base.html %}
{% block content %}
<h2>All Tags</h2>
<p>{% for tag in tags %}
<a href="{{ tag.path }}">{{ tag.name }}</a>
{% endfor %}</p>
{% endblock %}
Building this feature out, I took some time to remind myself how proud I was for even getting to this point with Zeus and making a working templating system. I didn’t really have to stop and think about the intrinsics of it or whether this works; I wrote this template, plugged in the data, and it worked. That was a pretty nice feeling.
We’re going to need to plug this template in somewhere at the config-level, so let’s add a new key to section.json
.
{
...
"tags_enabled": "true",
"tag_template": "tag_list.html",
...
}
Now we can work on generating tags, but wait, we need a template to display posts filtered by tags. How do we do that? Well, thankfully, here we can re-use our original blog.html
template that I use to list all my posts. The difference now is that we have tag-relevant information. What if we leveraged the presence of tag data in what we pass to our blog.html
, and we can re-use the original template? That’s exactly what I’m gonna do.
{% extends base.html %}
{% block content %}
<h2>List of blog pages{% if tag %} tagged {{ tag }}{% endif %}</h2>
<ul>
{% for page in pages %}
<li><a href="/{{ page.url }}">{{ page.title }}</a></li>
{% endfor %}
</ul>
{% endblock %}
If we have a tag
key in our context, we add the text "tagged {{ tag }}"
into our header to make it look more correct. We don’t even need to modify the for page in pages
part, because we built a tag map that will pass the relevant pages easily.
Now for the finishing touches, in the following order, we will:
/<section>/tags
folder and index.html
index.html
/<section>/<tag>
folder and index.html
index.html
The code ends up looking like this.
(define tags-enabled
(trim-down (Hash:get-else section-json 'tags_enabled "")))
(when (string=? tags-enabled "true")
(define tag-map (Create-Tag-Map pages))
(define tag-tmpl (Hash:get section-json 'tag_template))
(define tag-root-folder (build-path Output-Directory section "tags"))
(define tag-root-index (build-path tag-root-folder "index.html"))
(make-directory* tag-root-folder)
(define env/alltags
(Hash:update Env 'tags
(map (tag-factory section)
(hash-keys tag-map))))
(Tmpl:Publish Tstore tag-tmpl env/alltags tag-root-index)
(for ([tag (hash-keys tag-map)])
(define tag-folder (build-path Output-Directory section tag))
(define tag-file (build-path tag-folder "index.html"))
(make-directory* tag-folder)
(define env/tagged-pages
(Hash:update (Hash:update Env 'tag tag)
'pages (map Page:Data-meta (Hash:get tag-map tag))))
(Tmpl:Publish Tstore section-template env/tagged-pages tag-file)))
With everything now in place, tags are discovered, posts are grouped, folders are created, and now we have a working tags feature on the website!
This was a miniature feature I planned on releasing, and it wasn’t overly complicated, and I think it makes it so much nicer to traverse posts. Best part of it all, this is something I put together by hand, and I really enjoyed it.
The overall goal of Zeus has been, and hopefully will continue, to be a simple codebase I can maintain to produce my personal website, and future websites I would like to develop. Writing this tag feature out, I’m starting to see some spots that could do with some improvement. Doing a Hash:update
within a Hash:update
was not as pretty as I might like it to be, so I think I need to do some tweaking there to make the code a little less crazy. 😅
This new feature will mark me tagging the code as v0.2
. Tests are passing currently with 18 unit tests, performance is still in-line with my expectation using a language like Racket, but overall, I’m proud of what I’ve accomplished so far.
$ make test
18 tests passed
$ time make build
make build 0.76s user 0.04s system 87% cpu 0.918 total
$ cat zeus/*.rkt | wc -l
1373
In the near future, I’d like to start working on automating the deployment process, so I hope to write about that sometime soon.
Thanks for reading and see you around!