(Updated: )
/ #eleventy #alpinejs #javascript 

How to migrate a bunch of HTML pages (Alpine.js Playground) to Eleventy

Alpine.js Playground was recently migrated from custom build scripts & HTML pages to leverage Eleventy.

For context, Alpine.js Playground’s custom build scripts + HTML files had the following pros and cons.

Pros:

  • simple
  • very simple, everything is in the scripts folder.
  • no dependencies (except the scripts do have dependencies)

Cons:

  • hand-rolled, doesn’t leverage any tooling
  • no markdown/data file support
  • clunky editing the homepage HTML inside the JS file that generates it
Table of Contents

Why Eleventy?

I didn’t know this at the time but it supports HTML files as input, which means I can use my existing HTML files with minimal updates (eg. adding a title in the front matter).

It’s fast and isn’t opinionated (cf. it allows you to input HTML files).

I want a tool to be able to add more categories to Alpine.js Playground Homepage, for example adding a “components” section with ready to copy-paste demos from codepen or an articles section.

I also want a tool that would allow me to template the pages (within reason), for example to add the newsletter subscription form to all the pages without a copy-paste job.

In short I want something that’s lightweight, fast, renders to HTML and that I can add quickly without reformatting the whole project (easy to configure & flexible). This tool I pick should allow me to remove the custom build scripts and stop editing HTML inside of a JavaScript template string.

Adding Eleventy

Install Eleventy locally

yarn add --dev @11ty/eleventy

Run it with yarn eleventy --input=pages --output=dist --serve, it runs on localhost:8080 but there’s no index file so going to http://localhost:8080 shows a blank 404 page.

Creating a simple config

Instead of using command line flags for configuration, we can create a config file at .eleventy.js.

It’s a barebones “copy-pasted from somewhere” configuration (probably the docs). In which templateFormats sets supported formats to markdown (md), Nunjucks (njk) and HTML (html). The data and HTML template engine is set to Nunjucks by setting htmlTemplateEngine and dataTemplateEngine to njk.

Finally, we port the configuration flags we used at the command line in the dir option, input folder is the pages directory and output should go to the dist directory.

module.exports = function() {
  return {
    templateFormats: [
      "md",
      "njk",
      "html",
    ],
    htmlTemplateEngine: "njk",
    dataTemplateEngine: "njk",
    dir: {
      input: "./pages",
      output: "dist"
    }
  };
};

Migrating the Alpine.js Playground Homepage

The Alpine.js Playground homepage shows a list (with links) of the example pages.

How to get an index.html

echo "Hello world" >> pages/index.njk

Run yarn eleventy --serve.

And see “Hello world” in the browser as follows.

“Hello world” returned from http://localhost:8080/

This means Eleventy uses index.njk to generate the index.html page for the site.

Migrating index.html from build-index.js template string to a nunjuck template

scripts/build-index.js generates an index file using a couple of functions (that read from the pages directory). The pages data is injected into the page using a custom serialiser and Alpine.js renders the list client side once it’s loaded.

build-index also has functionality to inject description from package.json and the commit SHA and date from an environment variable. Initially I though these might have to be filters but it seems package.json is injected as pkg in all templates and a data file can be used to generate the commit information.

Back to rendering the list of pages, we should be able to use a Nunjucks for loop and loop through the relevant pages instead of Alpine.js’ x-for, removing the need to use Alpine.js.

