Schemescape

Development log of a life-long coder

Test-driving Eleventy for a simple dev blog, part 2

I'm testing out the most promising static site generators for my blog. In part 1, I recorded my initial impression of Eleventy; in this post, I describe my experience fully integrating Eleventy into my dev blog (final code here).

Background

I already covered my ideal dev blog setup in detail, but here's the gist of it:

And the corresponding directory structure:

Configuration

My initial .eleventy.js configuration file looked something like this:

module.exports = function(eleventyConfig) {
    // Copy everything under "static" to the root of the built site (note: this is relative to this config file)
    eleventyConfig.addPassthroughCopy({ "static": "./" });

    return {
        // Don't process Markdown first with a template language
        markdownTemplateEngine: false,

        dir: {
            input: "content",
            output: "out",
            includes: "../templates" // Note: this is relative to the input directory
        }
    }
};

Note: the markdownTemplateEngine: false setting is to prevent Eleventy from using Liquid to process my Markdown (I don't want this step because it tries to process all instances of {%, etc., when I actually want to be able to use those tokens in my blog post content).

JavaScript templates: easy to write, hard to maintain

After spending the last few posts railing against "unintuivie", "verbose", and "ugly" template languages like Liquid and EJS, Eleventy's JavaScript templates felt like a shining beacon of familiarity and simplicity -- it's just JavaScript! Sure, JavaScript used to be a terrible language (what do you expect for a language that was originally created in 10 days?), but modern JavaScript is pleasant to write, especially with template literals.

I was able to easily dive in and create a bare bones page template (I put this in a shared library, templates\shared.js):

    renderPage: (data, content) => `<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="utf-8" />
        <title>Schemescape${data.title?.length > 0 ? `: ${escapeHTML(data.title)}` : ""}</title>
        ${data.description ? `<meta name="description" content="${escapeHTML(data.description)}" />` : ""}
        ${data.keywords?.length > 0 ? `<meta name="keywords" content="${escapeHTML(data.keywords.join(","))}" />` : ""}
        <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
        <link rel="stylesheet" href="${getPagePathToRoot(data)}/css/style.css" />
    </head>
    <body>
        <main>
            <header><h1><a href="${getPagePathToRoot(data)}/">Schemescape</a></h1></header>
            ${content}
        </main>
    </body>
</html>`,

This probably took me 10 minutes to create. But there are a few problems I've noticed along the way:

Given that I already know JavaScript, for a simple web site like mine, JavaScript templates are quick and easy. I'm not sure I'd want to maintain a large list of such templates, however, because they're a bit messy and error-prone.

Note: the template above uses some helpers defined elsewhere in the file, e.g. to compute the path to the root of the site (so I can use relative links and view my HTML directly from the file system). The full code is in my GitHub repository, on the eleventy branch.

Blog post pages

Setting up blog post pages was pretty simple:

1. Create the HTML template

I called it templates/post.11ty.js:

const escapeHTML = require("escape-html")
const { renderPage } = require("./shared");

const formatDateAsTimeElement = date => `<time datetime="${date.toISOString().replace(/T.*$/, "")}">${dateFormatter.format(date)}</time>`;

const renderArticle = data => `
<article>
    <header>
        <h1><a href="./">${escapeHTML(data.title)}</a></h1>
        <p>${formatDateAsTimeElement(data.page.date)}</p>
    </header>
    ${data.content}
</article>`;

module.exports = data => renderPage(data, renderArticle(data))

2. Inject data into all posts

Add content/posts.json to inject data to all files under the content/posts directory:

{
    "layout": "post.11ty.js",
    "tags": "post"
}

The first setting selects the template and the second tags all the files as part of the "post" collection (for later iteration).

With those two simple steps, Eleventy will generate directories with an HTML file for each Markdown source file I write.

Adding a home page

For now, I'm just aggregating all my posts in reverse chronological order on the home page. Also pretty simple:

1. Create the HTML template

Along with my generic page template above, the template is pretty simple (note the data.collections.post.slice().reverse().map(...) part):

templates/index.11ty.js:

const { renderPage } = require("./shared");

const renderArticleShort = data => `
<article>
    <header>
        <h1><a href=".${data.page.url}">${data.title}</a></h1>
        <p>${formatDateAsTimeElement(data.page.date)}</p>
    </header>
    <summary><p>${data.description}</p></summary>
</article>`;

module.exports = data => renderPage(data,
`<ul>
    ${data.collections.post.slice().reverse().map(post => `<li>${renderArticleShort(post.data)}</li>`).join("\n")}
</ul>`);

Note: the .slice() is needed because JavaScript's reverse() function mutates the array (as Eleventy's documentation notes).

