Webpack 4 WordPress

Renato Ruškan
Q agency
Published in
14 min readNov 12, 2018

Webpack is an awesome tool to improve front-end development workflow, but, sometimes, it can be tricky to set it up for a certain project type. How about connecting it to the most famous CMS in the world — WordPress? That’s what this article is all about — using Webpack to create a WordPress theme.

The goal is to support the following:

  • modern JavaScript
  • Sass as a CSS preprocessor
  • browser support
  • development server with an automatic browser reloading
  • source maps
  • minimizing and uglifying code for production
  • easy file handling (copying)

Note: This article will explain how to use certain Webpack loaders and plugins with WordPress rather than explaining how they work. If you’re interested in that, you can read their respective documentations.

Requirements, conventions and structure

WordPress themes are located in <wordpress-root>/wp-content/themes folder. Each theme has it’s own folder. For the purpose of this article, our theme name will be kyu. WordPress theme structure looks something like this (with more or less files):

/* File structure of wordpress/wp-content/themes/kyu */
|- footer.php
|- functions.php
|- header.php
|- index.php
|- page.php
|- ...
|- style.css

We’ll put all our built assets into assets folder in their respective folders:

/* File structure of wordpress/wp-content/themes/kyu */
+- assets
+- fonts
+- images
+- scripts
+- styles
|- footer.php
|- functions.php
|- ...
|- style.css

Note: assets folder will be automatically created by Webpack.

All source and Webpack files will be in the resources folder. Source files will live in resources/assets folder and Webpack (compiler) files will live in resources/compiler folder:

/* File structure of wordpress/wp-content/themes/kyu */
+- assets
+- resources
+- assets
+- compiler
|- footer.php
|- ...
|- style.css

Note: You can name your files and folders whatever you want.

You’ll need Node 8.5.0 or greater. We’ll be using Webpack 4. As a package manager, we’ll use Yarn, but you can use npm as well.

Note: In the following text, root means resources folder, where package.json is. Theme root means kyu folder.

Getting started

If you haven’t already, create resources folder in the theme root and position yourself in it within a terminal. To initialize a new project, run:

yarn init

or

npm init

This will guide you through the creation of the package.json file (type whatever you want as answers). Once you have your package.json, we’ll need to install the needed dependencies. Here’s a complete list:

@babel/core
@babel/preset-env
autoprefixer
babel-loader
browser-sync
cache-loader
clean-webpack-plugin
copy-webpack-plugin
css-loader
css-mqpacker
cssnano
file-loader
friendly-errors-webpack-plugin
mini-css-extract-plugin
minimatch
node-sass
postcss-loader
postcss-preset-env
querystring
resolve-url-loader
sass-loader
style-loader
webpack
webpack-dev-middleware
webpack-hot-middleware

To install them as devDependencies, run:

yarn add --dev @babel/core @babel/preset-env autoprefixer babel-loader browser-sync cache-loader clean-webpack-plugin copy-webpack-plugin css-loader css-mqpacker cssnano file-loader friendly-errors-webpack-plugin mini-css-extract-plugin minimatch node-sass postcss-loader postcss-preset-env querystring resolve-url-loader sass-loader style-loader webpack webpack-dev-middleware webpack-hot-middleware

or

npm install --save-dev @babel/core @babel/preset-env autoprefixer babel-loader browser-sync cache-loader clean-webpack-plugin copy-webpack-plugin css-loader css-mqpacker cssnano file-loader friendly-errors-webpack-plugin mini-css-extract-plugin minimatch node-sass postcss-loader postcss-preset-env querystring resolve-url-loader sass-loader style-loader webpack webpack-dev-middleware webpack-hot-middleware

Configuring package.json

After all dependencies have been installed, we need to add some things to package.json.

We’ll run our Webpack in two different modes — development and production. Development mode will run from serve.js and production mode from build.js in compiler folder. Let’s add scripts to package.json to run those files:

"scripts": {
"serve": "node compiler/serve",
"start": "node compiler/serve",
"build": "node compiler/build"
}

Note: start is there only as an alias for serve , since some people prefer it more.

Now, when we run yarn serve or npm run serve, Node will execute serve.js file in compiler folder. The same goes for build.js.

We want to support as many browsers as possible. Since we’re using autoprefixer, we need to configure the browsers it’s gonna use. We’ll add the following overkill list to the package.json:

