Twig + Webpack

May 30, 2019

I recently completed phase one of a project that required a static HTML site. Farther down the road a CMS will be rolled into the site. Being a big proponent of the DRY principle and having a short turn around time on this particular project I wanted to find a way to 1) automate code generation and leverage build tools to speed up the development process and 2) reduce code duplication as much as possible to make development and debugging faster. Part 1 was straight forward, I rely heavily on webpack during front end development for CSS and Javascript on web projects. Part 2 I wasn't so sure about. I had used Handlebars and Mustache in the past but I wanted to use something I was more familiar with, specifically Twig, since I use it in every Craft project. A little Googling found a javascript port of Twig.

With Twig as an option I started setting up the project. In my project src folder I created a twig folder that would hold all the template files and setup a '_layouts' and '_partials' directory to hold my base layouts and reusable twig elements. Since the first pass of the site would be static any data for the pages would need to reside in the pages themselves. Each main twig file equated to an HTML file (ie index.twig, about.twig, contact-us.twig etc).

I wanted to make the pages and elements as reusable as possible so following normal Twig practices I started with a layout file that included the normal document head, header and footer elements. With that in place I started creating the individual pages, each one extending the base layout template. 

The base layout:


<title>Site{% if title|length %} - {{ title }}{% endif %}</title>
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<body class="" >
{% include "../_partials/header.twig" %}
{% block main %}
{% endblock %}
{% include "../_partials/footer.twig" %}
</body>

A page extending the base layout:


{% set title = "Some page" %}
{% extends "_layouts/layout.twig" %}

{% block main %}
main page content
{% endblock %}

Inside the individual pages I used a combination of hard coded html elements and text and twig arrays with macros or includes. Here's an example of a carousel using a twig array and an include file.


<div class="carousel" data-js-module="carousel">
  {% set slides = [
    {
      img: {desktop: "/uploads/home/carousel-1.jpg", mobile:"/uploads/home/carousel-1.jpg"},
      headline: "Lorem ipsum dolor sit amet",
      cta: {
        url: "/page-1.html",
        label: "Lorem"
      }
    },
    {
      img:{desktop: "/uploads/home/carousel-2.jpg", mobile: "/uploads/home/carousel-2-mobile.jpg"},
      headline: "Consectetur adipiscing elit",
      cta: {
        url: "/page-2.html",
        label: "Consectetur"
      }
    },
    {
      img: {desktop: "/uploads/home/carousel-3.jpg", mobile: "/uploads/home/carousel-3-mobile.jpg"},
      headline: "Nam diam elit, faucibus at scelerisque",
      cta: {
        url: "/page-3.html",
        label: "Nam diam"
      }
    }]%}

  {% for slide in slides %}
    {% include "_partials/carousel-slide.twig" with slide %}
  {% endfor %}  
</div>   

And here's the carousel-slide.twig file:


<div class="carousel-slide relative">
  <picture>
    <source srcset="{{img.desktop}}" media="(min-width: 1024px)">
    <img src="{{ slide.img.mobile}}"/>
  </picture>
  <div>
    <div>
      <div>
        <div>
          {% if slide.headline is defined %}
            <h1>{{ slide.headline }}</h1>
          {% endif %}
          {% if slide.cta is defined %}
            <a href="{{ slide.cta.url }}" >{{ slide.cta.label }}</a>
          {% endif %}
        </div>
      </div>
    </div>
  </div>
</div>

For a few parts of the website I needed to use the same template and just switch up the content on the page. To accomplish that I created a layout that extended the base layout and then created simple twig templates that looked like this. Each 'bio' twig file would result in a standalone bio HTML page. All the bios existed in a sub directory of the main twig files. 

Here's an example of an individual bio template:


{% set bio = {
  name: "John Smith",
  title: "Partner",
  image: "/uploads/team/john-smith-color_800x800.jpg",
  subheadline: "Vestibulum sollicitudin bibendum orci, eget iaculis lorem dignissim vel.",
  education: "University of Lipsum",
  body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus feugiat dictum sem, et auctor elit luctus non. Praesent sit amet tellus ut odio sagittis consectetur in eget neque. 
Fusce fringilla odio quis nibh suscipit, interdum bibendum eros auctor. Ut velit augue, accumsan nec commodo nec, placerat non ipsum. Quisque sed molestie nibh. Integer sit amet venenatis nulla, nec molestie dui. Ut elementum dictum quam vel iaculis."
} %}

