Ulzurrun de Asanza i Sàez

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.

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.

A Twenty Twenty child theme with SASS stylesheet and HMR, and early preview of what we will achieve by the end of the post.

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:

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:

  1. 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 like style.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.
  2. 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.

A Twenty Twenty child theme with SASS stylesheet and HMR.

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:

  1. Create a new bundle with ts/hello-world.ts.
  2. Copy the bundle build results to js/hello-world.js so we can ship a production version when running npm 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:

  1. 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.
  2. 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.

A Twenty Twenty child theme running a TypeScript script with HMR.

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:

  1. Load Vite client script.
  2. Add a package.json and dependencies (more details).
  3. (Optional) Writing your stylesheet using SASS.
  4. (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

There are no comments yet.

Leave a Reply

Your email address will not be published.

Required fields are marked *

Your avatar