"browserlist": [
"> 0.5%",
"last 2 versions",
"Firefox ESR",
"android 4",
"opera 12",
"ie >= 10",
"ie_mob >= 10",
"ff >= 30",
"chrome >= 34",
"safari >= 7",
"opera >= 23",
"ios >= 7",
"android >= 4.4",
"bb >= 10",
"not dead"
]

That’s all for the package.json.

Babel and PostCSS configurations

As you may have already noticed, we’re using Babel to transpile our modern JavaScript code to a code most browsers understand. To make Babel work, we need to create it’s configuration file — .babelrc . Create it in the root. Once you’ve created it, add the following:

{
"presets": ["@babel/preset-env"]
}

This means that Babel will use its preset-env for transpiling.

We also need to configure PostCSS, which we’re using to optimize our CSS code. You can read about PostCSS loader for Webpack here. In the root, create postcss.config.js file and add the following:

module.exports = ({ options }) => {
plugins: {
'autoprefixer': {},
'postcss-preset-env': {},
'css-mqpacker': {},
'cssnano': options.dev ? false : {
preset: ['default', {
discardComments: { removeAll: true }
}]
}
}
});

This may seem a lot, but it isn’t. We’re just telling PostCSS which plugins to use and which configuration to use for each plugin. You can read more about the plugins in their documentations. Actual configuration is given only to cssnano , since we want to discard the comments in production CSS. We’re exporting the configuration as a Node module because we want to be able to modify it according to our Webpack configuration. If you want to remove/add some plugins, feel free to do so. options.dev is a flag we’ll provide to denote if we’re in development mode. If we are, we don’t want to minify our CSS with cssnano — so we put it to false as to “don’t use this plugin”.

At this moment, your resources folder should look like this:

+- node_modules
|- .babelrc
|- package.json
|- postcss.config.js
|- yarn.lock # or package-lock.json if you use npm

Creating Webpack compiler helper files

Before creating Webpack configuration file, we’ll create some helper (utility) files to use. Now is the time to create compiler folder and position yourself in it.

publicPath.js

When WordPress resolves it’s URLs and paths, it resolves them absolutely, from the page root. So, if we want to load an image from the kyu theme, we’ll need to use the following path:

/wp-content/themes/kyu/assets/images/some-image.jpg

If your WordPress lives in a subfolder on your domain, for example http://my-website.com/wp , the path would need to include wp as well at the beginning of the URL.

/wp/wp-content/themes/kyu/assets/images/some-image.jpg

So let’s create a file called publicPath.js in the compiler folder. The first thing we need to do is add Node’s path module:

const path = require('path');

Next, we’ll export our function as Node module so we can use it. Function will take two parameters — folder, to which we are bundling our files (in our case assets) and prefix, if we have our WordPress site in a subfolder. prefix will default to an empty string.

const path = require('path');module.exports = (folder, prefix = '') => {
}

Our theme name can have any name, so we want to get the name of the folder in which our theme lives. We’ll use help from the Node’s path module:

const path = require('path');module.exports = (folder, prefix = '') => {
// In our case, theme will equal to 'kyu'.
const theme = path.basename(path.resolve('../'));
}

path.resolve returns absolute path from where the process is started (usually where package.json is) — in our case, an absolute path to resources folder. Since our built assets will live one level up in the assets folder, we add ../ . After that, we get the basename of that path, a.k.a the folder name.

The last thing we need to do is to return the full public path:

return `${prefix}/wp-content/themes/${theme}/${folder}/`;

Full source code of publicPath.js is:

const path = require('path');module.exports = (folder, prefix = '') => {
const theme = path.basename(path.resolve('../'));
return `${prefix}/wp-content/themes/${theme}/${folder}/`;
}

HMR (Hot Module Replacement)

HMR is an awesome Webpack feature which allows you to inject CSS/JS changes into website without doing a full reload. We’ll be using it only for CSS because JS injection is usually used with frameworks and requires additional code in files. You can read more about it here. Also, it will work automatically through Webpack Dev and Hot Middlewares.

Let’s start by creating an hmr folder inside compiler folder. Inside, create two files — index.js and overlay.json. index.js will be the main file which we’ll use in Webpack config. overlay.json will contain styles for error overlay in the browser used by Friendly Errors Webpack Plugin. You can style it whatever you want. Here’s the overlay.json we’ll be using:

