While Eleventy is a magnificent, and increasingly popular static site generator (SSG), I’ve found it hard to find good references on starting a project using standard technologies like webpack, Babel and Sass, so decided to write this tutorial.
Before we start, it’s important to know that I’m not going to describe any technology in great detail, so I’ll assume you have a basic understanding of the “front-end” stack, and have Node.js installed (both v10.15 and v14.5 have been tested and work perfectly).
It’s also not a comprehensive guide to Eleventy. There are other resources for that, with Learn Eleventy From Scratch seemingly a great option. While I have only read the free, first lesson, I see many positive comments from the wider community so feel free to check it out.
Let’s take a look at what we’ll cover:
- Setup
- Configuring Eleventy
- Creating a layout
- Creating our first page
- Serving our site
- Re-writing URLs
- Setting up webpack
- Improving our cache busting
- Serving our prod build
- Cleaning our prod build
- Minifying our HTML for prod
- Implementing Babel for polyfilling and transpilation
- Vendor prefixing CSS with Autoprefixer
- A Git gotcha - my assets aren’t updating!
- Wrapping up
Open a terminal in a new project directory, and run:
npm init -y
Next up, install Eleventy:
npm install @11ty/eleventy --save-dev --save-exact
I prefer to keep all source files in a root src
directory, with the full folder structure looking like this:
src/
_components/
All UI partials
_data/
Eleventy data files
_layouts/
Base page layouts
_pages/
Each individual page template
assets/
css/
index.scss
All other scss files
js/
index.js
All other js files
images/
All images used
Configuration and build files
Files in assets
will be handled by webpack, Eleventy will transform all of the directories with a leading _
, and will copy across any images
.
When the site is built, we’ll configure Eleventy to output it to a dist
directory at the root level.
I prefer to use EJS as my templating language because it’s the closest to being “just JavaScript” while also providing a simple developer experience for writing standard HTML.
OK, so let’s configure Eleventy to support the above structure.
First, create an .eleventy.js
file in the project root with the following:
module.exports = function(eleventyConfig) {
return {
dir: {
includes: '_components',
input: 'src',
layouts: '_layouts',
output: 'dist',
},
// Allows using markup and EJS features in markdown
markdownTemplateEngine: 'ejs',
templateFormats: [
'ejs',
'md',
],
};
};
The above assumes that you’ll be using .ejs
or .md
files for your templating, so I recommend you install an appropriate syntax highlighter for your editor.
Layouts are special templates that can be used to wrap other content, which in our case will be the base, page-level HTML markup.
In src/_layouts
, let’s create a default.ejs
file with the following:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title><%= locals.title %></title>
</head>
<body>
<%- content -%>
</body>
</html>
For the most part, this is a standard HTML file, but we have 2 uses of EJS syntax:
<%= locals.title %>
Will output an escaped page title
here. In EJS, it’s safer to always prefix your variables with locals.
, that way you can easily support undefined
variables in your templates.
<%- content -%>
Eleventy provides a content
variable, so this will render any page content (unescaped) that uses this layout within this block.
In src/_pages
, let’s create an index.md
with the following:
---
title: My cool website
layout: default.ejs
---
# Hello, world
Welcome to my website.
A random number is <%- Math.random() %>
The part between ---
is called front matter, where we can define whatever variables we like, as well as make use of some built-in ones provided by Eleventy.
We’ve defined what our page title
will be (which is consumed within our default
layout), and the layout
to use.
We’re using markdown which will be converted to an <h1>
and <p>
s, and demonstrating that you can also use EJS features (and JavaScript) within markdown itself.
With our page created, how do we serve it up locally and see changes as we update the content and create new pages?
Let’s head over to package.json
, and update our "scripts"
with this:
"scripts": {
"build:site": "ELEVENTY_ENV=production npx eleventy",
"dev:site": "ELEVENTY_ENV=development npx eleventy --serve"
},
Now, from a terminal, you can run npm run dev:site
and browse to http://localhost:8080/_pages/ to see your HTML page fully rendered.
We’ve also added a command to perform a production build, but we won’t need to use that just yet. Also note the ELEVENTY_ENV=production|development
. This provides us the ability to do different things with our Eleventy process, like minifying HTML, depending on the build type.
But wait up, we don’t want users (or us) to have to browse to /_pages
in every URL; that home page should be available at the root domain!
Thankfully, Eleventy has a feature called permalinks, which allows you to set what the URL for each page will be. Now, while we can manually add this to every page’s front matter to remove _pages
, we can go one better by automating that.
Let’s create a _pages.json
in src/_pages
, with the following:
{
"permalink": "<%- page.filePathStem.replace('/_pages', '').replace('/index', '') %>/index.html"
}
“What is this madness?”, I hear you cry. Well, since we can use JavaScript within EJS, and use EJS within JSON, we can use the page.filePathStem
which Eleventy provides to construct a new, better permalink path.
As an example, for the following files:
/_pages/index.md
/_pages/foo/index.ejs
/_pages/foo/bar.md
/_pages/foo/baz/index.ejs
/_pages/foo/baz/qux.md
Eleventy will provide the following filePathStem
s:
/_pages/index
/_pages/foo/index
/_pages/foo/bar
/_pages/foo/baz/index
/_pages/foo/baz/qux
So, this “script” first removes the /_pages
from the filePathStem
string, then removes any trailing /index
so every page is “equal”, and finally appends /index.html
, which, for the above path examples, results in:
/index.html
/foo/index.html
/foo/bar/index.html
/foo/baz/index.html
/foo/baz/qux/index.html
Now, you should be able to browse directly to http://localhost:8080/ to see your “Hello, world” file, and the correct path for any other file you create later on.
While this is kinda cool, it’s completely unstyled, so let’s see how we can set up webpack to compile CSS using Sass.
Before we do any webpack configuration, let’s first scaffold our assets so we have something to configure.
We’re going to use Sass, which, while it may be going out of favour in some circles, still does an excellent job at allowing us to write more maintainable CSS.
As a side note, I still like Sass so much that I’ve used it to create an atomic CSS library called Hucssley, which, in my humble opinion, is excellent!
Anyway, back to this project…
Create src/assets/css/index.scss
with the following code:
html {
font-family: sans-serif;
background: #cbe3f5;
}
In this directory, you would add all of your project’s Sass partials and @import
them from index.scss
.
If you prefer to co-locate your CSS and components, you could just as easily store them in specific template folders within _components
and @import
from there as well.
For webpack to handle our CSS, it must be import
ed into a JavaScript file, so let’s create src/assets/js/index.js
with the following:
import '../css/index.scss';
console.log('Hello again');
Although we import
the CSS within in JavaScript, this is not CSS-in-JS; it’s purely so webpack can do its thing™.
Now that we have our asset files, let’s begin with the setup.
First, we’re going to need to install quite a few dependencies now, so kill your dev:site
process, and run this in the terminal.
npm install css-loader fibers mini-css-extract-plugin optimize-css-assets-webpack-plugin sass sass-loader terser-webpack-plugin webpack webpack-cli webpack-merge --save-dev --save-exact
In the previous step, we installed a dependency called webpack-merge. This will allow us to have separate development and production configurations which share the same, common configuration.
In the project root, create webpack.config.common.js
with the following:
// Makes Sass faster!
const Fiber = require('fibers');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const path = require('path');
module.exports = {
// Our "entry" point
entry: './src/assets/js/index.js',
output: {
// The global variable name any `exports` from `index.js` will be available at
library: 'SITE',
// Where webpack will compile the assets
path: path.resolve(__dirname, 'src/compiled-assets'),
},
module: {
rules: [
{
// Setting up compiling our Sass
test: /\.scss$/,
use: [
{
loader: MiniCssExtractPlugin.loader,
},
{
loader: 'css-loader',
options: {
url: false,
},
},
{
loader: 'sass-loader',
options: {
implementation: require('sass'),
sassOptions: {
fiber: Fiber,
outputStyle: 'expanded',
},
},
},
],
},
],
},
// Any `import`s from `node_modules` will compiled in to a `vendor.js` file.
optimization: {
splitChunks: {
cacheGroups: {
commons: {
test: /[\\/]node_modules[\\/]/,
name: 'vendor',
chunks: 'all',
},
},
},
},
plugins: [
new MiniCssExtractPlugin({
filename: '[name].css',
}),
],
};
Now, let’s create our development config, at webpack.config.dev.js
:
const { merge } = require('webpack-merge');
const common = require('./webpack.config.common.js');
module.exports = merge(common, {
mode: 'development',
// Allow watching and live reloading of assets
watch: true,
});
And finally, our production config, at webpack.config.prod.js
:
const OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin');
const TerserPlugin = require('terser-webpack-plugin');
const { merge } = require('webpack-merge');
const common = require('./webpack.config.common.js');
module.exports = merge(common, {
// Enable minification and tree-shaking
mode: 'production',
optimization: {
minimizer: [
new OptimizeCssAssetsPlugin({}),
new TerserPlugin({
extractComments: false,
}),
],
},
});
Now we have our configs, we need scripts to run them. Head over to package.json
, and add 2 new scripts:
"build:assets": "webpack --config webpack.config.prod.js",
"dev:assets": "webpack --config webpack.config.dev.js",
so it should look like this:
"scripts": {
"build:assets": "webpack --config webpack.config.prod.js",
"build:site": "ELEVENTY_ENV=production npx eleventy",
"dev:assets": "webpack --config webpack.config.dev.js",
"dev:site": "ELEVENTY_ENV=development npx eleventy --serve"
},
Note: JSON doesn’t allow trailing commas on the last line of an object, so all of the updates I suggest are correct if adding them alphabetically.
If you run npm run build:assets
in your terminal, you should now have 2, minified files generated at:
src/compiled-assets/main.css
src/compiled-assets/main.js
Let’s open up our src/_layouts/default.ejs
, and in the <head>
, add a reference to the stylesheet, and before the closing </body>
, add a reference to our JavaScript files.
<head>
… existing tags
<link rel="stylesheet" href="/assets/main.css?<%- Date.now() %>" />
</head>
<!-- <script src="/assets/vendor.js?<%- Date.now() %>"></script> -->
<script src="/assets/main.js?<%- Date.now() %>"></script>
</body>
We’ve also added some simplistic cache busting using the current timestamp (but we’ll improve that for production later on).
If you’re wondering why we have a commented out vendor.js
<script>
, we’ll use this later when we implement Babel to polyfill and transpile
You would have noticed that we’re loading these from the /assets
directory, but we compiled them in to a /compiled-assets
one. Now we need to update our .eleventy.js
config file to handle this.
Update to .eleventy.js
to include the following at the start of the function
body:
// Watch our compiled assets for changes
eleventyConfig.addWatchTarget('./src/compiled-assets/main.css');
eleventyConfig.addWatchTarget('./src/compiled-assets/main.js');
// eleventyConfig.addWatchTarget('./src/compiled-assets/vendor.js');
// Copy src/compiled-assets to /assets
eleventyConfig.addPassthroughCopy({ 'src/compiled-assets': 'assets' });
// Copy all images
eleventyConfig.addPassthroughCopy('src/images');
Let’s test it to see if it works!
Open another terminal, and in 1, run:
npm run dev:assets
and in the other, run:
npm run dev:site
If you browse to http://localhost:8080/, your page should be light blue with a sans-serif font! And if you open the console, "Hello again"
should be there.
OK, so let’s see if we can update the files and reload our page. Change anything in the CSS, perhaps change the background
to #e1e1e1
and change the console.log
to print 'Hello from the console'
.
Did the changes take effect? Awesome!
Having to run 2 terminals is a pain though, so let’s improve that with a dev
command. Before we proceed, first stop both of those dev:
processes from the terminal.
If you’re not using (or need to support Windows), you can simply add:
"dev": "npm run dev:assets & npm run dev:site",
However, if you do need to support Windows, I recommend installing npm-run-all as a dev dependency with:
npm install npm-run-all --save-dev --save-exact
Then you can use the following command instead:
"build": "npm-run-all --parallel dev:assets dev:site",
Even if you don’t need to support Windows, I quite like the syntax of npm-run-all
, especially if you need to run many commands, because it removes the need for prefixing npm run
every time.
Try running npm run dev
from a terminal, and both processes should run and watch for changes as before.
Now let’s create a similar command to build for production:
Update your package.json with:
"prod": "npm-run-all build:assets build:site"
Notice that these don’t run in parallel, because we want the assets to be ready before Eleventy copies them to dist/
.
Earlier, we used Date.now()
to cache bust our assets, however, that would mean that with every build (and deployment), users would be forced to download new versions of the files, even if they hadn’t changed. We can improve that by using a library called md5-file to use the file’s content as our base for cache busting string.
Let’s first install it as a dev dependency:
npm install md5-file --save-dev --save-exact
Now, create src/_data/cacheBust.js
with the following:
const md5File = require('md5-file');
const cacheBust = () => {
// A "map" of files to cache bust
const files = {
mainCss: './src/compiled-assets/main.css',
mainJs: './src/compiled-assets/main.js',
// vendorJs: './src/compiled-assets/vendor.js',
};
return Object.entries(files).reduce((acc, [key, path]) => {
const now = Date.now();
const bust = process.env.ELEVENTY_ENV === 'production' ? md5File.sync(path, (_err, hash) => hash) : now;
acc[key] = bust;
return acc;
}, {});
};
module.exports = cacheBust;
This script will loop over the items in files
, and for each of the keys, return the current date in milliseconds for development, or the md5 hash for production in a new object. Notice the process.env.ELEVENTY_ENV === 'production'
, which is taking advantage of the ELEVENTY_ENV=production
variable that we added to our package.json
"script"
earlier.
Now we need to update our src/_layouts/default.ejs
to use our new cache busting string:
- <link rel="stylesheet" href="/assets/main.css?<%- Date.now() %>" />
+ <link rel="stylesheet" href="/assets/main.css?<%- cacheBust.mainCss %>" />
- <!-- <link rel="stylesheet" href="/assets/vendor.js?<%- Date.now() %>" /> -->
- <link rel="stylesheet" href="/assets/main.js?<%- Date.now() %>" />
+ <!-- <link rel="stylesheet" href="/assets/vendor.js?<%- cacheBust.vendorJs %>" /> -->
+ <link rel="stylesheet" href="/assets/main.js?<%- cacheBust.mainJs %>" />
We can run our prod build with npm run prod
, but we don’t have an explicit way to view it in the browser yet.
Let’s install a new dependency, serve, which is an excellent, little web server:
npm install serve --save-dev --save-exact
And add a new script to package.json:
"serve:prod": "serve ./dist/"
Running npm run serve:prod
should now make your site available at http://localhost:5000/, where, if you inspect the source or view the Network tab, you should see the CSS and JS files have the correct md5 hash on their querystring.
Since both our development and prod builds end up in the same place, I’d recommend cleaning the directory before performing a prod build, just to ensure there aren’t any leftover files in there. To support all operating systems, use another node module, rimraf.
npm install rimraf --save-dev --save-exact
and update package.json to include:
"del:dist": "rimraf ./dist",
and change our build script to:
"prod": "npm-run-all del:dist build:assets build:site",
Since we’re minifying our CSS and JavaScript in our webpack process, let’s also minify our HTML to squeeze a little more performance benefits:
Install html-minifier as another dev dependency:
npm install html-minifier --save-dev --save-exact
Then update .eleventy.js
to first import the package:
const htmlmin = require('html-minifier');
And then, before the return
, add:
if (process.env.ELEVENTY_ENV === 'production') {
eleventyConfig.addTransform('htmlmin', (content, outputPath) => {
if (outputPath.endsWith('.html')) {
const minified = htmlmin.minify(content, {
collapseInlineTagWhitespace: false,
collapseWhitespace: true,
removeComments: true,
sortClassName: true,
useShortDoctype: true,
});
return minified;
}
return content;
});
}
Most developers these days want to take advantage of ES6 (2015) features and APIs, like arrow functions, const
, spreads and Array.from()
, while also supporting older browsers which don’t understand these syntaxes.
Thankfully, Babel can do all this for us with minimal effort.
Let’s install the required dev dependencies:
npm install @babel/core @babel/preset-env babel-loader core-js --save-dev --save-exact
Now let’s tell webpack to use Babel by updating webpack.config.common.js
to add this new object in the rules
array:
// Transpile and polyfill our JavaScript
{
test: /\.js$/,
use: 'babel-loader',
exclude: /node_modules/
},
Next configure Babel by creating a babel.config.json
at the root of the project:
{
"presets": [
[
"@babel/preset-env",
{
"corejs": 3,
"useBuiltIns": "usage"
}
]
]
}
I’d also recommend creating a .browserslistrc
file with the browsers you wish to support. For instance, I plan to support the following:
> 1%
last 2 versions
And finally, let’s un-comment all of the vendor references throughout the project, so in:
.eleventy.js
src/_data/cacheBust.js
src/_layouts/default.ejs
With that all setup, if you were to update your src/assets/js/index.js
to add:
Array.from(document.getElementsByTagName('p')).forEach((p) => {
console.log(`p ${index}, startsWith('W')`, p, p.innerHTML.startsWith('W'));
});
then run a dev or prod build, you should see that Babel created a vendor.js
file, which has automatically transpiled the arrow function and the template literal, and polyfilled Array.from
and .startsWith()
, so all this works as expected, even in IE 11!
The best thing about our Babel setup is that it will only polyfill the features you use, so as you continue to develop your site and write more JavaScript, the vendor file will change appropriately.
While out of scope for this tutorial, you may also want to investigate creating and conditionally loading 2 JavaScript bundles: 1 for modern browsers, and 1 for legacy browsers with polyfills in tow.
The final piece is to use Autoprefixer to automatically add vendor prefixes to your CSS output, to allow you to only write the standard syntax, while ensuring all browsers (in your .browserslistrc
) are catered for.
Let’s install 2 more dependencies:
npm install autoprefixer postcss-loader --save-dev --save-exact
Now create a postcss.config.js
in the project root:
module.exports = {
plugins: [
require('autoprefixer'),
],
};
And update webpack.config.common.js
to add the following before the loader: 'sass-loader'
item .scss
use
array:
{
loader: 'postcss-loader',
},
If you were to now update your CSS to include display:flex
somewhere, you should see it automatically prefixed with display: -webkit-box
and display: -ms-flexbox
.
Now, this one stumped for me for a while, so take note!
Inevitably, your project will be stored in a Git repo, which will have its own .gitignore
file. As you would never want to store the compiled files in Git, it would look something like this:
/dist/
/node_modules/
/src/compiled-assets/
.DS_Store
Thumbs.db
Unfortunately, this will break Eleventy from watching your assets, but it’s an easy fix. Create a near-identical .eleventyignore
file in the root, but make sure that src/compiled-assets
is not listed, like so:
/dist/
/node_modules/
.DS_Store
Thumbs.db
Then update .eleventy.js
to add:
eleventyConfig.setUseGitIgnore(false);
I hope you’ve found this whirlwind tutorial helpful for getting started with Eleventy and common developer tooling. I sure wish I had this guide a while ago!
And if you’d like to see the code in one place, I have it hosted as an Eleventy starter kit called Elf.