blogccasion

Eleventy (11ty) year, year-month, and year-month-day indexes

I love hackable URLs. A hackable URL is a URL that makes sense to a human reader, and where the human reader can guess what to change to get to another page. For example, if you look at the URL of this very blog post, https://blog.tomayac.com/2024/11/02/eleventy-11ty-year-year-month-and-year-month-day-indexes/, what would you expect happens if you hack the URL to any of the following values?

.eleventy.js

If you have a URL structure that's similar to mine, feel free to copy the relevant excerpts quoted in the following snippet from my .eleventy.js and add them to your .eleventy.js. The _.chain() function is from the lodash library.

// Year collection
eleventyConfig.addCollection('postsByYear', (collection) => {
  return _.chain(collection.getAllSorted())
    .filter((item) => 'tags' in item.data && item.data.tags.includes('posts'))
    .groupBy((post) => post.date.getFullYear())
    .toPairs()
    .reverse()
    .value();
});

// Year / Month collection
eleventyConfig.addCollection('postsByYearMonth', (collection) => {
  return _.chain(collection.getAllSorted())
    .filter((item) => 'tags' in item.data && item.data.tags.includes('posts'))
    .groupBy((post) => {
      const year = post.date.getFullYear();
      const month = String(post.date.getMonth() + 1).padStart(2, '0');
      return `${year}/${month}`;
    })
    .toPairs()
    .reverse()
    .value();
});

// Year / Month / Day collection
eleventyConfig.addCollection('postsByYearMonthDay', (collection) => {
  return _.chain(collection.getAllSorted())
    .filter((item) => 'tags' in item.data && item.data.tags.includes('posts'))
    .groupBy((post) => {
      const year = post.date.getFullYear();
      const month = String(post.date.getMonth() + 1).padStart(2, '0');
      const day = String(post.date.getDate()).padStart(2, '0');
      return `${year}/${month}/${day}`;
    })
    .toPairs()
    .reverse()
    .value();
});

// Helper filter to format month names
eleventyConfig.addFilter('monthName', (monthNum) => {
  const date = new Date(2000, parseInt(monthNum) - 1, 1);
  return date.toLocaleString('en-US', { month: 'long' });
});

// Helper filters for parsing date parts
eleventyConfig.addFilter('getYear', (dateStr) => dateStr.split('/')[0]);
eleventyConfig.addFilter('getMonth', (dateStr) => dateStr.split('/')[1]);
eleventyConfig.addFilter('getDay', (dateStr) => dateStr.split('/')[2]);

Nunjucks templates

Then, in your blog's root, add three files:

  • year-index.njk
  • year-month-index.njk
  • year-month-day-index.njk

They're all three pretty similar, but for the sake of completeness, here are all three.

year-index.njk:

---
pagination:
  data: collections.postsByYear
  size: 1
  alias: year
layout: layouts/home.njk
permalink: /{{ year[0] }}/
---

<h2>{{ year[0] }} Archive</h2>

{% for postedYear, yearPosts in collections.postsByYear %}
  {% if postedYear === year[0] %}
  <ul class="postlist">
    {% for post in yearPosts | reverse %}
      <li class="postlist-item{% if post.url == url %} postlist-item-active{% endif %}">
        <a href="{{ post.url | url }}" class="postlist-link">{% if post.data.title %}{{ post.data.title }}{% else %}<code>{{ post.url }}</code>{% endif %}</a>
        <time class="postlist-date" datetime="{{ post.date | htmlDateString }}">{{ post.date | readableDate }}</time>
      </li>
    {% endfor %}
  </ul>
  {% endif %}
{% endfor %}

year-month-index.njk:

---
pagination:
  data: collections.postsByYearMonth
  size: 1
  alias: yearMonth
layout: layouts/home.njk
permalink: /{{ yearMonth[0] }}/
---

<h2>{{ yearMonth[0] | getMonth | monthName }} {{ yearMonth[0] | getYear }} Archive</h2>

