Relational data in Eleventy

Many-to-many relationships are a fixture of structured content.

A basic example is a collection of Posts, each written by one or more Authors. So how do you shape the content so that it's easy to generate pages for Posts complete with nice bylines, and for individual Authors including a list of their posts?

The setup

The code snippets throughout this article use Eleventy 1.0.2, the latest version at the time of writing. They assume the input directory is a folder named content/, and use the Nunjucks templating language throughout. The corresponding .eleventy.js configuration is below:

/* File: .eleventy.js */

module.exports = config => {
return {
markdownTemplateEngine: 'njk',
htmlTemplateEngine: 'njk',
dir: {
input: 'content'
}
};
};

The content/ folder holds all Posts and Authors in individual .md files. We'd like the frontmatter of one such file to reference information stored in a different file, and have that information readily available when rendering the page.

Posts reference each Author by their file path relative to the input directory. This path serves as an unique key for a piece of content. Here's how content/posts/hello.md might look:

---
title: Hello
authors:
- authors/dan.md
- authors/catalin.md
layout: layouts/post.html

---


This is our first collective post!

Author files then hold the actual information in the frontmatter — things such as the full name, or the path to an avatar image — as well as the author bio in the Markdown content section. Here's content/authors/dan.md:

---
title: Dan Burzo
avatar: img/authors/dan-burzo.jpg
layout: layouts/author.html

---


This is Dan's short bio.

We want to accomplish two things:

Augment the frontmatter with Computed Data

Eleventy comes with a cool feature called Computed Data that lets us add template data, at any level of the data cascade. More importantly, it lets us overwrite existing frontmatter data with richer values. We only need to define eleventyComputed once for each type of content, using directory data files.

For Posts, the content of the content/posts/posts.11tydata.js file is shown below. By defining the tags field, we add all entries in the folder to the posts collection. We do the same in content/authors/authors.11tydata.js to gather all Authors in the corresponding collection.

In Eleventy Collections, the unique identifier for a piece of content is its inputPath, which is the items's file path relative to the project's root, starting with the path for the input directory. In our case, all inputPaths start with ./content/.

With eleventyComputed, we replace the Post's authors field with the corresponding items from the authors collection, matching their inputPath. Remember that we referenced post authors by paths that were relative to the input directory. We have to concatenate that back into our paths before we can match against inputPath values.

/* content/posts/posts.11tydata.js */

module.exports = {
layout: 'layouts/post.html',
tags: 'posts',
eleventyComputed: {
authors: data => {
const postAuthors = data.authors || [];
const collection = data.collections.authors;
return postAuthors.map(authorPath =>
collection.find(item =>
item.inputPath === `./content/${authorPath}`
)
);
}
}
};

The inverse, showing all Posts by a specific Author, is similarly done with eleventyComputed. This time, we look in the posts collection for items whose authors data field includes the current inputPath.

/* content/authors/authors.11tydata.js */

module.exports = {
layout: 'layouts/author.html',
tags: 'authors',
eleventyComputed: {
author_posts: data => {
const inputPath = data.page.inputPath;
return data.collections.posts.filter(item => {
const postAuthors = item.data.authors || [];
return postAuthors.some(
authorPath => `./content/${authorPath}` === inputPath
);
});
}
}
};

This setup makes it straightforward to use the enriched template data. In the layout for Posts...

<!-- content/_includes/layouts/post.html -->

<hgroup>
<h1>{{ title }}</h1>
<p> By
{% for author in authors %}
<a href='{{ author.url }}'>{{ author.data.title }}</a>
{%- if not loop.last -%}, {%- endif -%}
{% endfor %}
</p>
</hgroup>

{{ content | safe }}

...and for Authors:

<!-- content/_includes/layouts/author.html -->

<h1>{{ title }}</h1>

{{ content | safe }}

<h2>{{ title }}'s posts</figure>
<ul>
{% for post in author_posts %}
<li><a href='{{ post.url }}'>{{ post.data.title }}</a></li>
{% endfor %}
</ul>

The two basic patterns — find by ID and filter by attribute — can be replicated for each relationship between data types you care to represent.

But before we pepper that pesky "./content/" string around a dozen directory data files, let's factor it out, while improving performance in the process.

To make collection items easier to find by ID, let's put all the content in one big dictionary, indexed by the keys used across the .md files to reference each other: the file path relative to the input directory. In other words, the inputPath with the input directory trimmed. We do that by defining a custom collection called _indexed in the project's .eleventy.js config:

/* .eleventy.js */

const path = require('path');

module.exports = config => {

const INPUT_DIR = 'content';

/*
Index all content relative to the input directory
in an Eleventy collection called `collections._indexed`
*/

config.addCollection('_indexed', data => {
const index = {};
data.getAll().forEach(item => {
const key = path.relative(INPUT_DIR, item.inputPath);
index[key] = item;
});
return index;
});

return {
markdownTemplateEngine: 'njk',
htmlTemplateEngine: 'njk',
dir: {
input: INPUT_DIR
}
};
};

This custom collection simplifies our computed data. To fetch Post authors, we can look them up in the index directly, which is much faster than iterating through the collection, and involves none of that string concatenation.

/* content/posts/posts.11tydata.js */

module.exports = {
layout: 'layouts/post.html',
tags: 'posts',
eleventyComputed: {
authors: data => {
const postAuthors = data.authors || [];
const index = data.collections._indexed;
return postAuthors.map(authorPath => index[authorPath]);
}
}
};

In the case of Author posts, we replace string concatenation with reading the paths from the index, making the code if not faster then at least more resilient.

/* content/authors/authors.11tydata.js */

module.exports = {
layout: 'layouts/author.html',
tags: 'authors',
eleventyComputed: {
author_posts: data => {
const inputPath = data.page.inputPath;
const index = data.collections._indexed;
return data.collections.posts.filter(item => {
const postAuthors = item.data.authors || [];
return postAuthors
.map(authorPath => index[authorPath].inputPath)
.includes(inputPath)
});
}
}
};

These could be further refactored into reusable functions, to make declaring many relationships more palatable, but this is the basic idea.