Skip to content

Latest commit

 

History

History
756 lines (550 loc) · 24.8 KB

tutorial.md

File metadata and controls

756 lines (550 loc) · 24.8 KB

Creating a production-ready Eleventy project with webpack, Babel and Sass

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

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

Configuring Eleventy

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.

Creating a layout

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.

Creating our first page

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.

Serving our site

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.

Re-writing URLs

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 filePathStems:

/_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.

Setting up webpack

Creating our assets

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 imported 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™.

Installing our dependencies

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

Creating our configs

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

Adding the assets to our Eleventy site

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.

Updating our Eleventy config

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/.

Improving our cache busting

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 %>" />

Serving our prod build

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.

Cleaning our prod build

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",

Minifying our HTML for prod

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;
  });
}

Implementing Babel for polyfilling and transpilation

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.

Vendor prefixing CSS with Autoprefixer

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.

A Git gotcha - my assets aren’t updating!

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);

Wrapping up

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.