{
"background": "rgba(0, 0, 0, 0.9)",
"boxSizing": "border-box",
"fontFamily": "Menlo, Consolas, monospace",
"fontSize": "large",
"height": "100vh",
"lineHeight": "1.2",
"padding": "2rem",
"whiteSpace": "pre-wrap",
"width": "100vw"
}

Now let’s create index.js. First, we’ll include querystring library for URL query parameters parsing used by HMR client link and our overlay styles:

const querystring = require('querystring');
const overlayStyles = require('./overlay');

Our HMR module will have only one function — getClient(), which will return client URL for Webpack Hot Middleware. More about it and it’s settings here.

module.exports = {
getClient() {
const host = 'webpack-hot-middleware/client?';
const query = querystring.stringify({
path: '/__webpack_hmr',
timeout: 20000,
reload: true,
overlay: true,
noInfo: true,
overlayStyles: JSON.stringify(overlayStyles)
});

return `${host}${query}`;
}
}

Full source code of hmr/index.js is:

const querystring = require('querystring');
const overlayStyles = require('./overlay');
module.exports = {
getClient() {
const host = 'webpack-hot-middleware/client?';
const query = querystring.stringify({
path: '/__webpack_hmr',
timeout: 20000,
reload: true,
overlay: true,
noInfo: true,
overlayStyles: JSON.stringify(overlayStyles)
});

return `${host}${query}`;
}
}

Custom plugin — cleaning production files

When Webpack uses an entry point which is not a .js file, it will also create a .js file associated with it. It’s good for development use (because middlewares need to know how to handle non-js files, so they read those .js files) but for production, they are unnecessary. There’s already a similar plugin, but we’ll write our own which will give us some flexibility. Let’s call it Non JS Entry Cleanup Plugin.

We’ll create a folder called non-js-entry-cleanup-plugin inside compiler folder and create index.js file inside it.

In summary, what this plugin will do is disable emitting of those unnecessary .js files. We’ll provide it with settings later in the Webpack config. Here’s a full source code of non-js-entry-cleanup-plugin/index.js:

const path = require('path');
const minimatch = require('minimatch');
module.exports = class {
constructor(options) {
this.options = options;
}
apply(compiler) {
const { context, extension, includeSubfolders } = this.options;
compiler.hooks.emit.tapAsync('NonJsEntryCleanupPlugin', (compilation, callback) => {
const pattern = path.join(context, `${includeSubfolders ? '**/' : ''}*.${extension}`);
Object.keys(compilation.assets).filter(asset => minimatch(asset, pattern)).forEach(asset => delete compilation.assets[asset]); callback();
});
}
}

So what we’re doing is before Webpack emits files, we check the folders we provided for the extensions we provided and remove all the files that match the criteria.

Simple config file

To have some liberty with our basic configuration, we’re gonna create a simple config file config.js inside compiler folder. It will have a simple, basic configuration for our compiler:

module.exports = {
context: 'assets',
entry: {
styles: './styles/main.scss',
scripts: './scripts/main.js',
},
devtool: 'cheap-module-eval-source-map',
outputFolder: '../assets',
publicFolder: 'assets',
proxyTarget: 'http://wp4wp.loc',
watch: [
'../**/*.php'
]
}

context — root of all our source assets. In our case it’s resources/assets, but, since our root is actually resources folder, we put only assets.

entry — entries we’re gonna use with Webpack — our main files that will be built.

devtool — Webpack devtool for source maps.

outputFolder — folder in which we will build our files (one level up assets folder in our theme)

publicFolder — folder name we’ll use with publicPath.

proxyTarget — target where your WordPress site is and which we’ll proxy using BrowserSync.

watch — files which will be watched by BrowserSync to trigger an autoreload. We want to watch all our theme .php files.

Writing webpack.config.js

Now comes the fun part. We prepared our helper files and now’s the time to write our webpack.config.js. Create webpack.config.js inside compiler folder.

The first thing we need to do is require all our configurations, helpers and plugins:

