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:
/blog/2023/04/
should show all posts from April 2023/blog/2023/
should show all posts from 2023/blog/
should show all posts
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):
- https://github.com/11ty/eleventy/issues/502
- https://github.com/tomayac/blogccasion/issues/19
- https://github.com/11ty/eleventy/issues/316#issuecomment-441053919
- https://github.com/11ty/eleventy/issues/1284
- Group posts by year in Eleventy (Blog post)
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!
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.