Catherine K.
Jul 7, 2021
Read time: 13 minute read
We updated our build process for Craft CMS websites from a difficult to maintain Gulp process to one that uses Laravel Mix, which is easier for us to maintain, and more easily allows us to do advanced optimization.
In a previous post from 2018, we detailed how we set up Gulp to minify, and then cache-bust, the assets for our Craft sites. This process worked, but it was something of a house of cards. Changes to one developer's local Node or npm version were liable to knock the whole thing down. Even package updates were dangerous. We had to spend a lot of time and effort to maintain that process, which resulted in creating new versions of it. In the end, we were left with several versions of the process scattered across our various sites. We had to look for a replacement. When we finally found it, we caught up with the times: webpack, as made accessible by Laravel Mix.
Webpack is a module bundler that preps JavaScript and other assets for the browser. Used by itself, there can be quite a learning curve. This i where Laravel Mix comes in. According to the Mix docs:
"...Mix is a thin layer on top of webpack for the rest of us. It exposes a simple, fluent API for dynamically constructing your webpack configuration. Mix targets the 80% usecase. If, for example, you only care about compiling modern JavaScript and triggering a CSS preprocessor, Mix should be right up your alley."
In this post, we'll skip over the harrowing hours of trial and error, the endless testing of various configurations, and the heartbreak of our failures. We'll jump straight to the triumph of our success, and show you the end result.
The Process
Our process uses a detailed package.json file for all the customizable aspects of the process and a (relatively) simple, static webpack file that ingests it. We got the inspiration for this setup from Andrew Welch's very helpful blog post, "A Better package.json for the Frontend."
The package.json File
This file contains the packages for the process, the scripts the run it, and the environment's files and paths. We have all the customizable parts of the process here so that we only have to change up one file when we drop this process into another site. I'll show the whole file first, then break it down by feature.
In devDependencies , we have the packages that are only required during development. In dependencies , we have the packages that are required during runtime. In optionalDependencies , we have the fsevents package. That's only needed on Mac environments, hence being optional .
Most of these packages are for our source styles and functionality, or compiling those into the desired form. The important ones for the process are:
Laravel Mix: a wrapper around Webpack to make it easy to do the common functions, like compiling and minifying assets.
Critical: extracts CSS for content appearing "above the fold" and inserts it inline. This process is also known as "Critical CSS."
Axios: the Webpack process uses Axios to make requests to the URLs specified in the critical object. From there, Critical figures out what styles are needed and generates them.
Scripts
These are the tasks that we'll run with Webpack. Note that the production task has the --production flag. This tells Webpack that we're running the task inProduction , so we'll do the "Production" version of the process. The Production version creates Critical CSS and versions and minifies the generated files. The non-Production version generates, minifies, and creates source maps for the source files.
Paths
This object contains a src object with the paths to our public directory and our node_modules directory.
It also contains the paths to all the source JavaScript files we want to condense into our main.js file. It also contains an array for any "components." In this case, "components" are discrete JavaScript functionalities for use on specific pages. We don't need them on every page, so there's reason to bundle them into main.js . Instead, we'll compile them separately and then reference the compiled files on the relevant pages.
This object also contains the SCSS file to condense into main.css .
Finally, this object defines all the paths for the build and dist generated files. The files generated by the Webpack process will be inserted at these paths, depending on which environment is specified in the task.
URLs and Critical
These are the url s for the live and development sites. We're only using the live site now, but it's always helpful to have a reference to the development site. The Critical CSS step of the Webpack process will combine the live URL and the various paths specified in critical . Then, it will hit the resulting URL via axios . Then, critical will comb through the axios response to figure out what styles to generate. Next, Twigpack Craft CMS plugin will take the critical url's template path and insert the resulting file there as inline styles.
The webpack.mix.js File
Because comments are easier in this file than in package.json , I'll leave the whole file below. It has lots of comments in context to make it easier to follow.
let mix = require('laravel-mix');
let criticalCSS = require('critical');
let axios = require('axios').default;
let process = require('process');
let sourcePackage = require('./package.json');
let criticalRoutes = sourcePackage.critical;
let root = process.cwd();
/* Production process differs from development
environment processes: production should
be minified, have no sourcemaps, and be
versioned for cache-busting. Production
also specifically copies vendor assets to
dist directory, while Development seems
to do this automatically. Production also
creates Critical CSS for all pages specified
in the "critical" package.json array.
Development process has sourcemaps, no
minification, and no versioning.
Both environments split the code into vendor
and vendor files for easier caching, convert
SCSS to CSS, and compile all src files.
*/
require('events').defaultMaxListeners = 15;
if (mix.inProduction()) {
mix.autoload({}); // Prevents Mix's automatic configuring of jQuery
mix.setPublicPath(sourcePackage.paths.dist.base) // This is where the mix-manifest.json file will go
.options({
processCssUrls: false // Don't process relative URLs in CSS, which causes vendor relative styles to look in node_modules folder
});
// If there are files in the "components" array, compile them separately
if (sourcePackage.paths.src.components.length > 0) {
sourcePackage.paths.src.components.forEach(file => mix.js(file, 'js/components'));
}
mix.extract() // Split the js into several files; allows for conditional loading and partial caching
.js(sourcePackage.paths.src.js, '/js/main.js'); // First arg is file to be operated upon, second is where result will go
mix.sass(sourcePackage.paths.src.css[0], '/css') // Compile the SCSS into CSS
.version(); // This attaches a query string to the filename, for cache-busting
// Now that everything's been created, versioned, and placed, let's make some Critical CSS!
mix.then(() => {
criticalRoutes.forEach((element) => {
// The url to hit to generate the CSS
let elementRoute = sourcePackage.urls.live + element.url;
// The filepath and name for the resulting Critical CSS file
let elementFilename = root + sourcePackage.paths.dist.critical + '/' + element.template +'_critical.css';
axios.get(elementRoute)
.then(function (response) {
criticalCSS.generate({
inline: false, // We'll be making a file, so this is false
src: elementRoute, // The url
css: [sourcePackage.paths.dist.css + '/main.css'], // The css file to load at that url
width: 1200, // Browser width
height: 1200, // Browser height
target: {
css: elementFilename // The output file path for the Critical file
},
extract: false, // Must be disabled
inlineImages: false, // Inline small images
assetPaths: [
root + sourcePackage.paths.dist.images,// Inline asset paths
],
ignore: ['@font-face',/url\(/] // Ignore rules
});
})
.catch(function (error) {
console.log(error);
})
.then(function () {
});
});
});
} else {
mix.autoload({});
mix.setPublicPath(sourcePackage.paths.build.base)
.webpackConfig({
devtool: 'inline-source-map'
}) // This works with sourceMaps() to create sourceMaps
.options({
processCssUrls: false
});
if (sourcePackage.paths.src.components.length > 0) {
sourcePackage.paths.src.components.forEach(file => mix.js(file, 'js/components'));
}
mix.extract()
.sourceMaps() // Add source maps
.js(sourcePackage.paths.src.js, '/js/main.js')
.sass(sourcePackage.paths.src.css[0], '/css');
}
Twigpack
At this point, we've taken all our source files, operated on them, and created output files at the desired locations. This is great! But the Webpack process created cache-busted versions of these files. We can't just reference them statically in the templates, or we'd be getting the wrong files. To fix that, we've installed the Twigpack plugin. This plugin gets the cache-busted version of the source files from the manifest json. Then, it uses a template tag to insert the file of that name. It also has configuration for Critical CSS, so that it can output inline CSS at a specified point. Here's an example of our /config/twigpack.php file:
<?php
return [
// Global settings
'*' => [
// If `devMode` is on, use webpack-dev-server to all for HMR (hot module reloading)
'useDevServer' => false,
// Enforce Absolute URLs on includes
'useAbsoluteUrl' => true,
// The JavaScript entry from the manifest.json to inject on Twig error pages
// This can be a string or an array of strings
'errorEntry' => '',
// String to be appended to the cache key
'cacheKeySuffix' => '',
// Manifest file names
'manifest' => [
'legacy' => 'mix-manifest.json',
'modern' => 'mix-manifest.json',
],
// Public server config
'server' => [
'manifestPath' => '/path/to/public/assets/dir',
'publicPath' => '/path/to/public/assets/dir',
],
// webpack-dev-server config
'devServer' => [
'manifestPath' => '/path/to/public/assets/dir',
'publicPath' => '/path/to/public/assets/dir',
],
// Bundle to use with the webpack-dev-server
'devServerBuildType' => 'modern',
// Whether to include a Content Security Policy "nonce" for inline
// CSS or JavaScript. Valid values are 'header' or 'tag' for how the CSP
// should be included. c.f.:
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/script-src#Unsafe_inline_script
'cspNonce' => '',
// Local files config
'localFiles' => [
'basePath' => '@webroot/',
'criticalPrefix' => "/assets/dist/critical/",
'criticalSuffix' => "_critical.css",
],
// If `devMode` is on, use webpack-dev-server to all for HMR (hot module reloading)
'useDevServer' => false,
]
];
And in our layout template, we use those Twigpack tags:
Here's the resulting directory structure, in screenshot form.
From the dev task, we have the build directory with our css and js subdirectories and a mix-manifest.json file. From the prod task, we have a dist directory those subdirectories and a critical subdirectory.
One important note is that we named our critical.template paths in package.json to match the path for the template for that URL. For example, a URL of /blog/slug has a template path of /blog/_entry because it's actual template path is /blog/_entry . Our Twigpack config file looks at the criticalPrefix and criticalSuffix variable. These combine with the template variable in package.json to make the whole path for the Critical CSS file: /assets/dist/critical/blog/_entry_critical.css . See all three combined pieces there? It took a while to figure out, but it works beautifully.
We now have Critical CSS in addition to our generated assets!
Before we implemented this Webpack process, we were getting a Lighthouse score of 62. Not great.
Now, we're getting a 71! That's much better.
We still have more to do, from streamlining our resources to changing up our frameworks, but we've already made significant progress. Keep an eye our blog for more updates as we work to improve that score!
We’ve discontinued CP Filters for Craft CMS 5. Lab Reports and Link Vault will be updated. We still use Craft, and want to focus on creating great websites for our clients.
Shopify's Dawn theme homepage may get an SEO warning for an empty h1 header element. This is a missed opportunity to help search engines understand what your page is about. Small edits to the theme code can fix this technical SEO issue.
Shopify's default Dawn theme displays empty alt tags for collection images. This post explains how to fix blank alt tags for Shopify collection images to improve your Shopify store's accessibility and SEO.
Subscribe to Our Newsletter
A few times a year we send out a newsletter with tips and info related to web design, SEO, and things you may find interesting.