const path = require('path');
const webpack = require('webpack');
const MiniCssExtractWebpackPlugin = require('mini-css-extract-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const CleanWebpackPlugin = require('clean-webpack-plugin');
const NonJsEntryCleanupPlugin = require('./non-js-entry-cleanup-plugin');
const FriendlyErrorsWebpackPlugin = require('friendly-errors-webpack-plugin');
const { context, entry, devtool, outputFolder, publicFolder } = require('./config');const HMR = require('./hmr');
const getPublicPath = require('./publicPath');

What we basically did is that we imported all our created helpers and dependencies so webpack.config.js can use them. Since we have two modes, we’re gonna export Webpack config as a function which will return an actual config object. We’re doing it so we can modify it according to the mode. The only option we’ll provide is dev option, which will denote that we’re in development mode.

Let’s export our function and add two more variables to it:

module.exports = (options) => {
const { dev } = options;
const hmr = HMR.getClient();
return {
/* All keys and values are below. */
};
}

Following are the keys and values we’re gonna add to the returned object. We’ll also be using imported config variables, helpers and plugins.

mode

We’re gonna set Webpack mode depending on our dev variable:

mode: dev ? 'development' : 'production',

devtool

If we’re in development mode, we want to have source maps for easier debugging:

devtool: dev ? devtool : false,

context

Context must be an absolute path, so we’re gonna resolve it with our context folder we added to config.js.

context: path.resolve(context),

entry

We’re gonna have two main entries — one for styles and one for scripts. If we’re in development mode, we want to add our HMR client so we can watch for changes and do autoreload/HMR. We added scripts and styles to entry keys to make Webpack output files into those subfolders inside assets folder.

entry: {
'styles/main': dev ? [hmr, entry.styles] : entry.styles,
'scripts/main': dev ? [hmr, entry.scripts] : entry.scripts
},

output

We’ll use settings from our config.js file for output locations. [name].js means to use entry name as a filename, in our case, it will be scripts/main.js so it will put it inside scripts folder.

output: {
path: path.resolve(outputFolder),
publicPath: getPublicPath(publicFolder),
filename: '[name].js'
},

module: { rules }

Rules is where we’ll put all our loaders to handle various file types. For our purposes, we need to handle JavaScript files, Sass/CSS files and some common file types for images, fonts, videos, etc. You can always modify these, add more filetypes, add linters and so on. To learn more about loaders, check this link. To get more info about this great chunk of code, please refer to the documentation of each loader.

module: {
rules: [
{
test: /\.js$/,
exclude: /(node_modules|bower_components)/,
use: [
...(dev ? [{ loader: 'cache-loader' }] : []),
{ loader: 'babel-loader' }
]
},
{
test: /\.(sa|sc|c)ss$/,
use: [
...(dev ? [{ loader: 'cache-loader' }, { loader: 'style-loader, options: { sourceMap: dev } }] : [ MiniCssExtractWebpackPlugin.loader ]),
{ loader: 'css-loader', options: { sourceMap: dev } },
{ loader: 'postcss-loader', options: {
ident: 'postcss',
sourceMap: dev,
config: { ctx: { dev } }
} },
{ loader: 'resolve-url-loader', options: { sourceMap: dev } },
{ loader: 'sass-loader', options: { sourceMap: true, sourceMapContents: dev } }
]
},
{
test: /\.(ttf|otf|eot|woff2?|png|jpe?g|gif|svg|ico|mp4|webm)$/,
use: [
{
loader: 'file-loader',
options: {
name: '[path][name].[ext]',
}
}
]
},
]
},

plugins

Plugins are those who modify our files before compiling/emitting. We’ll use them to handle HMR, write pretty errors, extract CSS from JS bundled files, clean production folders and copy our files. Most of the plugins are used for production.

plugins: [
...(dev ? [
new webpack.HotModuleReplacementPlugin(),
new FriendlyErrorsWebpackPlugin()
] : [
new MiniCssExtractWebpackPlugin({
filename: '[name].css'
}),
new NonJsEntryCleanupPlugin({
context: 'styles',
extesion: 'js',
includeSubfolders: true
}),
new CopyWebpackPlugin([
path.resolve(outputFolder)
], {
allowExternal: true,
beforeEmit: true
}),
new CopyWebpackPlugin([
{
from: path.resolve(`${context}/**/*`),
to: path.resolve(outputFolder),
}
], {
ignore: ['*.js', '*.scss', '*.css']
})
])
]

Writing modes

We’re finally done with Webpack configuration — it was the hardest part. What’s left is to write development and production modes which will use that Webpack config.

serve.js

Let’s start with development mode. In compiler folder, create serve.js file. We’ll start by importing all necessary files and configurations:

const path = require('path');
const webpack = require('webpack');
const browserSync = require('browser-sync').create();
const webpackDevMiddleware = require('webpack-dev-middleware');
const webpackHotMiddleware = require('webpack-hot-middleare');
const { publicFolder, proxyTarget, watch } = require('./config');
const webpackConfig = require('./webpack.config')({ dev: true });
const getPublicPath = require('./publicPath');

Now that we imported everything we need, let’s instantiate our compiler with the configuration we wrote:

const compiler = webpack(webpackConfg);

Next, let’s create middleware array containing Webpack middlewares which will be used by BrowserSync:

const middleware = [
webpackDevMidleware(compiler, {
publicPath: getPublicPath(publicFolder),
logLevel: 'silent',
quiet: true
},
webpackHotMiddleware(compiler, {
log: false,
logLevel: 'none'
}
]

Note: We turned off all logging because it will be automatically handled by Friendly Errors Plugin.

We can finally initialize BrowserSync. Remember those .js files we clean in production? Well, for BrowserSync and and Webpack Hot Middleware to understand how to hot-reload .css files, we need that generated .js file in styles folder. So, we’re gonna inject it automatically using BrowserSync with snippetOptions before closing </head> tag. Also, files we’re watching, we’re gonna use absolute paths.

browserSync.init({
middleware,
proxy: {
target: proxyTarget,
middleware
},
logLevel: 'silent',
files: watch.map(element => path.resolve(element)),
snippetOptions: {
rule: {
match: /<\/head>/i,
fn: function(snippet, match) {
return `<script src="${getPublicPath(publicFolder)}"></script>${snippet}${match}`;
}
}
}
});

build.js

This one is relatively easy. Create build.js file inside compiler folder. We only need to import Webpack and our configuration.

const webpack = require('webpack');
const config = require('./webpack.config')({ dev: false });

After that, we only need to run Webpack and handle possible errors. Done.

webpack(config, (err, stats) => {
if(err) {
console.error(err.stack || err);
if(err.details) {
console.error(err.details);
}

return;
}
if(stats.hasErrors()) {
console.error(stats.toString({
all: false,
colors: true,
errors: true
}));
console.log(); return;
}
if(stats.hasWarnings()) {
console.warn(stats.toString({
all: false,
colors: true,
errors: true
}));
}
console.log(stats.toString({
colors: true,
chunks: false,
modules: false,
entrypoints: false,
children: false
}));
console.log();
});

Creating assets and running project

We’re done with the compiler. Finally. The last thing that’s left to do is to create our entry points. Inside resources folder create assets folder and inside two folders — scripts and styles. Inside scripts create main.js file and inside styles create main.scss files. Those are two main files which will be run by Webpack. All other styles and scripts you’ll want to import into those two files. You can create and name other folders for other files as you wish.

Before we run our project, we need to add the files to WordPress theme inside functions.php file. You’ll want to use something like this:

function wp4wp_scripts() {
wp_enqueue_style('main_css', get_template_directory_uri() . '/assets/styles/main.css', array(), '1.0', false);
wp_enqueue_script('main_js', get_template_directory_uri() . '/assets/scripts/main.js', array(), '1.0', true);
}
add_action('wp_enqueue_scripts', 'wp4wp_scripts');

If we run

yarn serve

or

npm run serve

a development server should start proxying your targeted website/vhost on localhost:3000 or similar port. It should open automatically in the browser.

If we run

yarn build

or

npm run build

Webpack should output files into assets folder inside theme root.

Final file structure

This is the file structure you should have at this point:

+- assets - will be created automatically.
+- scripts
+- styles
+- resources
+- assets
+- scripts
|- main.js
+- styles
|- main.scss
+- compiler
+- hmr
|- index.js
|- overlay.json
+- non-js-entry-cleanup-plugin
|- index.js
|- build.js
|- config.js
|- publicPath.js
|- serve.js
|- webpack.config.js
+- node_modules
|- .babelrc
|- package.json
|- postcss.config.js
|- yarn.lock
|- footer.php
|- functions.php
|- ...
|- style.css

Final words

This is only one of the many ways to achieve this result. One of the things that could definitely improve this workflow are linters. The thing that could improve the compiler environement is modularity and configurability.

One of the best features this workflow provides is ability to deploy even to older servers — all you need to do is grab your theme files, assets folder and you’re good to go. You don’t have to upload resources folder, because all built files and all other assets will be automatically copied and optimized to assets folder when you run build script.

Feel free to use the following setup for other project types and modify/improve it as necessary.

--

--