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.
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
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.
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.
Interested in Alpine.js?
Subscribe to Alpine.js Weekly. A free, once–weekly email roundup of Alpine.js news and articles