Build file-based theme inheritance module in Nuxt

Written by Filip Rakowski

We are used to certain ways of reusing code. Usually it happens through libraries and components with strictly defined APIs but sometimes thats not enough. Sometimes we want to reuse our whole codebase in a way that lets us adjust it to a special event like Black Friday or just make a slightly different version of our website for a specific country. In cases like that, we usually have very different needs, and being limited by previously defined extension points could make such tasks painful or even impossible.

Being able to automatically inherit all “standard”, unchanged files from a single source of truth and only adding what we want to change when making a new version of our website could drastically reduce the amount of maintained code. In this article, I will show you how to make all of that possible in Nuxt.js in a relatively simple way.

Taking inspiration from Nuxt itself

What makes Nuxt awesome and unique are out of the box features it comes with like file-based routing or Server Side Rendering. Have you ever wondered why these features doesn’t require you to write any additional code?

Most of the libraries deliver it’s features by exposing a third-party code you need to include into your app. The library is exposing a set of functions and objects that are considered its public API:

import { multiply } from 'fancy-math-library'
multiply(2,2) // 4 

Nuxt works a little bit different though. It takes advantage of having full control over the build process to hide the underlying complexity from its users.

Under the hood Nuxt is building new files on the fly based on directories like pages, components, plugins, etc. along with app configuration from nuxt.config.js. They are generated in the .nuxt folder.

Thanks to this approach you don’t see and interact with a very complex code that is responsible for functionalities that are heavily utilized by framework internals like server-side rendering, routing etc. You can influence how they work only through abstractions like file names in page folder. Because of that Nuxt can output more optimized code and avoid unnecessary breaking changes.

This is what happens when you change a single line of code in one of your Nuxt project files:

  1. .nuxt folder is flushed
  2. files inside .nuxt folder are recreated
  3. webpack HMR updates the changed part on the UI

So Nuxt is doing its own transformations, enrichments and merging of your files before standard webpack build is taking care of them. We will use the same strategy to solve our theme inheritance task.

What we have to do?

To make our inheritance mechanism work we will need 3 folders

  • base theme - one that we will inherit from
  • new theme where we will put files that should override ones from base directory
  • target code which will be used as a final directory that merges new and base directories. It will be our equivalent of .nuxt directory.
 ---| node_modules/
 ---| nuxt.config.js
 ---| package.json
 ---| assets/
 ---| base/
 ---| components/
 ---| layouts/
 ---| middleware/
 ---| new/
 ---| pages/
 ---| target/
 ---| plugins/
 ---| static/

Now when you think for a moment the inheriting itself turns out to be pretty straightforward:

  1. First we have to copy files from base theme to target directory.
  2. Then we have to copy files from new theme to target and override the ones that has conflicting names.

…and thats it! That's all you need to have a simple inheritance in place.

Of course it’s a poor developer experience because we will need to run the copying script every time we change something but don’t worry - in a few minutes we will try to make it better!

For now - lets keeps things simple.

Creating local Nuxt module

We will separate our feature from the rest of the app by isolating it in a Nuxt Module.

Nuxt Modules are commonly used to add third-party functionality into your Nuxt application. They can add all kinds of features like authentication, build process extensions or internationalization.

A Nuxt module is essentially just a function that runs every time we start our app from the CLI but what makes it powerful is the ability to extend and alter the configuration and the entire build.

A bare minimum for a working Nuxt module is just a simple function declaration:

// modules/theme-inheritance.js

export default function (moduleOptions) {
  console.log('Hello World!')
}

If we register the module in nuxt.config.js and run yarn dev or npm run dev we should see Hello World! in the console which means that our module has been successfully registered

// nuxt.config.js
modules: [
  '~/modules/theme-inheritance'
],

TIP: You can transfer any data from your Nuxt app to the module by passing an array instead of a string to modules property. What you will pass will be accessible via moduleOptions inside your module. It’s a great way to make your module configurable.

modules: [
  [ '~/modules/theme-inheritance', { baseThemeDir: './custom-dir' } ]
],

Changing the root directory

By default Nuxt is using / directory as a source root. It means that it expect pages, components, plugins etc to be in the root directory of your app so we need to change where Nuxt is looking for these resources.

Learn Vue.js With Vue School

Nuxt modules have access to the configuration object of the project via this.options. Here we can change anything that can be putted in the nuxt.config.js.

To change the source root directory of your project you should change srcDir property.

// modules/theme-inheritance
const path = require('path')

export default function (moduleOptions) {
  this.options.srcDir = './target';
}

Now we need to move our resources into the base directory. If you’re wondering why we havn't moved our files to the target dir, remember that our module will automatically generate the files in this directory, so we should leave it empty.

 ---| node_modules/
 ---| nuxt.config.js
 ---| package.json
 ---| base/
 -----| assets/
 -----| components/
 -----| layouts/
 -----| middleware/
 -----| pages/
 -----| plugins/
 -----| static/
 ---| new/
 ---| target/

Nuxt will now look for pages, middlewares, plugins etc inside target folder instead of/.

Copying files

Next up, we need to copy files from base and new to the target directory. And also empty the target directory before we do so, to get rid of any deleted files.

To do both things we will use a great library fs-extra (one my favorite ones, really) which comes with a lot of useful file system related functions.

The ones that we will use are:

  • copy that copies a file or directory (recursively)
  • emptyDir that removes all files from a certain directory