The raw HTML template string as per scripts/build-index.js is as follows.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta name="description" content="Alpine.js Playground - ${
      pkg.description
    }" />
    <title>Alpine.js Playground</title>
    <link
      href="https://cdn.jsdelivr.net/npm/[email protected]/dist/tailwind.min.css"
      rel="stylesheet"
    />
    <script
      src="https://cdn.jsdelivr.net/gh/alpinejs/[email protected]/dist/alpine.min.js"
      defer
    ></script>
  </head>

  <body class="flex">
    <div
      x-data="page()"
      class="flex mx-auto flex-col items-center px-8 md:px-32 py-24"
    >
      <h2 class="text-xl font-semibold text-gray-900 mb-8">Alpine.js Playground</h2>
      <div class="text-xs text-gray-500 italic mb-4">Last update: <a
        class="text-blue-300 hover:text-blue-600 hover:underline"
        href="${commit.url}"
      >${commit.text}</a>
      </div>
      <p class="mb-4">${
        pkg.description
      } by <a class="text-blue-500 hover:text-blue-800 hover:underline" href="https://codewithhugo.com/tags/alpinejs">Hugo</a></p>
      <ul class="list-inside mb-8">
        <template x-for="page in pages" :key="page.path">
          <li class="list-disc w-full">
            <a
              class="text-blue-500 hover:text-blue-800 hover:underline"
              :href="'/' + page.path"
              x-text="page.title"
            ></a>
          </li>
        </template>
      </ul>

      <form
        action="https://buttondown.email/api/emails/embed-subscribe/alpinejs"
        method="post"
        target="popupwindow"
        onsubmit="window.open('https://buttondown.email/alpinejs', 'popupwindow')"
        class="flex md:w-4/5 md:p-10 flex-col rounded mb-4 embeddable-buttondown-form"
      >
        <label
          class="flex text-gray-700 font-semibold mb-2 text-sm"
          for="bd-email"
        >
          Subscribe to Alpine.js Weekly
        </label>
        <p class="leading-relaxed flex text-sm mb-4">A free, once–weekly email roundup of Alpine.js news and articles.</p>
        <input
          placeholder="[email protected]"
          class="flex mb-2 bg-white focus:outline-none focus:shadow-outline border border-gray-300 rounded-lg py-2 px-4 appearance-none leading-normal"
          type="email"
          name="email"
          id="bd-email"
          required="required"
        />
        <input type="hidden" value="1" name="embed" />
        <input
          class="flex bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
          type="submit"
          value="Subscribe"
        />
      </form>
    </div>
    <script>
      function page() {
        return {
          pages: [${renderPagesToJSObj(pages)}]
        }
      }
    </script>
  </body>
</html>

Which renders in the browser as follows.

Output of copy-pasted HTML template string into index.njk

Getting the description from package.json using pkg built-in

Eleventy injects pkg into all templates, so all we need to do to bring pkg.description back is to switch from JS interpolation (${pkg.description}) to Nunjucks interpolation {{ pkg.description }}.

Accessing all pages

collections.all is straightforward enough, but since we don’t have frontmatter yet, we can only access the URL using the following in pages/index.njk:

<ul>
  {% for post in collections.all %}
    <li>{{ post.url }}</li>
  {% endfor %}
</ul>

Excluding pages from collections.all

scripts/build-index.js was excluding the thank-you page, we can do the same by adding the following front matter using eleventyExcludeFromCollections.

---
eleventyExcludeFromCollections: true
---

We also need to exclude index.njk itself, by adding the same frontmatter block.

Bringing page titles back

In order to get the title of each HTML file, we’ll need to use frontmatter. This can be considered slightly better than truncating the title tag (to remove “Alpine.js Playground”) since we’re being data-driven and don’t expect any particular template, just frontmatter.

That will look somethings like:

---
title: "Benchmark Array#includes vs RegExp literal & instance"
---

Which shows as the following

Eleventy template rendering all the pages

Showing commit information

Based on Eleventy conventions, we add a _data file to the root of the project. In this section we’ll loosely following what’s in the Eleventy Docs Example: Exposing Environment Variables.

Inside of _data/commit.js, we’ll create the following (getCommit is lifted from scripts/build-index.js):

function getCommit() {
  console.log('runs');
  return process.env.REPOSITORY_URL && process.env.COMMIT_REF
    ? {
        url: `${process.env.REPOSITORY_URL}/commits/${process.env.COMMIT_REF}`,
        text: process.env.COMMIT_REF.slice(0, 6)
      }
    : {
        url: "",
        text: "develop"
      };
}

module.exports = getCommit();

Eleventy will inject commit as a global as long as it’s configured correctly.

Unfortunately, because we’ve set the input directory to ./pages (instead of the current directory: .) we need to set the data directory to ../_data in our .eleventy.js config:

module.exports = function() {
  return {
    // other config
    dir: {
      // other dir config
      data: "../_data",
      // rest of dir config
    }
  };
};

And in pages/index.njk we can use commit using Nunjucks syntax:

<div>
  Last update: <a
    href="{{ commit.url }}"
  >{{ commit.text }}</a>
</div>

Which means the homepage is back to feature parity with the Alpine.js + hand-rolled scripts version.

Alpine.js Playground homepage with commit information using Eleventy

Cleaning up and removing Alpine.js

We’re now free to remove the Alpine.js CDN include:

<script
  src="https://cdn.jsdelivr.net/gh/alpinejs/[email protected]/dist/alpine.min.js"
  defer
></script>

As well as the x-data="page()" attribute and the corresponding script tag:

<script>
  function page() {
    return {
      pages: [${renderPagesToJSObj(pages)}]
    }
  }
</script>

Making sure reflection works (reflect.js)

To show the code that’s being run for the example, we include a shared reflect.js file. For this to be copied through we should add it to .eleventy.js in templateFormats

module.exports = function() {
  return {
    templateFormats: [
      // other template formats
      "js"
    ],
    // rest of config
  };
};