{% extends "../_layouts/bio.twig" %}

And here's the bio twig layout file:


{% extends "layout.twig" %}
{% block main %}
<main>
  <div>
    <div>
       <h1>{{bio.name}}</h1>
      <h2>{{bio.title}}</h2>
    </div>
    <div>
      <img src="{{bio.image}}">
    </div>
  </div>
  <div>
    <div>
      <p>{{bio.subheadline}}</p>
      <hr>
      <p>{{bio.education}}</p>
    </div>
    <div>
      {% set body = bio.body|split("\n") %}
      {% for line in body %}
      <p>{{line}}</p>
      {% endfor %}
    </div>
  </div>
</main>
{% endblock %}

Anyone with Twig experience will be completely comfortable with this setup. The syntax matches anything you would generally use in your standard PHP based Twig templates. The benefit of this setup is that those templates are plug and play with an Twig based CMS. When the time comes to add a CMS to mix drop them into the project, make any small adjustments to reference the data being injected by the CMS (ie entity and field names instead of hard coded text) and you are good to go. All the twig layouts, extensions, logic, filters and anything else will just work.

With the pages complete the next step was to turn those twig files into HTML. Webpack to the rescue, more specifically the html webpack plugin and the twig html loader. In the HtmlWebpack documentation there's an example on how to render out a single page. This chunk of code gets added to the webpack config to export a single file.


const HtmlWebpackPlugin = require('html-webpack-plugin')

module.exports = {
  entry: 'index.js',
  output: {
    path: __dirname + '/dist',
    filename: 'index_bundle.js'
  },
  plugins: [
    new HtmlWebpackPlugin()
  ]
}

With that as a starting point I came up with this to take a single twig file and output an HTML file.


const HtmlWebpackPlugin = require('html-webpack-plugin')

module.exports = {
  entry: './src/twig/index.twig',
  output: {
    path: __dirname + '/dist',
    filename: 'index.html'
  },
  plugins: [
    new HtmlWebpackPlugin()
  ]
}

and a rule for what to do with the twig files

module.exports = {
  module: {
    rules: [ ...
       // Twig templates
      {
        test: /\.twig$/,
        use: [
          'raw-loader',
          {
            loader: 'twig-html-loader',
            options: {
              data: {},
            },
          },
        ],
      }
    ]
  }
}

That worked like a charm but I had a lot of pages to output and I didn't want to hand code that setup for every page. I needed to recursively read through the main twig directory and make a list of the templates in all the folders, keeping the paths intact and excluding all the template files. 

// create a list of twig files to generate
// filter out anything that starts with an underscore or is not a twig file
function walk(dir) {
  let results = [];
  const list = fs.readdirSync(dir);
  list.forEach(file => {
    file = `${dir}/${file}`;
    const stat = fs.statSync(file);
    if (stat && stat.isDirectory() && path.basename(file).indexOf('_') !== 0) {
      /* Recurse into a subdirectory */
      results = results.concat(walk(file));
    } else if (
      stat &&
      !stat.isDirectory() &&
      path.extname(file) === '.twig' &&
      path.basename(file).indexOf('_') !== 0
    ) {
      /* Is a file */
      results.push(file);
    }
  });
  return results;
}

//start looking in the main twig folder
const files = walk('./src/twig');

// generate html plugins to export
const htmlPlugins = files.map(
  file =>
    // Create new HTMLWebpackPlugin with options
    new HtmlWebpackPlugin({
      filename: file.replace('./src/twig/', '').replace('.twig', '.html'),
      template: path.resolve(__dirname, file),
      hash: true,
    })
);

This does a few things. First, it creates an array of files from a source directory, grabbing only twig files and excluding twig files that start with an underscore or that exist in a directory that starts with an underscore (that's generally how I store my twig base layouts and includes to make them easier to identify and to prevent from rendering them without a valid entry/route). Second, with the array of files it loops over each file and creates an HTMLWebpackPlugin instance with the source path and the destination path, so "./src/twig/bios/john-smith.twig" becomes "/bios/john-smith.html" in my output directory. 

With that in place the only remaining thing to do is add the array of htmlPlugins to the webpack configuration plugins.


  plugins: [
    ... various webpack plugins 
  ].concat(htmlPlugins),

You can grab the entire webpack config file from this gist.

Hopefully you find this a useful approach to incorporate into your web development workflow. For anyone doing Craft development this could be super helpful.