Alex Pearwin

Moving from Jekyll to Eleventy

Although not too uncharacteristic of me, my subconscious seemed to be on a particularly focused mission to only do things of zero value during the lockdown.

As part of this, I bumped in to a couple of automatically generated dependency-update merge requests on my blog’s repository. After going checking out the changes on my laptop, I realised I didn’t remember how to build my blog any more. Not ideal, but since switching to Netlify I can relax and let someone else take care of that for me.

But then the that build failed.

So I had to fix it locally. And it turns out the Ruby bundled with macOS is too old for the latest version of Jekyll and its dependency. Installing different Ruby versions is something I never particularly enjoyed (RVM or rbenv? Or is there something else now?) so I did the only thing any sane person would do and migrated the entire thing to a completely different static site generator: Eleventy.

And this page is now generated by Eleventy, along with the rest of the site. This post isn’t so much a tutorial on Eleventy, as the documentation covers that nicely, more an overview of what I did to move from Jekyll to Eleventy.

Was it worth it? Maybe. The switch took more time than I’d remain unembarrassed to admit, but I enjoyed learning something a little different than what I’m used to, and I like the small amount of Eleventy’s workings I’ve seen.

If you’re interested in using a static state generator, Eleventy feels good enough. If you’re interested in migrating from Jekyll to Eleventy, read on.

Up and running

To get an existing Jekyll site even building with Eleventy, let alone functional, there are a few basics we need to get out of the way:

  • Eleventy is written in JavaScript and requires a JavaScript runtime. This is typically Node.js, along with the associated package manager npm.
  • We’ll need a package.json file to list the packages required for building the site.
  • Site-wide configuration is not stored in _config.yml, but in JSON files under the special _data directory.

Let’s get these out of the way.

First, install Node.js and create a skeletal package.json.

# Install node/npm (on macOS using Homebrew)
$ brew install npm
# Inside the blog repository root, create the package.json
$ npm init -y
# Install Eleventy, declared as a development dependency
$ npm install --save-dev @11ty/eleventy

Converting my Jekyll _config.yml to JSON looked like this:

{
    "title": "Alex Pearce",
    "email": "alex@alexpearce.me",
    "description": "Blog of physicist and developer Alex Pearce.",
    "baseurl": "",
    "url": "https://alexpearce.me"
}

Which is saved as _data/site.json. Note that build configuration, like what Markdown settings to use, doesn’t appear here. We’ll get back to that.

The values in the _data/site.json configuration file can be accessed using site.title and the like inside Liquid tags. Eleventy uses Liquid to render content, like Jekyll does, so most of your templates should work.

To build the site, run:

$ npx @11ty/eleventy

You might be greeted with this:

Problem writing Eleventy templates: (more in DEBUG output)
> You’re trying to use a layout that does not exist: default (undefined)

As I was. If we read about layouts in Eleventy, we’ll see that these are searched for in _includes by default, whereas they’re kept under _layouts in Jekyll. This is mentioned and can be configured, so we’ll create an .eleventy.js file to set the right path.

module.exports = {
    dir: {
        layouts: "_layouts"
    }
};

Building again with npx @11ty/eleventy, you might now get:

Problem writing Eleventy templates: (more in DEBUG output)
> Having trouble rendering liquid (and markdown) template ./_posts/physics/2012-04-28-scattering-cross-sections.md

`TemplateContentRenderError` was thrown
> Having trouble compiling template ./_posts/physics/2012-04-28-scattering-cross-sections.md

`TemplateContentCompileError` was thrown
> tag highlight not found, file:./_posts/physics/2012-04-28-scattering-cross-sections.md, line:13

This is progress, even if it might not feel like it.

Expanding the configuration

As mentioned, build configuration is seperate from site-wide constants, which are kept under _data.

Build configuration is kept in the .eleventy.js file we created earlier. We just defined a constant map there, but we can define a function instead.

module.exports = function(eleventyConfig) {
  return {
    dir: {
      layouts: "_layouts"
    }
  }
};

The eleventyConfig argument is passed in by the Eleventy code that uses this file, and we can use this object to do things like add build plugins and define new Liquid tags.

To fix the previous error, we need to include the syntax highlighting plugin, which makes the highlight Liquid tag available.

Install the plugin first, declaring it as a development dependency (which adds it to your package.json):

npm install --save-dev @11ty/eleventy-plugin-syntaxhighlight

And then use the plugin in the build configuration, which in full now looks like this:

const syntaxHighlight = require("@11ty/eleventy-plugin-syntaxhighlight");

module.exports = function (eleventyConfig) {
  // Enable syntax highlighting
  eleventyConfig.addPlugin(syntaxHighlight);

  return {
    dir: {
      layouts: "_layouts"
    }
  }
};

Rebuild and you’ll probably see a different error now. More progress!

My next error was about tag post_url not found, which refers to the Liquid tags used in Jekyll to cross-reference posts. We’ll come back to this.