{% for postedYearMonth, monthPosts in collections.postsByYearMonth %}
  {% if postedYearMonth === yearMonth[0] %}
  <ul class="postlist">
    {% for post in monthPosts | reverse %}
      <li class="postlist-item{% if post.url == url %} postlist-item-active{% endif %}">
        <a href="{{ post.url | url }}" class="postlist-link">{% if post.data.title %}{{ post.data.title }}{% else %}<code>{{ post.url }}</code>{% endif %}</a>
        <time class="postlist-date" datetime="{{ post.date | htmlDateString }}">{{ post.date | readableDate }}</time>
      </li>
    {% endfor %}
  </ul>
  {% endif %}
{% endfor %}

year-month-day-index.njk:

---
pagination:
  data: collections.postsByYearMonthDay
  size: 1
  alias: yearMonthDay
layout: layouts/home.njk
permalink: /{{ yearMonthDay[0] }}/
---

<h2>{{ yearMonthDay[0] | getMonth | monthName }} {{ yearMonthDay[0] | getDay }}, {{ yearMonthDay[0] | getYear }} Archive</h2>

{% for postedYearMonthDay, dayPosts in collections.postsByYearMonthDay %}
  {% if postedYearMonthDay === yearMonthDay[0] %}
  <ul class="postlist">
    {% for post in dayPosts | reverse %}
      <li class="postlist-item{% if post.url == url %} postlist-item-active{% endif %}">
        <a href="{{ post.url | url }}" class="postlist-link">{% if post.data.title %}{{ post.data.title }}{% else %}<code>{{ post.url }}</code>{% endif %}</a>
        <time class="postlist-date" datetime="{{ post.date | htmlDateString }}">{{ post.date | readableDate }}</time>
      </li>
    {% endfor %}
  </ul>
  {% endif %}
{% endfor %}

Helped by AI

And here's my dirty, little secret 🀫: I only actually coded year-index.njk myself, and then asked Claude to code the rest for me.

Initial prompt

I have a blog built with Eleventy. It uses a URL structure
that is https://blog.tomayac.com/$year/$month/$day/$title/.
For example, https://blog.tomayac.com/2024/08/26/my-response-to-the-cma/.

I already have a way to list all posts published in a year by
navigating to https://blog.tomayac.com/$year/. Now I want two
levels deeper and get first https://blog.tomayac.com/$year/$month/,
that is, all posts published in a given month, and
https://blog.tomayac.com/$year/$month/$day/, that is, all posts
published on a given year.

For the year index, this is how I got it to work:

In .eleventy.js:
eleventyConfig.addCollection('postsByYear', (collection) => {
    return _.chain(collection.getAllSorted())
      .filter((item) => 'tags' in item.data && item.data.tags.includes('posts'))
      .groupBy((post) => post.date.getFullYear())
      .toPairs()
      .reverse()
      .value();
  });

And then a Nunjucks file year-index.njk:

---
pagination:
  data: collections.postsByYear
  size: 1
  alias: year
layout: layouts/home.njk
permalink: /{{ year[0] }}/
---
<h2>{{ year[0] }} Archive</h2>
{% for postedYear, yearPosts in collections.postsByYear %}
  {% if postedYear === year[0] %}
  <ul class="postlist">
    {% for post in yearPosts | reverse %}
      <li class="postlist-item{% if post.url == url %} postlist-item-active{% endif %}">
        <a href="{{ post.url | url }}" class="postlist-link">{% if post.data.title %}{{ post.data.title }}{% else %}<code>{{ post.url }}</code>{% endif %}</a>
        <time class="postlist-date" datetime="{{ post.date | htmlDateString }}">{{ post.date | readableDate }}</time>
      </li>
    {% endfor %}
  </ul>
  {% endif %}
{% endfor %}

Can you create the rest?

Correcting prompt

It worked on the second attempt. In the first attempt, it invented a split Nunjucks filter, so I just told it about the error, and after that it just worked.

This fails now:
Error: filter not found: split (via Template render error)

Conclusion

There may be more elegant ways to achieve this, but this approach is what worked for me, and, hey, it all happens on the server at build time, so you, dear reader, get just the optimized HTML. Happy URL hacking! Oh, and whatever happened on March 3, 2009?