eleventy lessons

Image unrelated to post. Close up on a pale green hellebore flower.

recently I wrote several sites using Eleventy (4? 5?). Including, over the past few days, rewriting this one! That's right, if you're reading this, we're now running on 11ty and hosted by heckin.technology. See ya, GitHub. Won't miss ya.

I've compiled some of the things I've learned in a standalone site: 11ty Lessons.

however, since I don't know how much I'll focus on that specific site - it is mostly a sample - I am re-publishing the most useful information here. I'll skip the intro to Markdown content. I'm also going to update them where I've learned more or to better match what's represented on this site.

this will comprise of 4 parts: related posts, featured images, pagination, and tag image preview. Feel free to jump ahead, as none depend on the others.


by default, the Eleventy base blog comes with pagination between posts. Post 2 can take you to posts 1 and 3, etc.

while that is useful for this site, when building another site I wanted to see a couple randomly-suggested posts that shared 1 or more tags.

I started by referring to this GitHub issue about related posts. I had to fix a few errors that arose from the suggested code.

I also wanted to make three changes:

  1. I didn't want to just see posts that shared all tags, but rather posts that shared any tag
  2. I wanted to randomly add a few posts instead of just getting whatever was first (with a shared tag) in the post order
  3. I wanted to exclude the posts that I could reach with between-post pagination

filters.js

after adjusting for those needs, I had the following in filters.js:

eleventyConfig.addFilter("excludeFromCollection", function (collection=[], urls=[this.ctx.page.url]) {
  return collection.filter(post => urls.indexOf(post.url) === -1);
});

eleventyConfig.addFilter("filterByTags", function(collection=[], ...requiredTags) {
  return collection.filter(post => {
    return requiredTags.flat().some(tag => post.data.tags.includes(tag));
  });
});

eleventyConfig.addFilter("randomize", function(array) {
  // Create a copy of the array to avoid modifying the original
  let shuffledArray = array.slice();

  // Fisher-Yates shuffle algorithm
  for (let i = shuffledArray.length - 1; i > 0; i--) {
      const j = Math.floor(Math.random() * (i + 1));
      [shuffledArray[i], shuffledArray[j]] = [shuffledArray[j], shuffledArray[i]];
  }

  return shuffledArray;
});

post.njk

I used this in my post layout. filterTagList comes with the base blog by default, and removes the tags "posts" and "all." head also comes with the base blog. postlist.njk is my modified-from-the-base-blog post layout.

{% set relevantTags = tags | filterTagList %}

{% set olderPost = collections.posts | getPreviousCollectionItem %}
{% set newerPost = collections.posts | getNextCollectionItem %}
{% set urlsToExclude = [page.url, olderPost.url, newerPost.url]}

{% set postlist = collections.posts | filterByTags(relevantTags) | excludeFromCollection(urlsToExclude) | randomize | head(3) %}
{% if postlist.length %}
<section class="related-posts">
	<h2>related posts</h2>
  {% include "postlist.njk" %}
</section>
{% endif %}

and that sorts related posts.


images in 11ty use the Image Transform Plugin. I found it hard to find anything to reference while building this - a lot of sites in the template gallery are either text-heavy or not using the plugin - so I'm reproducing what I've got here for reference.

file structure