While we’re in the build configuration file, we’ll configure Eleventy’s Markdown engine, markdown-it, to support header anchors, header footnotes, and curly quotes, first installing the plugins:

$ npm install --save-dev markdown-it-anchor markdown-it-footnote

And then configuring them along with the engine itself:

const markdownIt = require("markdown-it");
const markdownItAnchor = require("markdown-it-anchor");
const markdownItFootnote = require("markdown-it-footnote");
const syntaxHighlight = require("@11ty/eleventy-plugin-syntaxhighlight");

module.exports = function (eleventyConfig) {
  // Add header anchor and footnotes plugin to Markdown renderer
  const markdownLib = markdownIt({html: true, typographer: true});
  markdownLib.use(markdownItFootnote).use(markdownItAnchor);
  eleventyConfig.setLibrary("md", markdownLib);

  // Enable syntax highlighting
  eleventyConfig.addPlugin(syntaxHighlight);

  return {
    dir: {
      layouts: "_layouts"
    }
  }
};

Note that I set the html configuration key of markdown-it to true, as I have a couple of posts which have HTML tags in them. You can omit this if you don’t need that.

Finally, the last bit of standard configuration we’ll add is to declare that we want the contents of the assets directory to be copied verbatim to the output folder (which is _site by default, the same as Jekyll’s).

// Copy anything in the assets/ folder verbatim
eleventyConfig.addPassthroughCopy("assets");

This goes in the .eleventy.js file. I’ll omit the surrounding code from now on.

Cross-referencing posts

Jekyll is touted as ‘blog-aware’. In practice this means it treats Markdown source under _posts in a slightly special way, interpreting YYYY-MM-DD filename prefixes as publication dates and allowing you to cross-reference them with the post_url Liquid tag.

Eleventy does the auto-dates thing, but is more agnostic to blog-specific stuff. So I had two problems:

  1. There was no post_url Liquid tag.
  2. My post URLs were being generated as a direct mirror of the directory structure in the repository, rather than nice /YYYY/MM/blog-post-title/ I had configured with Jekyll.

As Eleventy says, “cool URIs don’t change”, so we should try to preserve them during the migration. Luckily, this second problem is the simplest to fix.

We can use directory data files to set a common URL schema for all files which are under that folder. This is super cool! We then create _posts/_posts.json:

{
    "layout": "post",
    "permalink": "{{ page.date | date: '%Y/%m' }}/{{ page.fileSlug }}/"
}

And the URL structure of /YYYY/MM/blog-post-title/ is preserved.1 Note that any property specified here becomes common to files under _posts, so I took the opportunity to factor out the common layout: post configuration I had in all post files to this data file.

For the first problem, of no post_tag, Ru Singh has a great post with a nice solution. I modified it only slightly, to use a catch-all collection called posts, with this result in the function inside .eleventy.js:

// Define a posts collection for all blog posts
eleventyConfig.addCollection("posts", function(collectionApi) {
  return collectionApi.getFilteredByGlob("_posts/**/*.md");
});

// Define a post_url Liquid tag for cross referencing
// https://rusingh.com/articles/2020/04/24/implement-jekyll-post-url-tag-11ty-shortcode/
eleventyConfig.addShortcode("post_url", (collection, slug) => {
  try {
    if (collection.length < 1) {
      throw "Collection appears to be empty";
    }
    if (!Array.isArray(collection)) {
      throw "Collection is an invalid type - it must be an array!";
    }
    if (typeof slug !== "string") {
      throw "Slug is an invalid type - it must be a string!";
    }

    const found = collection.find(p => p.fileSlug == slug);
    if (found === 0 || found === undefined) {
      throw `${slug} not found in specified collection.`;
    } else {
      return found.url;
    }
  } catch (e) {
    console.error(
      `An error occured while searching for the url to ${slug}. Details:`,
      e
    );
  }
});

Cross-referencing a post now looks like this:

Check out [this post]({% post_url collections.posts, 'blog-post-title' %}) for more.

I like that you can omit the post’s date here, needing only the ‘slug’ of the file, but I don’t like that you always have to pass in collections.posts. I couldn’t find a way to retrieve this list from inside the tag definition function; I’d love to hear of solutions!

Because there were a bunch of post_url tags to update, I used sed to do everything in one go:

$ sed -i '' -E 's#post_url /.*/[0-9]{4}-[0-9]{2}-[0-9]{2}-(.*) %}#post_url collections.posts, \'\1\' %}#' $(find _posts -name '*.md')`

While I was at it, I also changed all highlight Liquid tags to plain Markdown code fences, which I prefer:

sed -i '' -E 's#{% highlight (.*) %}#```\1#' $(find _posts -name '*.md')
sed -i '' -E 's#{% endhighlight %}#```#' $(find _posts -name '*.md')`

First page rendered

At this point I had a build that finished, and hopefully you do too.

To continue development, it’s really nice to use the built-in webserver.2 This watches all source files and triggers a rebuild when they change, refreshing your browser for you at the same time (neat!):

$ npx @11ty/eleventy --serve
...
Copied 34 files / Wrote 59 files in 0.70 seconds (11.9ms each, v0.11.0)
Watching…
[Browsersync] Access URLs:
 -------------------------------------
       Local: http://localhost:8080
    External: http://192.168.0.2:8080
 -------------------------------------
          UI: http://localhost:3001
 UI External: http://localhost:3001
 -------------------------------------
[Browsersync] Serving files from: _site

If things look missing or broken, check that the path to your assets given in the rendered HTML matches what you’d expect.

You’ll probably also need to adjust references to things like page.title to just title. Properties declared in the frontmatter of the page can be referenced without the page prefix.

Post listings and RSS feeds

The final major missing piece was migrating the listings pages. I had a few of these:

  1. The main /blog/ listing, which is paginated in chunks of 10 posts.
  2. The tag-specific ‘search’ listings, implemented using a somewhat convoluted JavaScript-based approach.
  3. An RSS/Atom feed under /atom.xml.

In essence, migrating these meant looping over an Eleventy collection rather than the posts object as in Jekyll.

For the main listing we can use Eleventy’s pagination system, looping over the collections.posts collection defined earlier. For the tag listings I switched to statically generated per-tag listings, which is a lot cleaner than what I had before.

One thing that took me a little while to understand was getting posts in date-descending order, from newest to oldest. For the main listing the entire posts collection can be reversed by specifying reverse: true under the pagination: key, but for the tags listing I used the reversed Liquid filter instead.

I couldn’t figure out how to use the latter technique with pagination at the same time, as it resulted in paginated date-ascending posts, i.e. the first page showed the oldest 10 posts.

For the Atom feed, I installed an XML plugin which provides Jekyll-like filters useful for creating XML files, like date_to_rfc822. Getting Eleventy to process the feed template file meant renaming it to atom.html and then defining the permalink to be atom.xml. This is convoluted and I suspect I’m missing something, but it got the job done.

Loose ends

With that, most of the stuff I had with Jekyll was now looking as before, but under Eleventy 🎉

In no particular order, there are a few other things I came across that you might too:

  • Use an .eleventyignore file to ignore all but one or two typical posts. Get the site building with these, and then move on to the rest. This helps you focus on getting the core features working before tackling stuff which may be more specific.
    • You’ll likely want one of these anyway to ignore your README.md, which Eleventy will otherwise dutifully create a /README/index.html out of.
  • Eleventy doesn’t assume Sass out of the box, so I converted the little Sass-specific CSS I had to plain CSS. This gave me a chance to learn about CSS variables!
  • Jekyll has first-class support for tags and categories in posts, whereas Eleventy uses only tags to auto-generate its collections lists. I think you could define something in the build configuration to mimic categories, but I didn’t feel the need to have the separation, so just added the category of each post as a tag.
  • Language codes used by Prism, the syntax highlighter used by Eleventy, may not match those of the Jekyll highlighter. I had to change config to apacheconfig, for example.
  • Remember to go through your repository and remove any Jekyll-specific files like _config.yml. You might also want to add a .nojekyll file if your repository is configured for GitHub pages.
  • There’s plenty on the web about Eleventy, but I found Jérôme Coupé’s guide and the Eleventy base blog repository to be particularly useful.

If you’re in any doubt, the source code for this blog might be useful.

Ensuring consistent URIs

When migrating from one platform to another, it’s important to ensure the URIs of all of your pages don’t change, and to redirect to valid URIs in case they absolutely must.

I was super lucky to stumble across a couple of excellent Netlify build plugins which help do some of this work for you:

  1. no-more-404, which generates a cache of internal URIs in the first build, and then validates the existence of resources at those URIs in subsequent builds.
  2. checklink, which checks external URIs for 404s.

I enabled these plugins in the Jekyll build, particularly to generate the cache for no-more-404, and could then be confident in my URI consistency when migrating to Eleventy.

The only URI I couldn’t preserve was /search/, a hack used for tag listings. I configured a Netlify redirect to forward this to the new listings pages.

Reflection

I sometimes get the worrying feeling that most of the posts I write are about maintaining the blog itself, which is supposed to be a place to share interesting things rather than a generator of its own problems.

Still, I enjoyed the time I spent fiddling around with something new, which is a luxury. Jekyll and Eleventy are certainly both featureful enough for my needs, but Eleventy is more flexible, as it isn’t constrained by the security concerns that factor in to Jekyll’s design.

I’d be happy working with Eleventy in the future on other projects. But I’ll probably end up reworking the blog again before that ever happens 😄

Footnotes

  1. Note the trailing slash here, which makes a difference!

  2. You can’t open the .html output files in your browser, as the internal links are likely absolute, relative to your site.baseurl, so your stylesheets and stuff won’t load.