Masuga Smile Doodle

Craft CMS and Laravel Mix

Masuga
Catherine K. Jul 7, 2021 Read time: 13 minute read
Craft CMS and Laravel Mix

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.

{
  "name": "laravel-mix-src-management",
  "version": "1.0.0",
  "devDependencies": {
    "axios": "0.21.1",
    "critical": "1.3.10",
    "foundation-sites": "6.6.3",
    "jquery": "3.6.0",
    "laravel-mix": "6.0.19",
    "lazysizes": "5.3.2",
    "magnific-popup": "1.1.0",
    "mapbox-gl": "2.2.0",
    "resolve-url-loader": "3.1.2",
    "sass": "1.32.11",
    "sass-loader": "11.0.1"
  },
  "scripts": {
    "development": "mix",
    "watch": "mix watch",
    "production": "mix --production"
  },
  "private": true,
  "dependencies": {
    "chokidar": "3.5.1",
    "webpack": "5.37.0"
  },
  "optionalDependencies": {
    "fsevents": "2.3.2"
  },
  "paths": {
    "src": {
      "base": "./public/",
      "node_modules": {
        "base": "./node_modules"
      },
      "js": [
        "src/js/libs/ls.unveilhooks.min.js",
        "src/js/libs/prism.js",
        "src/js/main.js"
      ],
      "components": [
        "src/js/components/component-name.js"
      ],
      "css": [
        "src/css/main.scss"
      ]
    },
    "build": {
      "base": "./public/assets/build",
      "css": "./public/assets/build/css",
      "js": "./public/assets/build/js/",
      "images": "./public/assets/build/images/vendor",
      "fonts": "./public/assets/build/fonts/vendor"
    },
    "dist": {
      "base": "./public/assets/dist",
      "css": "./public/assets/dist/css",
      "js": "./public/assets/dist/js",
      "images": "./public/assets/dist/images/vendor",
      "fonts": "./public/assets/dist/fonts/vendor",
      "critical": "/public/assets/dist/critical"
    }
  },
  "urls": {
    "live": "https://www.website.com",
    "dev": "https://dev.website.com"
  },
  "critical": [
    {
      "url": "/",
      "template": "index"
    },
    {
      "url": "/example",
      "template": "/example/_entry"
    }
  ]
}
Dependencies: Dev, Optional, and Otherwise

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:

<head>
  <!-- Other stuff -->
  <!-- Critical CSS -->
  {{ craft.twigpack.includeCriticalCssTags() }}
  {{ craft.twigpack.includeCssModule("/css/main.css", true) }}
</head>

The Result

Here's the resulting directory structure, in screenshot form.

Directory structure generated by Laravel Mix

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.

Pre-webpack web vital score for gomasuga.com

Now, we're getting a 71! That's much better.

Post-webpack web vital score for gomasuga.com

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!

Gray Masuga Texture Gray Masuga Texture

You Might Also Like

How to Fix an Empty H1 Tag on Your Shopify Homepage

How to Fix an Empty H1 Tag on Your Shopify Homepage

Ryan Masuga Aug 15, 2022  ·  5 minute read

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.

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.

No spam. Unsubscribe any time.

Decorative Masuga Border