Webpack 4 WordPress
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, wherepackage.json
is. Theme root meanskyu
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 forserve
, 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.