11ty: Index ALL the things!

4 min read 0 comments

This is a second spinoff post in the migration saga of this blog from WordPress to 11ty.

On good URLs

It was important to me to have good, RESTful, usable, hackable URLs. While a lot of that is easy and comes for free, following this principle with Eleventy proved quite hard:

URLs that are “hackable” to allow users to move to higher levels of the information architecture by hacking off the end of the URL

What does this mean in practice? It means it’s not enough if tags/foo/ shows all posts tagged “foo”, tags/ should also show all tags. Similarly, it’s not enough if /blog/2023/04/private-fields-considered-harmful/ links to the corresponding blog post, but also:

Eleventy “Pagination” Primer

Eleventy has a pagination feature, which actually does a lot more than pagination: it’s used every time you want to generate several pages from a single template by chunking object keys and using them in permalinks.

One of the most common non-pagination use cases for it is tag pages. The typical /tags/tagname/ page is generated by a deceptively simple template:

---
pagination:
  data: collections
  size: 1
  alias: tag
  filter: ["blog", "all"]
permalink: /blog/tags/{{ tag }}/
override:tags: []
eleventyComputed:
  title: "{{ collections[ tag ].length | pluralize('post') }} on {{ tag | format_tag }}"
---

{% set taglist = collections[ tag ] | reverse %}
{# ... Loop over taglist here ... #}

That was it, then you just loop over taglist (or collections[ tag ] | reverse directly) to template the posts under each tag in reverse chronological order. Simple, right? But what about the indices? As it currently stands, visiting /blog/tags/ will just produce a 404.

Index of all tags

Creating an index of all tags only involves a single page, so it does not involve contorting the pagination feature to mind-bending levels, like the rest of this post. However, we need to do some processing to sort the tags by post count, and remove those that are not “real” tags.

There are many ways to go about with this.

The quick and dirty way

The quick and dirty way is to just iterate over collections and count the posts for each tag:

<ol>
{% for tag, posts in collections %}
	<li>{{ tags.one(tag) }}
		({{ posts.length }} posts)
	</li>
{% endfor %}
</ol>

Unfamiliar with the tags.one() syntax above? It’s using Nunjucks macros (there’s a {% import "_tags.njk" as tags %} earlier in the template too). Macros allow you to create parameterized templates snippets, and I’ve come to love them during this migration project.

The problem is that this does not produce the tags in any particular order, and you usually want frequently used tags to come first. You could actually fix that with CSS:

<ol>
{% for tag, posts in collections %}
	<li style="order: {{ collections.all.length - posts.length }}">
		{{ tags.one(tag) }}
		({{ posts.length }} posts)
	</li>
{% endfor %}
</ol>

The only advantage of this approach is that this is entirely doable via templates and doesn’t require any JS, but there are several drawbacks. First, it limits what styling you can use: for the order property to actually have an effect, you need to be using either Flexbox or Grid layout. But worse, the order property does not affect the order screen readers read your content one iota.

Dynamic postsByTag collection

To do it all in Eleventy, the most common way is a dynamic collection, added via eleventyConfig.addCollection():

config.addCollection("postsByTag", (collectionApi) => {
	const posts = collectionApi.getFilteredByTag("blog");
	let ret = {};

	for (let post of posts) {
		for (let tag of post.data.tags) {
			ret[tag] ??= [];
			ret[tag].push(post);
		}
	}

	// Now sort, and reconstruct the object
	ret = Object.fromEntries(Object.entries(ret).sort((a, b) => b[1].length - a[1].length));

	return ret;
});

That we then use in the template:

<ol>
{% for tag, posts in collections.postsByTag %}
	<li>
		{{ tags.one(tag) }} ({{ posts }} posts)
	</li>
{% endfor %}
</ol>

Custom taglist filter

Another way is a custom filter:

config.addFilter("taglist" (collections) => {
	let tags = Object.keys(collections).filter(filters.is_real_tag);
	tags.sort((a, b) => collections[b].length - collections[a].length);

	return Object.fromEntries(tags.map(tag => [tag, collections[tag].length]));
});

used like this:

<ol>
{% for tag, posts in collections | taglist %}
	<li>
		{{ tags.one(tag) }} ({{ posts }} posts)
	</li>
{% endfor %}
</ol>

Usually, filters are meant for more broadly usable utility functions, and are not a good fit here. However, the filter approach can be more elegant if your use case is more complicated and involves many different outputs. For the vast majority of use cases, a dynamic collection is more appropriate.

Index of posts by year

Generating yearly indices can be quite similar as generating tag pages. The main difference is that for tags the collection already exists (collections[tag]) whereas for years you have to build it yourself, using addCollection() in your config file.

This seems to come up pretty frequently, both for years and months (the next section):

This is what I did, after spending a pretty long time reading discussions and blog posts:

eleventyConfig.addCollection("postsByYear", (collectionApi) => {
	const posts = collectionApi.getFilteredByTag("blog").reverse();
	const ret = {};

	for (let post of posts) {
		let key = post.date.getFullYear();
		ret[key] ??= [];
		ret[key].push(post);
	}

	return ret;
});

and then, in blog/year-index.njk:

---
pagination:
  data: collections.postsByYear
  size: 1
  alias: year
permalink: /blog/{{ year }}/
override:tags: []
eleventyComputed:
  title: "Posts from {{ year }}"
---

{% import "_posts.njk" as posts %}
{{ posts.list(collections.postsByYear[year], {style: "compact"}) }}

You can see an example of such a page here: Posts from 2010.

Bonus, because this collection is more broadly useful, I was able to utilize it to make a little yearly archives bar chart!

Bar chart of posts by year, with 2012 selected and text above it reading "28 posts"

Index of posts by month

Pagination only works on one level: You cannot paginate a paginated collection (though Zach has a workaround for that that I’m still trying to wrap my head around). This also means that you cannot easily paginate tag or year index pages. I worked around that by simply showing a more compact post list if there are more than 10 posts.

However, it also means you cannot process the postsByYear collection and somehow paginate by month. You need to create another collection, this time with the year + month as the key:

config.addCollection("postsByMonth", (collectionApi) => {
    const posts = collectionApi.getFilteredByTag("blog").reverse();
    const ret = {};

    for (let post of posts) {
        let key = filters.format_date(post.date, "iso").substring(0, 7); // YYYY-MM
        ret[key] ??= [];
        ret[key].push(post);
    }

    return ret;
});

And a separate [blog/month-index.njk]({{ site.repo_file }}/blog/month-index.njk) file:

---
pagination:
  data: collections.postsByMonth
  size: 1
  alias: month
permalink: /blog/{{ month | replace("-", "/") }}/
override:tags: []
eleventyComputed:
  title: "Posts from {{ month | format_date({month: 'long', year: 'numeric'}) }}"
---

{% import "_posts.njk" as posts %}
{{ posts.list(collections.postsByMonth[month], {style: "compact"}) }}

You can see an example of such a page here: Posts from December 2010.