content/
|--img/
|  |--sample-0.jpg
|  |--sample-1.jpg
|  `--etc
|--posts/
|  |--lesson-0-front-matter-and-urls.md
|  |--lesson-1-headings-paragraphs-and-horizontal-lines.md
|  `--etc
`--etc

front matter

for any given post, my front matter references the image in this manner:

---
image:
  src: sample-0.jpg
  alt: moss on a fencepost
---

image HTML transform

As mentioned, there's a plugin for images. If you started with the base blog, in eleventy.config.js, you'll probably find a chunk of code similar to this already in place:

eleventyConfig.addPlugin(eleventyImageTransformPlugin, {
  formats: ["auto"],

  widths: [640],
  failOnError: true,
  htmlOptions: {
    imgAttributes: {
      // e.g. <img loading decoding> assigned on the HTML tag will override these values.
      loading: "lazy",
      decoding: "async",
    }
  },

  sharpOptions: {
    animated: true,
  },
});

setting formats to "auto" helps - use whatever type of image you want, get that type out. The default settings that came with the Eleventy base blog didn't set a width, which I wanted (by default, images off my camera - like the hellebore featured image for this post - are almost 5k pixels wide). I also found it helpful to set failOnError to true for a little more feedback.

NOTE: It sure seems like Eleventy will fail your image processing if there's no alt text. While admirable, it would be nice if I could find any documentation for this!

passthrough copy

as I worked through this, I thought I needed a passthrough copy for a while. You don't - just let the plugin do its thing.

templating

I needed images to show up in 3 places:

  • In the lists of posts on the home page, I wanted each post to show its featured image
  • In the "related posts" section on each individual post, I wanted each related post to show its featured image
  • And of course, I wanted the post to show its own featured image

both of these sections use the same template, which in my setup is called postlist.njk. Within the relevant links, I added the following:

{% if post.data.image.src %}
  <img src="/img/{{ post.data.image.src }}" alt="{{ post.data.image.alt }}">
{% else %}
  <div class="missing-image"></div>
{% endif %}

post body

the post body looks similar:

{% if image.src %}
<img class="featured-img" src="/posts/img/{{ image.src }}" alt="{{ image.alt }}">
{% endif %}

together, that sets up featured images for posts.


pagination

simple pagination

Post pagination in Eleventy is pretty straightforward, mostly requiring some specific front matter.

The home page pagination I have set up here looks like the following (in index.njk):

---
pagination:
  data: collections.posts
  size: 13
  alias: posts
  reverse: true
---

6 posts per page, paginate data from collections.posts which we'll call just posts for short, and do it in reverse (aka, most recent posts show first).

You'll also likely want previous and next buttons. I did. Here's what I have...

pagination.njk

There's two components to this, the bigger one being this pagination.njk template. Look, I like my little icons, ok? It takes an olderHref and a newerHref, and optionally an olderTitle and newerTitle.

{% if olderHref or newerHref %}
<nav aria-label="pagination">
  <ol class="pagination {% if inPost %}post-pagination{% endif %}">
    {% if olderHref %}
    <li class="older">
      <a href="{{ olderHref }}">
        <i class="fa-solid fa-hand-point-left" aria-hidden="true"></i>
        {{ olderTitle or "older" }}
      </a>
    </li>
    {% endif %}
    {% if newerHref %}
    <li class="newer">
      <a href="{{ newerHref }}">
        {{ newerTitle or "newer" }}
        <i class="fa-solid fa-hand-point-right" aria-hidden="true"></i>
      </a>
    </li>
    {% endif %}
  </ol>
</nav>
{% endif %}

calling the template

From index.njk we can include "pagination.njk":

{# idk why these are backwards either #}
{% set newerHref = pagination.href.previous %}
{% set olderHref = pagination.href.next %}
{% include "pagination.njk" %}

Yup. The order of previous vs. next is totally unintuitive to me, too.

complex pagination

however, there's a catch. Tag pages are created via pagination! It's a lot harder to paginate those - you can't just use the front matter to set it up.

I untangled this GitHub issue about double-layered pagination and came to the following solution...

eleventy.config.js

in eleventy.config.js:

// note that this uses the lodash.chunk method, so you’ll have to import that
eleventyConfig.addCollection("tagPagination", function(collection) {
  // Get unique list of tags
  let tagSet = new Set(collection.getAllSorted().flatMap((post) => post.data.tags || []));

  // Get each item that matches the tag
  let paginationSize = 6;
  let tagMap = [];
  let tagArray = [...tagSet];

  for( let tagName of tagArray) {
    let tagItems = collection.getFilteredByTag(tagName);
    let pagedItems = chunk(tagItems.reverse(), paginationSize);

    for( let pageNumber = 0, max = pagedItems.length; pageNumber < max; pageNumber++) {
      tagMap.push({
        tagName: tagName,
        pageNumber: pageNumber,
        pageSize: pagedItems.length,
        pageData: pagedItems[pageNumber]
      });
    }
  }

  return tagMap;
});

tag-pages.njk

in my tag-pages.njk file (or whatever you use to template out your tag pages):

---
pagination:
  data: collections.tagPagination
  size: 1
  alias: tag
eleventyComputed:
  permalink: /tags/{{ tag.tagName | slugify }}/% if tag.pageNumber %{{ tag.pageNumber + 1 }}/% endif %
  title: "Posts tagged {{ tag.tagName }}"
eleventyExcludeFromCollections: true
---
<h1>Posts tagged “{{ tag.tagName }}”</h1>

{% set postlist = tag.pageData %}
{% include "postlist.njk" %}

{# idk why these are backwards either #}
{% if tag.pageNumber > 0 %}
  {% set newerHref = pagination.href.previous %}
{% endif %}
{% if tag.pageNumber < tag.pageSize - 1 %}
  {% set olderHref = pagination.href.next %}
{% endif %}
{% include "pagination.njk" %}

note the pagination checking tag.pageNumber against tag.PageSize - the original suggested solution in the GitHub post creates an issue where the pagination loops through all of the tag pages bit-by-bit. This sorts that - hat tip to TheReyzar who mentioned the issue and showed part of their solution.

filters.js (again)

finally, in my filters.js file, I add the tagPagination tag to the tags that get filtered using filterTagList:

eleventyConfig.addFilter("filterTagList", function filterTagList(tags) {
  return (tags || []).filter(tag => ["all", "posts", "tagPagination"].indexOf(tag) === -1);
});

tag image preview

today I tackled making the tag page more visually interesting.

first, I worked on previewing the first featured image. The focus here is on digging into collections[tag] (reversed!) to get to the data of the first post.

<ul class="taglist">
{% for tag in collections | getKeys | filterTagList | sortAlphabetically %}
	{% set tagUrl %}/tags/{{ tag | slugify }}/{% endset %}
	<li>
    <a href="{{ tagUrl }}" class="taglist-link">
      {% set tagRecent = collections[tag] | reverse %}
      {% if tagRecent[0].data.image.src %}
        <img src="/posts/img/{{ tagRecent[0].data.image.src }}" alt="{{ tagRecent[0].data.image.alt }}">
      {% else %}
        <div class="missing-image"></div>
      {% endif %}
      <span class="post-tag">{{ tag }}</span>
      {% set numPosts = collections[tag].length %}
      ({{ numPosts }} post{% if numPosts > 1 %}s{% endif %})
    </a>
  </li>
{% endfor %}
</ul>

I found that having just the first featured image made the tag page hard to differentiate from any of the pages listing individual posts, so from there I moved to showing 4 images (or empty rectangles where there weren't four to show).

<ul class="taglist">
{% for tag in collections | getKeys | filterTagList | sortAlphabetically %}
	{% set tagUrl %}/tags/{{ tag | slugify }}/{% endset %}
	<li>
    <a href="{{ tagUrl }}" class="taglist-link">
      <div class="tag-imgs">
        {% set tagRecent = collections[tag] | reverse %}
        {% for i in range(0, 4) %}
        {% if tagRecent[i].data.image.src %}
          <img src="/posts/img/{{ tagRecent[i].data.image.src }}" alt="{{ tagRecent[i].data.image.alt }}">
        {% else %}
          <div class="missing-image"></div>
        {% endif %}
        {% endfor %}
      </div>
      <span class="post-tag">{{ tag }}</span>
      {% set numPosts = collections[tag].length %}
      ({{ numPosts }} post{% if numPosts > 1 %}s{% endif %})
    </a>
  </li>
{% endfor %}
</ul>

with a bit of display: grid, we're good to go, right?

handling multiple aspect ratios

this had worked so far because the photos on the lessons site are from my camera in landscape mode, producing neat, identical 3:2 aspect ratios. Let's throw a wrench in that and introduce a portrait-mode photo.

thankfully, the CSS property aspect-ratio makes this pretty straightforward, and object-fit finishes the job.

.taglist-link img {
  aspect-ratio: 3 / 2;
  object-fit: cover;
}

(I also set the missing-img <div>s to have the same aspect ratio.)


There's the good stuff from 11ty Lessons. Hope you enjoyed.