How to build a blog series with 11ty/Eleventy
Two ways to assemble a series in 11ty / Eleventy
When I set out to build this site, I wanted to use a flat file system because I didn't want to deal with WordPress database problems and other such issues. Flat files were refreshingly simple and secure. So, what should I use in that case?
I stumbled upon the Jamstack concept, which looked right up my alley. The idea of Static Site Generators in particular looked interesting. The basic idea with an SSG is that your website is generated from your static assets: a Markdown or HTML page, CSS, images, and Javascript. Then you commit the files to a GitHub repository, and deploy via Netlify or other options. There's no database like with WordPress; no backend is required. There are several SSGs out and about now. First I found Hugo, which I may take a second look at later. I also looked at Eleventy (also spelled 11ty). Eleventy seemed more fun to get into, and I did learn a lot from delving into it.
However, there is a cost to going off the beaten path. In my case, it turned out that the 11ty / Eleventy platform was not set up for blogging right off the bat. What I mean is, I took for granted that taxonomy is a concept everyone in this arena understands. It was something I had to deal with whenever the Detroit Free Press underwent a site migration or re-design, among other reasons. I assumed the concept would be accounted for in architecting an Eleventy site.
It was not.
Taxonomy is the classification of content. The first problem I ran into was Eleventy's peculiar assumption that all content would have tags, and there was no accounting for categories. Furthermore, what of a series, which are articles related by topic? Tags alone are inadequate, which meant I was obliged to figure out the latter two. An exercise that would ordinarily prompt me to try other SSGs instead, because working this out doesn't pass the WIBBOW test: Would I be Better off Writing? However, 11ty was more fun than my other options … and I also have trouble resisting a challenge.
I'm writing this post because I saw some confusion re: taxonomy, so I hope this will all be clear. If you already savvy taxonomy, here's your skip link: Two Options for Series Taxonomy
Let me illustrate the difference between a tag and a category: Science fiction is a genre, which is a category of fiction. Mysteries are a genre, ergo another category of fiction. Same for romance, same for fantasy. You get the picture. Let's focus on mysteries.
There is more than one kind of subcategory (subgenre) for mysteries: cozies with amateur sleuths who are unpaid, private investigators who are paid, or police procedurals where a cop is solving the mystery, etc.
Now, in any of these subcategories of mysteries, there may be what's called a locked room mystery, which is a mystery where there's no obvious way the killer could have gotten in or out of the room where the murder took place. "Locked-room mystery" is a tag because: Nancy Drew (amateur sleuth), Poirot (PI), or Detective Frank Pembleton (police procedural) could be investigating that case. Or Security Chiefs Odo or Garibaldi, if this were on Deep Space 9 / Babylon 5 and therefore the category of science fiction. PI Harry Dresden or amateur Diana Tregarde get involved if this is the fantasy subcategory of urban fantasy. And then there's Phryne Fisher or Marcus Didius Falco if we're looking at historical mysteries.
Either way, the "locked room" tag page would include mysteries from assorted categories (science fiction, fantasy, mystery) and assorted subcategories, e.g., amateur detective, historical mystery, or space station (if space station settings are a subcategory of science fiction).
TL;DR: Tags span multiple categories.
And you can see why organizing content with tags alone will be pointlessly unwieldy and mushy for sifting through pages of content.
But now let's turn to series. At the Detroit Free Press, what I'm calling a series was called a theme, which is not at all inaccurate — one does write on a theme after all — but that word is spoken for in the world of weblogs because theme in that scenario refers to a site's appearance and not a topic. Fair enough!
Now. Let's run with the locked-room business. Suppose I wanted to write multiple posts on how to write locked-room mysteries? The pitfalls, the cliches, the techniques, etc. This is where the series taxonomy comes in, because I could link all of those posts together as a series. Sure they could have the locked room tag, but so would all of the other posts about books where locked-room crimes happen. If you want this specific grouping on how to write locked-room mysteries, then you need a series taxonomy.
Or think of it this way: in the fantasy category, you have a couple of books where some little people team up with a wizard, an elf, a dwarf and a ranger to destroy the One Ring forged by the Dark Lord, Sauron. This story spans three separate books. If you were in a bookstore or library it would be ... vexing ... for these books to not be grouped together. They're all related, beyond their category or tag. So Lord of the Rings is the series name, and you can find those related books together. They're not separated and mingled with The Dark is Rising Sequence or the Chronicles of Narnia books just because they share the same category (fantasy) and may be tagged as save-the-world. To go step further, if "Tolkien" were a tag, you'd still need a series taxonomy, because he wrote other books besides the LotR trilogy.
Make sense? Is this horse good and dead? Onward!
Two Options for Series Taxonomy
I really had to search around for guidance on this one, and I found a few useful sources. I'll show you two approaches to choose from.
Option One: External Table of Contents Page
This one I discovered courtesy of this post: How I create an article series in Eleventy.
All of the posts in a series are listed on this separate page. And individual posts within that series will have a colophon at the top or bottom of the page that will tell you, This is article 1 of 10 in the XYZ series.
I preferred the idea of using the .json data file to keep track of the series metadata, which I discovered in a forked version. That is the one I will go into.
Option Two: In-Post Table of Contents
In this scenario the series TOC is inside the post. The colophon is obviously not needed in that case. I discovered this version at A11yProject (note that the lls are actually number ones) while I was researching web accessibility. Scroll to the bottom of the article, where you see this:
Caveat
My preference for these options hinges on the fact that they're "neat." Organized. They create a collection object (all collections are javascript objects in Eleventy) and tuck them neatly into a place e.g., a json file in the _data folder, or a series collection folder. If you have multiple series they're not "cluttering up" an eleventy.js file or anywhere else. And, you can see from the back end of your site exactly what series you have, and how to refer to them.
Option One
Before we proceed, take note of this file tree here:
root_folder/
|-- _site/
|-- _11ty/
| |-- collections/
| | |-- getTagList.js
| | |-- series.js
| |-- filters/
| |-- getSeries.js
|-- _data/
| |-- seriesData.json
|-- _includes/
| |-- layouts/
| | |-- base.njk
| | |-- home.njk
| | |-- post.njk
| |-- postslist.njk
|-- assets/
| |-- css/
| |-- img/
|-- content/
| |-- pages/
| | |-- 404.md
| | |-- archive.njk
| | |-- index.njk
| | |-- series.njk
| | |-- taglist.njk
| | |-- tags.njk
| |-- posts/
| | |-- blogpost1.html
| | |-- blogpost2.html
| | |-- blogpost3.html
| | |-- posts.json
|-- .eleventy.js
|-- package.json
|-- README.md
This is more or less how I organize the files and directories (folders) in 11ty, as seen in CodeSandbox / Stackblitz.
The relevant directories:
- _11ty: where I place the subfolders for collections, filters, and utilities.
- _data: here you set up the slugs that will make up the backbone of the series. Every post with a given series slug will be linked as a series.
- _includes/layouts/post.njk: this is where we put the code to add a link to the table of contents page for a series. It will appear on a relevant blog post
- content / pages / series.njk: where we set up the series table of contents page
- content / posts: self-explanatory
- .eleventy.js: in some versions of 11ty, this is called eleventy.config.js. Note the absence of a period before the eleventy in that scenario.
1. seriesData.json
In your _data folder, create a file named seriesData.json. Inside this file, you'll keep a json array with the series slug, name, and preface / info. The original sites I took this from said "description" instead of preface or info. The reason I changed it is because all of my posts have descriptions (item.data.description), and 11ty prioritizes the frontmatter of the post. Think of your CSS, where you may have two conflicting styles, but whichever one style is "closer" (later) in the stylesheet gets expressed; the farther (higher) one does not.
The slug for the first series is "evil-husbands-curious-wives" and the slug for the second is "swan-wives".
{
"evil-husbands-curious-wives": {
"name": "Evil Husbands and Their Curious Wives",
"info": "Exploring a type of folktale wherein an evil husband is bested by his curious wife"
},
"swan-wives": {
"name": "The Swan Wives",
"info": "Women who turn into swans, and the men who love them."
}
}
2. Series.js
You will place series.js in the directory / subdirectory of _eleventy / collections
/**
* Blog / Article series collections
* @param {*} collection
* @returns published posts in designated series order
*/
const seriesData = require("./../../_data/seriesData.json");
module.exports = function (collection) {
// get all posts in chronological order
const posts = collection.getSortedByDate();
// this will store the mapping from series to lists of posts; it can be a regular object if you prefer
const mapping = new Map();
// loop over the posts
for (const post of posts) {
// get any series data for the current post, and store the date for later
const { series, info, date, title, part } = post.data;
// ignore anything with no series data
if (series === undefined) {
continue;
}
// if we haven’t seen this series before, create a new entry in the mapping
// (i.e. take the description from the first post we encounter)
if (!mapping.has(series)) {
mapping.set(series, {
name: seriesData[series].name,
posts: [],
info: seriesData[series].info,
part: post.data.part,
title,
date,
});
}
// get the entry for this series
const existing = mapping.get(series);
// add the current post to the list
existing.posts.push({
name: seriesData[series].name,
title: title,
info: info,
date: date,
url: post.url,
part: part,
});
// update the date so we always have the date from the latest post
existing.date = date;
}
// now to collect series containing more than one post as an array that
// Eleventy can paginate
const normalized = [];
// loop over the mapping (`k` is the series title)
for (const [
slug,
{ title, name, posts, part, info, date },
] of mapping.entries()) {
if (posts.length > 1) {
// add any series with multiple posts to the new array
normalized.push({ slug, title, name, posts, part, info, date });
}
}
// return the array
return normalized;
};
Please observe Line 1, where it calls for seriesData.json file, so if you've arranged your folders and files differently make sure to update the location of seriesData.json in the series.js file.
3. The getSeries filter
The filter, getSeries.js, goes in the directory / subdirectory of _eleventy / filters/
/* Goes with the Blog / Article series collections */
module.exports = function filterSeries(series) {
return (series || []).filter((x) => x.data.series);
};
4. Inside the posts template
The template I use for blog posts is in a file called posts.njk, which I keep in the layouts folder, which is itself inside the _includes folder. Place the code below in your template in the part of your page where you want your series link to appear.
{%- if collections.series -%}
{%- set posts = collections.series[series] -%}
<sub>Originally posted on <time datetime="{{ page.date | htmlDateString }}">{{ page.date | readableDate }}</time>{% if series %} as part {{ part }} of the <a href="{{ '/series/' + series | urlize }}">{{ seriesData[series].name }}</a> series.{% endif -%}
{% for post in series.posts %}
<li>{{post.data.title}}</li>
{% endfor %}
</sub>
{%- endif -%}
FYI: For those of you who code-inspect, the language on that code above says "Twig." In point of fact I use the Nunjucks language, but the syntax highlighter I'm using (Prism.Js) doesn't have a Nunjucks language option. So, Prism's crew recommends using their Twig or Liquid language option for demonstrating Nunjucks code. And step 2 of that option is to include raw and endraw between your tags, per the Nunjucks crew.
5. Series TOC page
In this scenario the series template is inside a file, series.njk, kept in contents/pages. This is the "Table of Contents" page for a given series. Also you could call it a topic page or a theme page.
---
layout: layouts/base.njk
pagination:
data: collections.series
size: 1
alias: series
permalink: series/{{ series.slug }}/index.html
---
<section class="series">
<h1 class="center">{{ series.name }}</h1>
<sub>{{ series.info }}</sub>
<ul class="post-list">
{% for post in series.posts %}
<li class="post-list-entry"><a href="{{ post.url }}">Part {{ post.part }}: {{ post.title }}</a>
{% endfor %}
</ul>
</section>
Note, I said the layout is "base.njk." If I were doing this for real, I'd have an actual series layout template, e.g., "seriestoc.njk" that's more stylish than the bare-bones one you'll see in the sandboxes linked further below.
6. In eleventy.config.js
This file might also be called .eleventy.js. Regardless, here's what you put in it.
Inside of module.exports code block link to the location of your collection file.
/* For the Blog Series Collections */
eleventyConfig.addCollection(
"series",
require("./_eleventy/collections/series.js")
);
eleventyConfig.addFilter(
"getSeries",
require("./_eleventy/filters/getSeries.js")
);
7. In the frontmatter of a post
---
title: The Golden Apple Tree and the Nine Peahens, Part I
slug: golden-apple-part-i
description: This Serbian version of swan-wife folklore opts for peahens
date: 2018-10-01
series: "swan-wives"
part: 1
tags: second tag
---
Easy Peasy, Lemon Squeezy. Check out the aforementioned CodeSandbox. Or if you prefer, the StackBlitz
Option 2
Again, observe the directory tree:
root_folder/
|-- _site/
|-- _11ty/
| |-- collections/
| | |-- getTagList.js
| | |-- posts.js
| | |-- seriesCollections.js
| |-- filters/
| |-- getSeries.js
|-- _data/
| |-- metadata.json
|-- _includes/
| |-- layouts/
| | |-- base.njk
| | |-- home.njk
| | |-- post.njk
| |-- postslist.njk
|-- assets/
| |-- css/
| |-- img/
|-- content/
| |-- pages/
| | |-- 404.md
| | |-- archive.njk
| | |-- index.njk
| | |-- taglist.njk
| | |-- tags.njk
| |-- posts/
| | |-- external_links/
| | | |-- folktale_311_source.md
| | |-- blogpost1.html
| | |-- blogpost2.html
| | |-- blogpost3.html
| | |-- posts.json
| |-- series/
| | |-- swan-wives.html
| | |-- evil-husbands-curious-wives.html
|-- .eleventy.js
|-- package.json
|-- README.md
You're going to set up the collection object differently in this one, and it will be stored in a folder inside of content. For my own sake I called the folder "series," because it was less obfuscating than "collections," which is what A11y Project calls it (in their github file). After all, categories and tags are collections too, no? But this one is for a series. Rolling on:
The relevant directories or files in this case:
- _11ty / collections: Look here for the series object.
- _includes/layouts/post.njk: this is where we put the code to have a TOC within a relevant blog post
- content / series: This is where you will have the series "starters," which will be a file that has a frontmatter but nothing else. The names of the files will become the series slugs to connect the posts to.
- series.11tydata.js: if the directory it's in has a different title besides "series" then rename this file to match the folder title.
- content / posts / external_links: This will come into play if you want to link to an external source in your TOC
- .eleventy.js: in some versions of 11ty, this is called eleventy.config.js. Note the absence of a period before the eleventy in that scenario.
Note one thing: the data tree shows I have a posts collection. That's true to how I set up my actual site, where the posts collection is not tag based (where post is a frontmatter tag), but rather based on being in a folder titled "posts" (or "scriptorium," on my actual site). This is 11ty's "getFilteredByGlob" method.
Why does this matter? Because of an edge case involving the external links: they will appear on the landing pages for the home or archive pages if you are using the tag-based method for your posts collection. For the sake of convenience when creating my Code Sandbox I had used the out-of-the-box eleventy-base-blog. Hence my discovery of the edge case. Be warned!
Rolling on:
1. seriesCollections.js
This file, inside _eleventy/collections/ will have the following:
/**
* Blog / Article series collections
* @param {*} collection
* @returns published posts in designated series order
*/
module.exports = function (collection) {
// Collect all posts that are part of a series
const rawSeriesCollections = collection
.getFilteredByGlob("./src/content/series/*")
.sort(function (a, b) {
return a.data.title.localeCompare(b.data.title);
});
// Build up the content in the collection
const seriesCollections = {};
collection.getAll().forEach(function (item) {
if (item.data.series) {
if (!seriesCollections[item.data.series.slug]) {
seriesCollections[item.data.series.slug] = {
posts: [],
preface: item.data.series.preface,
};
}
seriesCollections[item.data.series.slug].posts.push(item);
}
});
// Sort by the order
for (const [slug, seriesCollection] of Object.entries(seriesCollections)) {
seriesCollection.posts.sort(function (a, b) {
return a.data.series.order - b.data.series.order;
});
// Attach collection object
seriesCollections[slug].collection = rawSeriesCollections.find(
(coll) => coll.template.parsed.name === slug
);
}
return seriesCollections;
}
Note Line 4, which calls for the content / series folder, which is where the files for the series "starters" are kept. If you set yours up a different way, make sure to account for where you are keeping your series starters.
2. post.njk redux
Just as with Option 1 you'll make an addition to the post.njk file, except this time, it will include the following:
{%- set collectionObject = collections.seriesCollections[series.slug] -%}
<details open>
<summary>Series: {{collectionObject.collection.data.title}}</summary>
<div class="content">
<p>{{collectionObject.collection.data.preface}}</p>
<ul>
{%- for post in collectionObject.posts -%}
{%- set currentPost = false -%}
{#- style current post differently from the other posts on the list -#}
{%- if post.data.title == title -%}
{%- set currentPost = true -%}
{%- endif -%}
{#- This is how I jury-rigged subheads onto the TOC -#}
{%- if post.data.series.subhed -%}
<li class="subhead">{{post.data.series.subhed}}</li>
{%- endif -%}
{#- But if you have an actual post it's linked here -#}
{%- if post.data.permalink -%}
<li class="seriesTOC_list">
<a class="" href="{{ post.url }}" {%- if currentPost -%}aria-current="page"{%- endif -%}>
{{ post.data.title | safe }}</a></li>
{#- This is how I jury-rigged the external links to other sites onto the TOC -#}
{%- elif post.data.series.url -%}
<li class="seriesTOC_list"><a class="" href="{{ post.data.series.url }}" target="_blank" rel="noopener">{{ post.data.title | safe }}<span class="visuallyHidden">(opens in a new tab)</span></a></li>
{%- endif -%}
{%- endfor -%}
</ul>
</div>
</details>
3. In the Series Folder
First you want to put the file, series.11tydata.js and write the following inside it:
module.exports = {
permalink: false,
};
Per my screenshot you see swan-wives.html and evil-husbands-curious-wives.html (you can use Markdown if it floats your boat). Ignore the "about" and "feed" folders; they're there because I forked the eleventy-base-blog and only made tutorial-relevant changes.
Note that per step 1, the names of these files (swan-wives, etc) will become the slug for the series. For the sheer hell of it, we're using swan-wives for the next step.
4. In the swan-wives file
The frontmatter should say:
---
title: The Swan Wives
preface: "Women who turn into swans, and the men who love them"
---
5. In the frontmatter of a relevant post
---
title: Your Post's Title
slug: Your-Post's-Slug
description: Your post's description
tags: whatever your post's tags
category: whatever your story's category
series:
slug: "swan-wives"
order: 11
---
Bonus options
The top left screenshot shows the table of contents for the Swan Wives Series. Note the subhead, "A Serbian Variant." The bottom left screenshot is for the Evil Husbands, Curious Wives Series. Note the external link, which is a red arrow: ↬. Thus, our bonus options are subheads and external links. Interested? Read on.
In step 2 I have those instances marked in the commented out lines (the comments are in between the # symbols, because that's how you comment-out in Nunjuks, the template I was using). But there's another step obviously, so I shall explain.
External Links
As I said before, the external links folder will be inside your blog posts directory (which in this scenario is titled "posts"). Any given file in this folder will have the following in their front matter:
---
title: Your title
series:
slug: "your-series-slug"
order: 3
url: https://externalwebsite.com
permalink: false
---
I have a false permalink so it doesn't show up on the blog / archive landing page. At least, as I said above, it won't if you build your posts collection via the getFilteredByGlob method and not the tag method. The post will be empty; it will only have the frontmatter you see above. Hence why I didn't want it appearing on any landing pages.
If you don't want to use an external links folder (or equivalent), and prefer to just use the posts folder only (or equivalent), you may do so, and use the same code above. Just note that if you want the external links to appear on your blog's landing page, you should make it clear that the link is going to an external site. Now for the subheads…
Subheads
If you want the subhead to go above a particular link in your TOC, this is what the front matter should look like in the relevant post:
---
series:
slug: "your-slug"
order: XX
subhed: Subheading for TOC
---
I went with s-u-b-h-e-d, the spelling used in journalism, simply because that keyword is unlikely to be spoken for elsewhere. So there are no potential conflicts.
Bing bang boom, done.
And best of all, I don't have to clog up my blog folder with empty posts.
Note: I originally used empty posts inside the posts folder as the basis for my subheads (similar to external links). It was handy for ordering the posts in the series: if subhead 1 (A Serbian Variant) was 1, then the next posts were 2 - 9. If I have another subhead after that, the post where I place the subhead is #10, and the next posts could be numbered 11 to 19. The third subhead would be #20 — you get the idea.
The idea is that if I have an extra post or two to add beneath a given subhead then I didn't have to worry about going into the files for the subsequent posts and shifting their numbers. For instance, if I wanted to add posts to the Swan Wives series, but this time focusing on the Japanese version of the story, the subhead might be "A Japanese variant." The first post under that subhead would be #10. If went back and added a fifth post under the "Serbian Variant" heading, I can simply number that post as #5 without having to change the order number for the next heading, "A Japanese Variant."
But that's a minor quibble. If you did want to use an external link as a subhead for your TOC, here's how I originally this (in post.njk):
{#- This is how I jury-rigged subheads onto the TOC #}
{% if post.data.permalink == false and not post.data.series.url %}
<li class="subhead">{{post.data.title | safe}}</li>
{% endif %}
I didn't like that idea because it makes things more complicated than it needs to be, I think, and because I didn't want my blog folder cluttered with empty posts. I can still use the numbering system; after all. But if you have a better use for empty-post method I used, feel free to adapt it for yourself.
A series list landing page for Option 2
Finally, A11yProject has a landing page to list their series (multiple, not singular), at their collections page. Currently there's just one series listed there, but I gather the intention is that if there are more series created, they will land there.
If this interests you, then take a look at their github pages: collection layout and collections page.
Here's the relevant part you'll need:
---
layout: layouts/base.njk
templateClass: template-collection
---
{%- set collectionObject = collections.postCollections[page.fileSlug] -%}
{# skipping the styling et cetera, et cetera #}
<ol class="your-own-style">
{%- for post in collectionObject.posts -%}
<li>
{% include "card/post.njk" %}
</li>
{%- endfor -%}
</ol>
And then the actual collections page:
<div data-content class="l-content">
{% set postCollections = collections[ "postCollections" ] %}
{% for handle, postCollection in postCollections %}
<div class="c-card__wrapper">
<h2 id="{{ postCollection.collection.data.title | slugify }}">{{ postCollection.collection.data.title }}</h2>
<div class="c-preface">{{ postCollection.collection.templateContent | safe }}</div>
<ol class="c-collection__list">
{%- for post in postCollection.posts -%}
<li>
{% include "card/post.njk" %}
</li>
{%- endfor -%}
</ol>
</div>
{% endfor %}
</div>
Now, note that A11yProject currently has only the one series, so the layout of just listing the articles in a series works. But if you have multiple series, you may want to take a look at George Griffiths' GitHub. Because he has multiple series he just lists the first entry of each one, on his All Series landing page. Neat idea, no?
I hope all of this helps. Have fun!