yarn add fs-extra
const path = require('path')
const fse = require('fs-extra')

export default async function (moduleOptions) {
  const baseDir = path.join(this.options.rootDir, './base');
  const targetDir = path.join(this.options.rootDir, './target');
  const newDir = path.join(this.options.rootDir, './new');

  this.options.srcDir = './target';

  await fse.emptyDir(targetDir);
  await fse.copy(baseDir, targetDir)
  await fse.copy(newDir, targetDir)
}

Note: fse.copy is overriding conflicting files by default which is exactly what we need when copying them from new folder to target

The above 14 lines of code is basically a working theme inheritance mechanism but since it copies the files only when we start the dev/prod server it obviously needs to be improved and deliver better developer experience.

Watching file changes

It’s usually fairly easy to make some idea work but then it could take significantly more time to make it work in a way that won’t eventually drive other developers crazy. Despite an obviously high cost of better developer experience I strongly believe that such investment always pays back. Sometimes even literally if people decide to sponsor your work!

Thankfully in our case we can make our module more enjoyable to work with with just a few lines of code. An obvious killer of a good developer experience is the fact that we need to rerun the script every time we change or add a file in both base and new directories.

What I want to do, is to watch for file changes in our theme directories (base and new), and rerun our script when any changes are detected.

To watch file changes we will use chokidar which is a very popular library that is a wrapper over native fs.watch that simplifies a lot of things that would normally require much more code. Let see how we can use it!

const path = require('path')
const chokidar = require('chokidar');
const fse = require('fs-extra');

export default async function (moduleOptions) {
  const baseDir = path.join(this.options.rootDir, './base')
  const targetDir = path.join(this.options.rootDir, './target')
  const newDir = path.join(this.options.rootDir, './new')

  this.options.srcDir = './target';

  const copyFiles = async () => {
    await fse.emptyDir(targetDir);
    await fse.copy(baseDir, targetDir)
    await fse.copy(newDir, targetDir)
  }

  await copyFiles()

  chokidar.watch([baseDir, newDir]).on('all', async (event) => {
    if (event === 'add' || event === 'change' || event === 'unlink') {
      await copyFiles()
    }
  });
}

Let’s quickly review what happened here.

On line 20 we’re using chokidar.watch function to watch all changes in base and new directories. This function reacts to all possible file-related events but we need to take actions in case of only 3 of them (unlink means deletion) so we’re filtering them.

Every time we add or change a file in watched directories we’re just copying all of them again into target dir. As simple as that.

Even though it won’t be a problem for most of the apps if you will extend the above module with multiple levels of inheritance you may end up copying huge amounts of files which could take a few seconds. To speed things up instead of copying all the files you can use a filepath argument from chokidar and fse.copy to copy only the added/modified file.

const path = require('path')
const chokidar = require('chokidar');
const fse = require('fs-extra');

export default async function (moduleOptions) {
  const baseDir = path.join(this.options.rootDir, './base')
  const targetDir = path.join(this.options.rootDir, './target')
  const newDir = path.join(this.options.rootDir, './new')

  this.options.srcDir = './target';

  await fse.emptyDir(targetDir);
  await fse.copy(baseDir, targetDir)
  await fse.copy(newDir, targetDir)

  const toTargetPath = (oldPath) => {
    let newPath = oldPath
      // In case path contains "base" or "target" dirs before the Nuxt dir
      .replace(this.options.rootDir, '')
      .replace('/base/', '/target/').replace('/new/', '/target/')
    return path.join(this.options.rootDir, newPath)
  }

  chokidar.watch([baseDir, newDir]).on('all', async (event, filePath) => {
    if (event === 'add' || event === 'change') {
      fse.copy(filePath, toTargetPath(filePath))
    }
    if (event === 'unlink') {
      fse.remove(toTargetPath(filePath))
    }
  })
}

TIP: If you’re working with any kind of version control add target directory to .gitignore

How to improve?

The above technique can be even more powerful when you combine it with a templating engine like EJS. Your base theme could generate different versions of its files based on specific conditions.

This is exactly how Vue CLI worked in the previous version. The code generated from templates was different depending on the features you’ve checked in the CLI.

<template>
  <div id="app">
    <img src="./assets/logo.png">
    {{#router}}
    <router-view/>
    {{else}}
    <HelloWorld/>
    {{/router}}
  </div>
</template>

<script>
{{#unless router}}
import HelloWorld from './components/HelloWorld'
{{/unless}}
export default {
  name: 'App'{{#router}}{{else}},
  components: {
    HelloWorld
  }{{/router}}
}
</script>

<style>
#app {
  font-family: 'Avenir', Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
  margin-top: 60px;
}
</style>

You can see an example of such theme inheritance mechanism with EJS and Nuxt in Vue Storefront Next repository (here and here).

Summary

Having a directory-based inheritance mechanism can be really useful when we work with multiple themes within a single app. It also provides limitless possibilities when it comes to overriding and enhancing functionalities without worrying about limitations of existing extension points. With a small help of two 3rd party libraries and outstanding capabilities of Nuxt modules we can easily create a reusable module that enables an enjoyable theme inheritance experience.

Learn Vue.js With Vue School

Leave a Reply

Your email address will not be published. Required fields are marked *

Up Next:

How to test custom prop validators in Vue.js

How to test custom prop validators in Vue.js