As alluded to in my recent post about Jekyll. The time has finally come to leave Jekyll for something more capable.
Although Jekyll has been a great introduction into the JAMstack or Static Site Generation for many years. I never actively chose it, but rather adopted it because of it's prolific use on GitHub Pages.
Jekyll has been a little tricky to use, with it's dependency on Ruby, and it's community support slowly being abandoned.
Maintaining a Jekyll site has started getting in the way of updating the site.
It's time to move on, and after much consideration of the optioned. I looked deeply at Hugo and 11ty.
I settled on Eleventy as it's closer in spirit to Jekyll, more modular, flexible (due to JS) and incredibly fast.
Jekyll to 11ty Migration Plan
- Assess 11ty docs and blog posts from others.
- Setup a POC.
- Build a Jekyll compatible setup, so that I don't need to change absolutely everything.
- Build a new theme using Pico CSS that's largely similar to my modded klise Jekyll theme but with way fewer dependencies.
- Deploy on Cloudflare Pages.
It all took a solid 3 or 4 days work, but I learnt a lot for my other static projects and pretty happy with the result.
11ty Lessons Learned
You'll need a basic understanding of JavaScript or better, specifically an understanding of Node JS. If not, you'll certainly be learning it along this migration.
Although Jekyll is also largely modular and portable. e.g. markdown, liquid templates etc. It actually has a bunch of quirks and modification on top of these systems that are not supported by the underlying standards or other tools. 11ty needs to have some features setup to allow compatibility with a Jekyll content dataset.
All the information you need is within the 11ty docs - but IMO it's an not well presented and poorly categorised. It's not easy to onboard, or find what you're looking for as an 11ty noob.
Hopefully some more funding can help improve the documentation for newcomers.
11ty itself, and their docs seem to be loosely inspired by Jekyll's systems but with some unclear deviations. Some systems are the same while others are quietly missing or just entirely different.
Although 11ty is a Node JS package, it doesn't have it's own Docker container published.
Thankfully it's easy to make one for those who prefer containerised development processes.
11ty Build Output Warning
Coming from Jekyll, you might assume that the output directory, usually _site
is emptied before a new build is placed in there. It is not, and if you have multiple builds on top of each other (or even an old local Jekyll build) you're in for a world of confusion. See my solution below.
Although the 11ty docs provide both Common JS and ESM examples. You'll likely need to use the newer ESM code to have a wider compatibility with CI/CD builders and build environments like Cloudflare or GitHub/Gitea Actions.
Also, watch out when copying example 11ty configs from other sites. This is JS after all, and can run pretty much anything, but also, some examples may use CommonJS rather than ESM, and some may use their own config variable name that's incompatible with the examples provided on the 11ty docs.
e.g. eleventyConfig.foobar
vs config.foobar
or require(
vs import foo from 'bar'
etc.
The 11ty Basics
Setting up is pretty straight-forward, you can remove all the old Ruby stuff like Gemfiles etc.
11ty is a standard node package, which means to use plugins (or 11ty itself) you should to use a node package manager to install it.
If you have node installed and managed on your system directly, this likely means running something like npm install @11ty/eleventy
.
This will create a standard "package.json" etc.
If you'd rather a container setup. Add the following Dockerfile and compose file into your project's root directory.
Dockerfile:
FROM node:alpine
RUN npm install -g @11ty/eleventy
WORKDIR /src
CMD ["eleventy"]
compose.yaml:
services:
eleventy:
build:
context: .
volumes:
- .:/src
command: npm start
container_name: "eleventy"
ports:
- 127.0.0.1:8080:8080
Tip
Make sure you setup an npm start
command in your "package.json", then you can just run docker-compose up
to get going with local development. --serve
sets up the site on localhost:8080 with live builds and reloads as you make changes.
package.json:
{
"version": "1.0.0",
"main": ".eleventy.js",
"type": "module",
"scripts": {
"start": "npm install & npx @11ty/eleventy --serve"
}
}
You'll also need an .eleventy.js
config file. It will mostly replace your old Jekyll config.
When you want to use npm
inside the container, for example to install something. You can run docker exec -it eleventy sh
. This will drop you into the alpine Linux container's shell (which has node installed) and then you can do all the npm install PLUGIN_NAME
commands you need.
To remove a plugin it's; npm uninstall PLUGIN_NAME
.
Example 11ty Config
.eleventy.js:
// Plugin Imports
import syntaxHighlight from "@11ty/eleventy-plugin-syntaxhighlight";
import { IdAttributePlugin } from "@11ty/eleventy";
// 11ty Conig Export
export default function (eleventyConfig) {
// Enable Plugins
eleventyConfig.addPlugin(syntaxHighlight);
eleventyConfig.addPlugin(IdAttributePlugin);
// Config Here
}
Here we're using ESM to import the plugin called "eleventy-plugin-syntaxhighlight". For this to work we will have already need to install the plugin using npm install eleventy-plugin-syntaxhighlight
as above.
We're also importing a plugin from within eleventy itself as "IdAttributePlugin".
Tip
In this article, whenever you see 11ty config code, you can assume that goes inside the default export brackets unless otherwise stated. i.e. where it says "// config here" above.
Configuring 11ty like Jekyll
Public Assets
11ty will not copy any files into the build folder by default that are not processed; e.g. .md
or template files.
You'll need to tell it to copy over your public assets like CSS, JS etc.
// Copy the `assets` directory to the compiled site folder
eleventyConfig.addPassthroughCopy('./assets');
eleventyConfig.addPassthroughCopy('./favicon*');
eleventyConfig.addPassthroughCopy('./_redirects'); // cloudflare redirects
If your assets or public files are not stored in ./assets
, change as needed.
To ignore specific files from the build:
eleventyConfig.ignores.add("README.md"); // or use `.eleventyignore` file
11ty vs Jekyll Layouts
How your old theme was setup in Jekyll will determine how you migrate it to 111ty. If you have all the CSS and templates locally then you're good to go. If you're using a Gem-type theme, you'll need to grab all the assets.
11ty CSS
Lot's of Jekyll themes use SASS (SCSS) to build CSS files. 11ty does not support this out of the box but there are several 11ty plugins to add support for this.
Jekyll has _layouts
and _includes
. This has never really made much sense to me as a "layout" is really just a "big include" so moving to 11ty's setup of having only _includes
works well for me.
Any markdown files with frontmatter of the following, will look inside the includes folder for the layout. e.g. "./_includes/page.html"
---
layout: page
---
Collections & Posts
While Jekyll treats posts in _posts
as a first-class type. 11ty does not, any group of files is called a collection. To setup a _posts
directory we add the following config.
eleventyConfig.addCollection('posts', collection =>
collection.getFilteredByGlob('./_posts/*.md').sort((a, b) => b.date - a.date)
)
11ty also uses the tags:
frontmatter of processed files to setup collections. So if you have files tagged with "plants" in their frontmatter, you'll also have a collections.plants
as well as collections.post
and the global collections.all
.
Note
You will need to find any post/collection loops in your template files and update them. i.e. the main posts listing page or a homepage, and update it from site.posts
to collections.posts
.
Setting Post Attributes Globally with Permalinks
URLs are Important
When migrating a site, always make sure all your old URLs are the same, or at least redirected as needed. You don't want to be breaking other websites links to your site. Every-time you change something.
To set the permalink config (or any other frontmatter) in bulk or across a whole collection like "collections.posts" you need to add a JSON file into the collections folder with the same name as the folder.
For example, to add a default layout and permalink frontmatter to all posts in the "collections.posts":
Create the file ./_posts/_posts.json
with the content:
{
"permalink": "{{ page.fileSlug }}/",
"layout": "post.html"
}
11ty Drafts
I've never much liked having my post drafts in a separate folder under _drafts
and so I'm quite happy to keep them in _posts
with a specific frontmatter variable to separate them from the normal posts.
// Drafts
// https://www.11ty.dev/docs/config-preprocessors/#example-drafts
// do not use `_drafts`, only `_posts` with `draft: true` or `published: false`
eleventyConfig.addPreprocessor("drafts", "*", (data, content) => {
if (data.draft || data.published === false) {
if (process.env.ELEVENTY_RUN_MODE === "build") return false;
else {
if (!data.title) data.title = data.page.fileSlug; // untitled drafts
data.title = '[Draft] ' + data.title; // show in dev
}
}
});
Now in your post frontmatter, you can use either:
draft: true
published: false
This config also adds a nice [Draft]
marker to the post title. This is super helpful for local development.
Site Properties
You may be familiar with the site.
property in Jekyll.
This is not how 11ty works by default, but we can replicate some of that functionality.
Add a file called _data/site.json
in the data directory. Here you can build a JSON object with all your site variables. They'll be exposed to Liquid the same as in Jekyll e.g. site.name
.
{
"url": "https://benhoskins.dev",
"lang": "en_GB",
"mode": "light",
"title": "Ben Hoskins",
}
Note 11ty Site Vars in Config
The catch is that you cannot use these site.foo
type variables in your .eleventy.js
config file, as the data directory is processed after the config for 11ty. In the older Common JS format, we could just require the JSON file and use it in the config but it's not as easy under ESM.
Data & Properties
The "Data Cascade" was a bit of a nightmare to understand when coming from Jekyll.
Essentially the key-path of page data changes when your on the page vs in a forloop vs in a template.
e.g. on a page template you can query {{ title }}
rather than {{ page.title}}
in Jekyll, but in a loop you need to use {{ page.data.title }}
, but not for some properties like URL which is still {{ page.url }}
even in a loop. And, then, sometime, there's also {{ page.items.title }}
maybe?
:insert rage confusion meme here:
It's all a tad messy, but I'm sure there's a good reason for it, and here's the docs. Good luck.
Clearing Build Folder
As I mentioned above in the "Lessons Learned" section. 11ty assumes the site will be build in an ephemeral environment where _site
does not already exist. e.g. a CI/CD pipeline.
Obviously that's not the case for everyone, or for local development using the npx @11ty/eleventy --serve
command.
If you're not aware of this, you'll face hours of extremely frustrating debugging and phantom files while doing some local development and testing... (Achievement Get)
The solution is to have 11ty empty the _site
directory before each build. There's a complicated way which only deletes the files that 11ty knows it created itself previously here, but I much prefer a simpler full delete everything under _site
approach that was discussed in 11ty GitHub discussion #2294.
Install "del" with npm install del
then import the plugin before your config export, then within the config section add the fullclean
function i.e:
import { deleteSync as fullclean } from 'del';
export default function (eleventyConfig) {
fullclean('_site/*');
}
Jekyll Hangovers
Jekyll has some odd standards with frontmatter, and so 11ty through some errors when building some of my posts. Notably I needed a specific date format of date: YYYY-MM-DD
or date: YYYY-MM-DD HH:mm:ss
my existing format did not have seconds included. I also needed to remove some empty or duplicate frontmatter vars too.
Keep an eye on your markdown links too. Some of mine contained spaces which worked on Jekyll, but output raw markdown on 11ty.
Some features that people often use in Jekyll flavoured templates or setups are just not supported by the underlying systems or 11ty itself.
I found that I needed to replicate the following shortcodes for the Liquid templating language and 11ty, setup Liquid to support the looser Jekyll style include syntax and add some excerpt support.
Important absolute_url
If you also need to use absolute_url
shortcode, make sure to input your own sites URL here instead of mine!
// Jekyll Hangovers
// https://24ways.org/2018/turn-jekyll-up-to-eleventy/
eleventyConfig.setFrontMatterParsingOptions({
excerpt: true,
excerpt_separator: ''
});
// Reproduce some Jekyll Liquid filters, sometimes loosely
eleventyConfig.addFilter('group_by', groupBy)
eleventyConfig.addFilter('sort_by', sortBy)
eleventyConfig.addFilter('where', where)
eleventyConfig.addFilter('absolute_url', absolute_url)
function absolute_url(value) {
return "https://benhoskins.dev" + value;
}
function where(array, key, value) {
return array.filter(item => {
const data = item && item.data ? item.data : item
return typeof value === 'undefined' ? key in data : data[key] === value
})
}
function sortBy(array, key) {
return array
.slice(0)
.sort((a, b) =>
a[key].toLowerCase() < b[key].toLowerCase()
? -1
: a[key].toLowerCase() > b[key].toLowerCase()
? 1
: 0
)
}
function groupBy(array, key) {
const get = entry => key.split('.').reduce((acc, key) => acc[key], entry)
const map = array.reduce((acc, entry) => {
const value = get(entry)
if (typeof acc[value] === 'undefined') {
acc[value] = []
}
acc[value].push(entry)
return acc
}, {})
return Object.keys(map).reduce(
(acc, key) => [...acc, { name: key, items: map[key] }],
[]
)
}
// Liquid Include Syntax
// https://24ways.org/2018/turn-jekyll-up-to-eleventy/
eleventyConfig.setLiquidOptions({
dynamicPartials: false,
root: [
'_includes',
'.'
]
});
Excerpt Note
This particular "excerpt" config is setup before the markdown is processed, and so you may get raw markdown in the excerpt. Be aware of this!
11ty Custom Extras
On top of these configs, I've found the following useful also:
// Custom Filters
eleventyConfig.addFilter("related", related);
function related(collection = [], requiredTags, url) {
return collection.filter(post => {
// Filter the specified collection, confirm it isn't the current page, and has all the required tags.
return post.url !== url && requiredTags?.every(tag => post.data.tags?.includes(tag));
});
}
eleventyConfig.addFilter('output', (data) => {
return console.log(data);
})
// Discover Environment Liquid Shortcode
// https://kittygiraudel.com/2020/11/30/from-jekyll-to-11ty/
// {% production %}on prod!{% endproduction %}
eleventyConfig.addPairedShortcode('production', content =>
process.env.ELEVENTY_RUN_MODE === "build" ? content : undefined
)
The "related" filter is a bit naff, but it'll do the job for now. It will filter the collection for any files that have the same tags. It can be used in a template like so:
{%- assign relatedPosts = collections.posts | related: tags, page.url -%}
{%- if relatedPosts.length > 0 %}
<section class="container">
<h2>Related posts</h2>
<ul class="post-banners">
{%- for post in relatedPosts limit: 3 %}
<li>
<a href="{{ post.url }}">{{ post.data.title }}</a>
</li>
{%- endfor %}
</ul>
</section>
{%- endif -%}
The "output" filter can be used for dumb debugging. Pump any var into output and it'll show somewhere in the build logs. e.g. {{ site.name | output }}
.
The "production" pared shortcode is handing for excluding analytics or other parts of your templates from loading while your developing. Production in this context means when the static site is "built" as opposed to "serve" for local development.
{% production %}
<script defer src="https://cloud.umami.is/script.js" data-website-id="example"></script>
{% endproduction %}
Recommended 11ty Plugins
I'm also using the following plugins:
import syntaxHighlight from "@11ty/eleventy-plugin-syntaxhighlight";
import markdownItAlerts from "markdown-it-github-alerts";
import feedPlugin from "@11ty/eleventy-plugin-rss";
import { eleventyImageTransformPlugin } from "@11ty/eleventy-img";
import { IdAttributePlugin } from "@11ty/eleventy";
eleventyConfig.addPlugin(eleventyImageTransformPlugin);
eleventyConfig.addPlugin(feedPlugin);
eleventyConfig.addPlugin(syntaxHighlight);
eleventyConfig.addPlugin(IdAttributePlugin);
// Markdown Plugin
// https://www.11ty.dev/docs/languages/markdown/#add-your-own-plugins
// https://www.npmjs.com/package/markdown-it-github-alerts
eleventyConfig.amendLibrary("md", (mdLib) => mdLib.use(markdownItAlerts));
RSS Feeds
The old site had a slightly more custom feed setup that most simple blogs so I decided to use the manual approach shown on the feed docs.
I noticed that the default virtual config for feeds seemed to be broken for how my collections are setup. I'm not sure why, you may also need to reverse the collection in a manual feed template file if you use my collection config as above. See issue #63
There was a strange bug, with the unhelpful error [11ty] 2. url.startsWith is not a function (via TypeError)
for one of my feeds.
It was caused by a collection that does not write pages for each item in the collection, but does still have a feed. To accomplish this I used permalink: false
in the collections frontmatter, but this broke the feed template. You can see my solution to this here in issue #58.
Replacing the {%- set absolutePostUrl %}{{ post.url | htmlBaseUrl(site.url) }}{% endset %}
in the feed template with:
{%- if post.data.permalink === false -%}
{%- set absolutePostUrl %}#{{ post.data.title | slugify }}{% endset %}
{%- else -%}
{%- set absolutePostUrl %}{{ post.url | htmlBaseUrl(site.url) }}{% endset %}
{%- endif -%}
Comments & Questions
Reply by email to send in your thoughts.
Comments may be featured here unless you say otherwise. You can encrypt emails with PGP too, learn more about my email replies here.
PGP: 9ba2c5570aec2933970053e7967775cb1020ef23