I’ve also needed to rework reflect.js script includes from relative to absolute, since Eleventy prettifies URLs by default. That is, instead of putting a benchmark-array-includes-vs-regex.html file it creates a directory with an index.html file at benchmark-array-includes-vs-regex/index.html and loads everything with trailing forward slashes (/) so ./reflect.js will need to be replaced with /reflect.js.

Preparing for dev and production

For development, it’s useful to add a start shortcut for eleventy --serve and for build, we should run eleventy. This is all configured in the package.json.

{
  "scripts": {
    "build": "rm -rf dist && eleventy && node scripts/inline-remote-css/cli.js",
    "start": "eleventy --serve",
    "//": "other scripts"
  },
  "//": "other package.json properties"
}

We can also remove the serve package (which was formerly used for the local dev server) using:

yarn remove serve

Aside: fixing a globbing issue in inline-remote-css

scripts/inline-remote-css/cli.js only worked on index.html after switching to Eleventy.

This is due to the pretty URLs (and files) that Eleventy generates and the naive inline-remote-css implementation that just reads the files in the top-level directory.

To fix this, we can switch to the glob library and use the dist/**/*.html glob match.

To begin with, we’ll add the glob package using:

yarn add --dev glob

Then we can import and promisify it in scripts/inline-remote-css/cli.js.

// other imports
let glob = require('glob');
const { promisify } = require('util');
glob = promisify(glob);

Finally we can use it to replace our fs.readdir approach. What was originally reading the directory and filtering to keep only .html files.

const files = (await fs.readdir(distDir)).filter(f => f.endsWith(".html"));

Becomes a simple glob call, distDir is ./dist so our glob is ./dist/**/*.html, ie. all the HTML files, top-level and nested in the dist directory.

const paths = await glob(`${distDir}/**/*.html`);

And we propagate this change throughout the rest of the function.

glob returns absolute paths, where readdir only returns the name of files in a directory, which means paths had to be generated.

async function main() {
  const files = (await fs.readdir(distDir)).filter(f => f.endsWith(".html"));
  await Promise.all(
    files.map(async f => {
      try {
        console.time(f);
        const filePath = `${distDir}/${f}`
        // Read file
        const initialHtml = await fs.readFile(filePath, "utf8");
        // Run transform
        const newHtml = await inlineCss(initialHtml);
        // Write back to file
        await fs.writeFile(filePath, newHtml, "utf8");
        console.timeEnd(f);
      } catch (err) {
        console.error(`${f}: ${err.stack}`);
      }
    })
  );
}

Thanks to glob returning absolute paths, we can eschew the filePath juggling and map through the paths output from globbing directly.

async function main() {
  const paths = await glob(`${distDir}/**/*.html`);
  await Promise.all(
    paths.map(async path => {
      try {
        console.time(path);
        // Read file
        const initialHtml = await fs.readFile(path, "utf8");
        // Run transform
        const newHtml = await inlineCss(initialHtml);
        // Write back to file
        await fs.writeFile(path, newHtml, "utf8");
        console.timeEnd(path);
      } catch (err) {
        console.error(`${path}: ${err.stack}`);
      }
    })
  );
}

First Impressions of Eleventy

I’ve been using Hugo for codewithhugo.com for a few years now. One big advantage is has over other static site generators is speed and rendering to static HTML, as opposed to Gatsby/Next/Nuxt do render statically but by default want to hydrate to a client-side JS application.

Eleventy shares this part of the Hugo philosophy, it’s also performant and renders to static HTML.

What’s been a good surprise is that Eleventy doesn’t care about what input formats you throw at it. If you’ve got a mix of markdown, asset and HTML files, that’s fine with Eleventy, it’s there to fit your project, not the other way around.

I was therefore able to easily augment my HTML files, add an index page and be done with the whole project. The added bonus was that I could ditch Alpine.js on the homepage where it didn’t really need to run.

The reason I decided to use Eleventy was to be able to add extra sections to the homepage, and based on what I’ve seen, I should be able to add either an index.md file with a bunch of data in the front-matter or some data files in the _data directory.

unsplash-logoMarc-Olivier Jodoin

Author

Hugo Di Francesco

Co-author of "Professional JavaScript", "Front-End Development Projects with Vue.js" with Packt, "The Jest Handbook" (self-published). Hugo runs the Code with Hugo website helping over 100,000 developers every month and holds an MEng in Mathematical Computation from University College London (UCL). He has used JavaScript extensively to create scalable and performant platforms at companies such as Canon, Elsevier and (currently) Eurostar.

Interested in Alpine.js?

Subscribe to Alpine.js Weekly. A free, once–weekly email roundup of Alpine.js news and articles