How to write WordPress Themes with SASS, TypeScript, and HMR
Not interested in all the details? I shared a twentytwenty child theme with SASS, TypeScript, and HMR applying all the ideas in this post that you can use as a starting point.
Modern front-end developer experience is nicer than the one we had in 2007 when WordPress came out.
- CSS preprocessors help you reduce boilerplate and reuse mixins.
- TypeScript minimizes runtime errors with its type-checking and IDE auto-completion.
- The latest syntactic sugar from ECMAScript drafts makes your code simpler.
- Partial content refresh when files change with HMR enables a seamless experience.
Regular WordPress themes require reloading the entire page when any JavaScript or CSS changes. However, with tools like Vite we can achieve a state-of-the-art developer experience.
In this post I will guide you through all the steps required to add SASS, TypeScript, and HMR support to any WordPress theme.
Hot module replacement
If you are familiar with HMR fundamentals, just skip to the code.
HMR is based on browser-server collaboration. First, the browser loads an initial version of all the files. After that, it opens a bidirectional connection to the server (typically using a WebSocket).
The server uses the bidirectional connection to notify the browser when any file changes. Then, the browser replaces the old script/link tag in the DOM with a new tag that loads again the affected file. To force reloading the asset, the browser appends a timestamp to the URL of file.
Some assets may not be directly usable in the browser. For instance, SASS files must be compiled into CSS, TypeScript files into JavaScript, and some modern ECMAScript features might need some transformations to be compatible with the stable version of the browsers.
Vite solves these problems using a WebSocket interface for notifying file changes, a client script that handles the tag updates, and a dev server to provide the client script and the browser-ready assets.
Note that the Vite dev server and HMR are used only during development. When the theme is shipped to our public-facing WordPress site we must just load the built CSS and JS files.
Using Vite in WordPress
We will be working on a new file named hmr.php
. The code can be added directly to your theme functions.php
but keeping it in a different file will make things cleaner. To load hmr.php
in your theme functions.php
we can use require_once
and get_theme_file_path
:
require_once get_theme_file_path('/hmr.php');
Code language: PHP (php)
Adding Vite dev server helpers
The first thing to add to hmr.php
are a couple of helper functions: one to get the Vite dev server URL and the other to check whether HMR should be used or not. We can define the Vite dev server URL in our wp-config.php
file, right after the <?php
opening tag at beginning of the file:
define('VITE_DEV_SERVER_URL', 'http://localhost:1337');
Code language: PHP (php)
Running your WordPress installation in Docker? Then you might be running the Vite dev server in a different container or in your host machine. You can set the Vite dev server URL in an environment variable instead of a PHP constant so you can customize it when starting the container.
Now we can create the two helpers in hmr.php
:
getViteDevServerAddress
to return the Vite dev server URL.-
isViteHMRAvailable
to check that the Vite dev server URL is defined.
function getViteDevServerAddress()
{
return VITE_DEV_SERVER_URL;
}
function isViteHMRAvailable()
{
return !empty(getViteDevServerAddress());
}
Code language: PHP (php)
Loading Vite client script
Vite client script is an ECMAScript module. ECMAScript modules can be loaded in most browsers but they need an additional type="module"
attribute added to the script
tag, which WordPress doesn’t add. We will need a helper function to simplify adding this attribute to our scripts. Let’s open hmr.php
and add a loadJSScriptAsESModule
helper:
/**
* Loads given script as a EcmaScript module.
*
* @param string $script_handle Name of the script to load as
* module.
*
* @return void
*/
function loadJSScriptAsESModule($script_handle)
{
add_filter(
'script_loader_tag',
function ($tag, $handle, $src) use ($script_handle) {
if ($script_handle === $handle) {
return sprintf(
'<script type="module" src="%s"></script>',
esc_url($src)
);
}
return $tag;
}, 10, 3
);
}
Code language: PHP (php)
Now we can load the Vite client script from the dev server when available. We should add to hmr.php
:
const VITE_HMR_CLIENT_HANDLE = 'vite-client';
if (isViteHMRAvailable()) {
wp_enqueue_script(
VITE_HMR_CLIENT_HANDLE,
getViteDevServerAddress().'/@vite/client',
array(), // This script has no dependencies
null // WordPress appends a version queryString to the URL when this value is not null
);
loadJSScriptAsESModule(VITE_HMR_CLIENT_HANDLE);
}
Code language: PHP (php)
It’s important to add null
as script version. WordPress appends the version number as a queryString parameter to script URLs and the Vite dev server doesn’t return the client script if when requesting it with additional queryString parameters.
The dev server is not available
Load your site right now and you will realize that the browser tries to load the /@vite/client
file but fails. That’s because we haven’t started the Vite dev server yet, so let’s set it up and start it afterward.
Creating a package.json
Vite is distributed as an NPM package so be sure you have NPM installed in your system.
In the NPM ecosystem, the recommended way to install dependencies is by defining them in a package.json
file. This file can live in your theme’s folder.
NPM will download the dependencies to a node_modules
folder that it will create next to the package.json
file. You won’t need those dependencies in your production environment so don’t forget to delete the node_modules
folder before shipping your theme to the public.
Our initial package.json
will look like this:
{
"name": "wordpress-theme",
"version": "1.0.0",
"private": true,
"license": "UNLICENSED",
"scripts": {
"start": "vite build && vite dev",
"build": "vite build"
},
"devDependencies": {
"vite": "^4.0.3"
}
}
Code language: JSON / JSON with Comments (json)
This file has 2 important sections:
scripts
section allows us to group some commands into scripts that we will use later on. We define two:start
will build our scripts for production (you need some production assets likestyle.css
in order to activate the new theme in WordPress, even if you want to run it in development mode) and start the Vite dev server.build
will build our scripts for production.
devDependencies
section allows us to define dependencies that are required during development. We only have one dependency:vite
.
Install the dependencies now by running: npm install
.
Setting up Vite
Vite setup lives in a file named vite.config.ts
, next to the package.json
file we just created. It’s a regular TypeScript file that must have a default export with the Vite settings. We can use the defineConfig
function that Vite offers to add type-checking support to our config object, spotting typos and other errors. A basic vite.config.ts
file with HMR looks like this:
import { defineConfig } from 'vite';
export default defineConfig({
server: {
port: 1337,
host: '0.0.0.0',
},
});
Code language: TypeScript (typescript)
In line 5 we define the port the Vite dev server listens to – 1337
– and in line 6 we allow connections from any host (useful if you run your WordPress site in a Docker container).
The dev server is not ready yet
If you start the Vite dev server right now it will fail because it can’t find an index.html
file. Vite looks for an index.html
file by default and serves any imported script. We can’t use an index.html
file as the entry point because that’s not how WordPress themes work. Instead, we will provide the list of files to build and serve. The first one is our theme style.css
, which we will write using SASS.
Writing style.css
using SASS
Create a new folder for our SASS files, sass
. Then create there a new SASS file to hold our styles, sass/style.scss
. To keep things simple, style.scss
will be quite short:
/*!
Theme Name: YOUR THEME
Theme URI: https://your-theme-url.com
Description: A blank WordPress Theme with SASS, TypeScript and Hot Module Replacement (HMR) support
Author: YOUR NAME
Version: 1.0
*/
$background-color: red;
body {
background: $background-color;
}
Code language: SCSS (scss)
Vite doesn’t support stylesheets as input files, so we have to import the stylesheet in a TypeScript file (which Vite does support) and then tweak our Vite config a bit so it automatically copies the output CSS file to the right folder.
We need an additional file, sass/style.ts
, to import sass/style.scss
. It only has to import sass/style.scss
, so this one-liner is enough:
import './style.scss';
Code language: TypeScript (typescript)
Vite can’t build SASS files by default so we must update our package.json
to install an additional dependency to build our SASS files.
Add the following code inside the devDependencies
section, right before line 11, declaring Vite as a dependency: "sass": "^1.56.1",
. package.json
will look like this:
{
"name": "wordpress-theme",
"version": "1.0.0",
"private": true,
"license": "UNLICENSED",
"scripts": {
"start": "vite build && vite dev",
"build": "vite build"
},
"devDependencies": {
"sass": "^1.56.1",
"vite": "^4.0.3"
}
}
Code language: JSON / JSON with Comments (json)
Don’t forget to install the new dependency running npm install
.
Loading stylesheet from Vite dev server
Now we will update our hmr.php
file so our theme loads the new SASS file instead of the original style.css
, but only when the Vite dev server is available. To achieve this we will use the add_filter
WordPress function to customize the stylesheet URI and directory URI.
if (isViteHMRAvailable()) {
add_filter(
'stylesheet_uri', function () {
return getViteDevServerAddress().'/sass/style.scss';
}
);
add_filter(
'stylesheet_directory_uri', function () {
return getViteDevServerAddress().'/sass';
}
);
}
Code language: PHP (php)
A working SCSS stylesheet with HMR
Start the Vite dev server using npm start
now and go to your site. The background color will be red. Modify the style.scss
file. Notice how the site applies the latest changes without any kind of reload. Sweet.
Generating production assets
So far we got the SASS files and the HMR working but we are still missing a piece: building the final style.css
file for production usage. This step requires a bit of coding, too, since by default we cannot really customize the destination path of the internal assets that Vite generates.
We can customize the output path of the input files we provide Vite. However, in our config we are passing a style.ts
proxy file as Vite doesn’t support the actual style.scss
as input. This forces us to write a Vite plugin that will copy the style.scss
build result to the right path.
A Vite plugin to copy build results
Let’s open vite.config.ts
and write a small CopyFile
function. This function will take 2 parameters: the source file name and the destination path and it will return a new Vite plugin. The plugin will copy the source file to the destination.
import fs from 'fs';
import { resolve as resolvePath, dirname } from 'path';
import { Plugin } from 'vite';
const CopyFile = ({
sourceFileName,
absolutePathToDestination,
}: {
sourceFileName: string;
absolutePathToDestination: string;
}): Plugin => ({
name: 'copy-file-plugin',
writeBundle: async (options, bundle) => {
const fileToCopy = Object.values(bundle).find(({ name }) => name === sourceFileName);
if (!fileToCopy) {
return;
}
const sourcePath = resolvePath(options.dir, fileToCopy.fileName);
await fs.promises.mkdir(dirname(absolutePathToDestination), {
recursive: true,
});
await fs.promises.copyFile(sourcePath, absolutePathToDestination);
},
});
Code language: TypeScript (typescript)
Next, we can use the plugin in the defineConfig
call.
Copying files with our Vite plugin
Vite has a plugins
option where we can provide an array of plugins that it will use. Let’s update the defineConfig
call to pass a new instance of our copy-file-plugin
plugin configured to copy the style.css
file to the right destination:
const __dirname = new URL('.', import.meta.url).pathname;
export default defineConfig({
plugins: [
CopyFile({
sourceFileName: 'style.css',
absolutePathToDestination: resolvePath(__dirname, './style.css'),
}),
],
build: {
target: 'modules',
outDir: '.vite-dist',
rollupOptions: {
input: {
'stylesheet': './sass/style.ts',
},
},
},
server: {
port: 1337,
host: '0.0.0.0',
},
});
Code language: TypeScript (typescript)
Note that we are also setting an explicit bundle in rollupOptions
. The value of the key we use – stylesheet
– is not important but the name of the entry point file will affect the source file in line 6: we use style.css
as the source file name in line 6 because we are using the style.ts
proxy file as the entry point for the bundle in line 15.
When Vite builds a file that imports assets that should not be inlined in the resulting JavaScript code (like CSS code), it creates new assets using the entry point file name as an internal name. Eventually, Vite writes those assets using the internal name as a prefix.
Our copy-file-plugin
runs right after Vite writes all the assets and compares each asset’s internal name with the sourceFileName
, copying only those files that match.
For our theme’s stylesheet, Vite generates a file with an internal name style.css
because it reuses the name of the entry point, style.ts
, but uses the right extension for the file MIME type, css
.
Building the theme with Vite
Build the theme running npm run build
. If you run the theme in production mode you will see the right styles. Make any changes to style.scss
. To see the result you will need to build the theme again with npm run build
and reload the page. Old school development, right? Let’s enable development mode again by undoing the changes in wp-config.php
and let’s add some TypeScript to our theme.
Using TypeScript in a WordPress theme
Using TypeScript in a Gutenberg block is possible but trickier and needs some additional boilerplate to get access to the right types and support multiple blocks without global namespace pollution. I will explain how in a future post.
Let’s write some TypeScript for our theme. We will create an example script that will append an item to the WordPress admin bar if it’s visible or log a warning otherwise.
Writing the TypeScript file
Create a new ts
folder for all our TypeScript sources and a ts/hello-world.ts
file with the following content:
const appendDebugEntry = (config: {
adminBarSelector: string;
debugEntryContainerClasses: Array<string>;
debugEntryContainerTagName: string;
debugEntryTagName: string;
debugEntryText: string;
}): void => {
const adminBar = document.querySelector(config.adminBarSelector);
if (!adminBar) {
console.warn('WordPress admin bar is disabled so no debug item has been added');
return;
}
const debugEntryContainer = document.createElement(config.debugEntryContainerTagName);
debugEntryContainer.classList.add(...config.debugEntryContainerClasses);
const debugEntry = document.createElement(config.debugEntryTagName);
debugEntry.textContent = config.debugEntryText;
debugEntryContainer.appendChild(debugEntry);
adminBar.appendChild(debugEntryContainer);
};
appendDebugEntry({
adminBarSelector: '#wpadminbar',
debugEntryTagName: 'li',
debugEntryContainerClasses: ['ab-top-secondary', 'ab-top-menu'],
debugEntryContainerTagName: 'ul',
debugEntryText: 'Written from TypeScript',
});
Code language: TypeScript (typescript)
Don’t mind the unnecessary complexity of this script: the goal is to show that TypeScript code is transpiled and a browser-working version shipped to the users.
Next, we have to update our Vite config to build the new file.
Adding the TypeScript file to our Vite config
We must update our vite.config.ts
file to:
- Create a new bundle with
ts/hello-world.ts
. - Copy the bundle build results to
js/hello-world.js
so we can ship a production version when runningnpm run build
.
Specifically, we must add the highlighted lines (7-10 and 18).
export default defineConfig({
plugins: [
CopyFilePlugin({
sourceFileName: 'style.css',
absolutePathToDestination: resolvePath(__dirname, './style.css'),
}),
CopyFilePlugin({
sourceFileName: 'helloWorld',
absolutePathToDestination: resolvePath(__dirname, './js/hello-world.js'),
}),
],
build: {
target: 'modules',
outDir: '.vite-dist',
rollupOptions: {
input: {
stylesheet: './sass/style.ts',
helloWorld: './ts/hello-world.ts',
},
},
},
server: {
port: 1337,
host: '0.0.0.0',
},
});
Code language: TypeScript (typescript)
After this, we must update our theme so it enqueues the new TypeScript file.
Enqueueing TypeScript in our WordPress theme
Then we can enqueue the script in our functions.php
file. We have to take into account that there are 2 different scripts to be enqueued:
- We want to load the TypeScript source we wrote during development. To do so we request the file from the Vite dev server so it gets transpiled into something our browser can run.
- We want to load the production-ready JavaScript output when the theme is running in production. Vite generates this file when running
npm run build
.
We will add an action to wp_enqueue_scripts
hook and use wp_enqueue_script
to load our script right after we require hmr.php
in our functions.php
. It’s important to require hmr.php
before because it defines isViteHMRAvailable
and getViteDevServerAddress
that we need to decide which file to load.
add_action(
'wp_enqueue_scripts', function () {
$handle = 'hello-world';
$dependencies = array();
$version = null;
if (isViteHMRAvailable()) {
loadJSScriptAsESModule($handle);
wp_enqueue_script(
$handle,
getViteDevServerAddress() . '/ts/hello-world.ts',
$dependencies,
$version
);
} else {
wp_enqueue_script(
$handle,
get_stylesheet_directory_uri() . '/js/hello-world.js',
$dependencies,
$version
);
}
}
);
Code language: PHP (php)
A working TypeScript file with HMR
You can load your site now. If the WordPress admin bar is enabled you will notice a new entry on the trailing side that says «Written from TypeScript». You will see a warning logged in the console otherwise. Make changes to hello-world.ts
and you will notice the page reloads automatically to apply the changes.
Summary and downloads
You can add SASS, TypeScript, and HMR to any WordPress theme, no matter how old it is. You must do some changes to support modern frontend tooling:
- Load Vite client script.
- Add a
package.json
and dependencies (more details). - (Optional) Writing your stylesheet using SASS.
- (Optional) Using TypeScript code in your theme.
As a starting point, I shared a twentytwenty child theme with SASS, TypeScript, and HMR, following the steps in this post.
Enjoy a better developer experience with your next WordPress theme and let me know what you think in the comments!
No replies on “How to write WordPress Themes with SASS, TypeScript, and HMR”