2. Add a source file

The content\index.md file just points to the template:

---
layout: index.11ty.js
---

Eleventy will now build my index.html file at the root of the output directory.

Enabling links between pages

I use relative links between my Markdown files for internal links. For example, the post you're reading is eleventy-2.md and my previous post on Eleventy is eleventy.md. Here's how I made that second link:

[eleventy.md](eleventy.md)

This setup feels natural and works great for Markdown, both in Visual Studio Code's Markdown preview mode, and when viewing Markdown files in my repository directly on GitHub.

I was hoping that many static site generators would translate these links into ones that worked on the HTML web site, but so far it's seeming like that was just wishful thinking.

Fortunately, since I'm not tweaking output locations, this translation is just a simple mechanical process (prepend "../" and remove the ".md" suffix). Even better, there is a markdown-it plugin named markdown-it-replace-link for making such changes. This plugin can be integrated into Eleventy's pipeline via the .eleventy.js config file (after npm install --save-dev markdown-it-replace-link, of course):

module.exports = function(eleventyConfig) {
    // Convert relative Markdown file links to relative post links
    const customMarkdownIt = require("markdown-it")({
            html: true,
            replaceLink: link => link.replace(/^([^/][^:]*)\.md$/, "../$1"),
        })
        .use(require("markdown-it-replace-link"));

    eleventyConfig.setLibrary("md", customMarkdownIt);
...

The regular expression just checks for relative links (not starting with "/" or containing a ":") that end with ".md" and then prepends "../" and omits the ".md" suffix, to instead point to the source Markdown file's corresponding output directory (which contains an index.html).

Adding an RSS feed

Many of the static site generators I tested out automagically generate an RSS news feed. Eleventy does not do this.

Eleventy's documentation points to an official plugin for generating an Atom feed.

But if you read the documentation, it becomes apparent that the plugin is just a couple of helpers and the actual Atom template is something you'll be copy-pasting into your repository. Interestingly, I don't see any logic in the sample template to limit the number of posts.

This feels like a shortcoming of Eleventy (and one that probably wouldn't be difficult to fix). I shouldn't have to maintain my own template for a standard format.

Validating links

In the interest of catching mistakes before sharing them with the entire Internet, I'd like to at least validate all of the internal links between my pages at build time.

My ideal implementation would be a tool that I unleash on the output HTML files, crawling relative links to ensure they're all valid and that all pages are reachable. It would also ensure that any links to anchors within pages exist. Such a tool probably exists, but I haven't found it yet.

For now, I'm just using a quick and dirty hack that validates Markdown files exist in the process of updating the relative links noted previously. The code is fragile and slow enough that I won't advertise it anymore here, but it is in the repository linked below, if you're curious.

And that's it!

It took a little while to sort out some of the above details, but since I was able to leverage my existing JavaScript knowledge, Eleventy ended up being pretty easy to integrate. As proof, this page exists, and you're reading it.

All of the actual code used to generate this page is up on GitHub under the "eleventy" branch.

Update: I ended up switching from Eleventy to Metalsmith because Metalsmith has a simpler design and is easier to extend with plugins.