How to use frontend assets in the backend code with Vite? + Vite 5.0 Update

I'll update Vite to the latest version, address common issues, and discuss how the latest changes impact our WordPress workflow. As the icing on the cake, I'll show you how to use static assets in backend code with Vite.

tl;drGitHub

Intro

Do you face problems with Vite integration in WordPress? HMR doesn’t work? I was there too. I messed up a little bit by skipping the „read the documentation” part while upgrading Vite. But who reads the documentation first? 😅 Anyway, If you want to avoid my mistakes or you are searching for solutions to your problems, you must see this!

I’ll upgrade Vite to the latest version, fix what went wrong and discuss what changes it brings to our WordPress integration. Finally, the icing on the cake, I’ll show something you must know if you want to use frontend assets in backend code. Of course, in a way that will help you get to know Vite better.

Welcome back, I’m Przemek and this is my journey to mastering Vite with WordPress - the second part! If you haven't had a chance to check out the first one, I insist you do this, because you'll need knowledge presented there. I ensure you won't regret 😎


Table of Content

If you don't have a time to read all of this, you can go directly to github PR including all the code we created and take what you need 🤝

  1. Fix: Handling missing element in the current Vite integration.
  2. Maintenance: Updating Vite, discussing breaking changes and solutions.
  3. Brainstorm: What is wrong with static assets in the backend code?
  4. Coding Session: Creating a Vite plugin allowing using all the assets in the backend.

I’ve forgot about this! Load Scripts as Modules

In my previous video, I presented how to integrate Vite with WordPress. However, thanks to @jantack7186 observation, there was one important missing element: the need to load scripts as modules no matter if the development mode is active or not.

I have just forgot about this! It wasn't a problem in case of having just one script without external libraries, but for sure, It had to be fixed. So, I’ve moved the function which adds type=“module“ tags from the Vite integration class to Assets module, to ensure the loading of all theme assets as modules.

These changes have now been reflected in the original article so if you follow them, you should make it work without any problems.

#github


Vite 5 Released! What Changes it Brings to WP

Vite 5 has just been released, and I want to send a big thanks to the team for their huge effort. You are amazing guys! This update brings performance improvement caused by adopting Rollup 4, increases the minimum node version to Node 18, focuses on cleaning up deprecated APIs, and includes several smaller enhancements.

If you’re interested in all the changes, you can check the detailed release notes. In this discussion, I’ll focus on aspects crucial to the WordPress workflow we defined in the previous article. Especially the ones that have broken my integration.

Let's start with upgrading Vite using the yarn upgrade vite --latest command.

#github

#1 Vite CJS Node API deprecated

I’ll start with something small. CJS API has been marked as deprecated and it will be removed in future releases. It means that we should not use require(’vite’) anymore.

That’s not a problem, because we already use dynamic imports with import declaration but in the current setup, when executing the dev/build command, Vite displays a deprecation notice. It can be resolved by adding type: ”module” to the package.json file.

$ vite build
The CJS build of Vite's Node API is deprecated. See https://vitejs.dev/guide/troubleshooting.html#vite-cjs-node-api-deprecated for more details.
vite v5.0.10 building for production...
✓ 4 modules transformed.
dist/manifest.json           1.37 kB │ gzip: 0.33 kB
dist/uBgCxLf2.woff2         65.27 kB
dist/d0h2cOfx.css            3.23 kB │ gzip: 1.36 kB
dist/_CQrhhbQ.js             0.18 kB │ gzip: 0.17 kB │ map: 0.48 kB
dist/OQKw9U_W.js             0.18 kB │ gzip: 0.17 kB │ map: 0.48 kB
dist/js.cookie-Hg5VKEPt.js   1.46 kB │ gzip: 0.76 kB │ map: 5.86 kB
✓ built in 174ms
✨  Done in 0.47s.

#github

#2 Manifest is being generated in .dist/manifest.json

Another important change involves the relocation of the manifest.json file, crucial for handling asset loading in our system after the build.

├── dist
│   ├── manifest.json
├── dist
│   ├── .vite
│   │   ├── manifest.json

In Vite 4.5 when manifest was enabled and set to true in the config, it was placed in the output directory, so it was /dist/manifest.json in our setup. After the upgrade, it is also wrapped in .vite directory ( /dist/.vite/manifest.json) so WordPress is not able to load this file anymore resulting in displaying build the code notice all the time.

To solve this, we should update the path to manifest file in the WordPress config or force Vite to place this file as earlier, directly to the output directory, by specifying the file path in thebuild.manifest key of vite.config.js to manifest.json. I've decided to set it in Vite because I don’t want to keep the public references to development infrastructure details in the production. Let’s call it a security risk.

export default defineConfig({
  build: {
    manifest: 'manifest.json',
  },
}

#github

#3 Vite Client is now available within the base path

As you remember, to make up the integration, we had to load Vite client in WordPress when the development server was active to make up the HMR features work.

I couldn’t find any references to this in the release notes, but after updating Vite, the client was not available within the http://localhost:5173/@vite/client URL anymore. It started taking the base path that we’ve set into account so it became available with http://localhost:5173/wp-content/themes/footmate/@vite/client.

We need to reflect this change in the WordPress config and everything starts to work fine. I’m not sure if I missed something earlier, but with Vite 4, it is available directly at localhost. Can you let me know in the comments how it worked in your cases?

#github

#4 Exit Code 1 with yarn

I couldn’t find any references to this issue too, but after the upgrade, I noticed that I no longer encounter errors when terminating the development process with cmd + c command. This issue might also be related to yarn itself, although I’m uncertain. Anyway, I’m glad that it functions fine now.


What is wrong with static assets?

One of my readers asked a question about handling static assets in the backend code. And I want to send a big thanks to Michal because that’s a great question indeed!

Vite is primarily designed for creating reactive front-end applications. However, when integrated with backend tools like WordPress, it might need a little bit of help in understanding our development processes, especially if they deviate from the standard practices. One such case is handling public assets in the backend code.

Let’s try to illustrate this problem by implementing a simple requirement which is displaying a logo located in the resources/images directory in the header.

How to use assets in the backend code?

We already defined a workflow for loading CSS/JS in the previous material, involving the Assets\Resolver trait to obtain the correct URL for the assets we need. We'll use the same workflow in all backend code, but we only need to perform a small tweak.

namespace FM\Assets;

trait Resolver
{
    public function resolve(string $path): string
    {
        $url = '';

        if (! empty($this->manifest["resources/{$path}"])) {
            $url = FM_ASSETS_URI . "/{$this->manifest["resources/{$path}"]['file']}";
        }

        return apply_filters('fm/assets/resolver/url', $url, $path);
    }
}
<header class="app__header">
    <img src="{{ fm()->assets()->resolve('images/logo.svg') }}" />

    <h3>
        {{ get_bloginfo('name') }}
    </h3>
</header>

The resolve function used to handle this is hidden in the Resolver class, so we need to open it for public usage by changing the access modifier to public. Thanks to this, we can resolve assets across the whole codebase.

Assets are missing in production build?

As you probably remember, when the development mode is active assets are available within the Vite dev server. So, if we access this resource in our backend code, everything works fine. But when we build our application for production using Vite, sometimes images or files, like the logo, might not show up anymore as expected.

In Vite, images, fonts, and SVG files referenced in the CSS/JS code are automatically resolved, added to the manifest.json file, and moved to the dist directory with the build, allowing effective usage in the production mode. If assets are referenced differently, for example, in our backend code (Blade), they might not get included in the final build. As a result, when we view the a in production mode, these assets may appear missing.

It is a problem because sometimes we need to use such assets in the backend code. Of course, we can hack this workflow and add a reference to the image in the CSS file, for example, to a class that won’t be used, but it doesn’t make sense. Vite should help us, not create problems. That’s why we need to help Vite learn how to assist us.

How to use assets in backend with production build?

Our goal? We want to use assets in the backend code, regardless of whether they have been referenced in the CSS/JS file or not. How to achieve this? We can manually move required files to the dist directory after each build and add entries in the manifest.json file. That’s how we will be able to use them effectively — TASK DONE.

I’m just kidding. It doesn’t make sense. Relying on manual work defeats the very purpose of Vite’s efficiency. So, what’s the solution? We ask Vite to do this!

Vite allows extending its default behaviour with plugins. We could use rollup-plugin-copy plugin to copy assets from one directory to another during the build process. So, for example, we can define that the PNG, SVG files located in the resources directory should be copied to the dist directory. However, this plugin doesn’t fully meet our needs. We still have to manually list these assets in manifest.json file to not break our WordPress flow. Unfortunately, this plugin doesn’t provide this.

I searched for solutions but it was hard to find them, and I’m not really surprised. In the default way of using Vite, it’s not needed, because the public directory is the one that can be used for assets not referenced in the code. In our backend workflow, it probably won’t work easily. So, I’ve decided to learn something new and create my own plugin to do exactly what I need. And surprisingly,


Creating Vite Plugin

We need to create a flow to copy specific assets to dist directory during the build process and add entries to manifest.json to use them effectively in backend code.

Before we proceed, please note that, at the moment, our focus is mainly on making our dev flow functional, with potential upgrades and adjustments in the future. That's why we skip some of the official plugins conventions as it is recommended for now. Once we solve our problems, we might think about adjusting it to general usage. Time will tell.

#github

How to create a Vite plugin?

Maybe you don't recall, but we already created a simple Vite plugin that refreshes the browser window upon changes in backend files. Due to its simplicity, it was defined inline within vite.config.js, and while this method is fine for such simple scenarios, in this case, I prefer a more organized approach to avoid a monolithic file and uphold the Single Responsibility Principle (SRP).

export default defineConfig({
  plugins: [
    {
      name: 'php',
      handleHotUpdate({ file, server }) {
        if (file.endsWith('.php')) {
          server.ws.send({ type: 'full-reload' });
        }
      },
    },
  ],
});

Let's create a copy.js file within the .vite directory with a factory function that returns the actual plugin object, then import it in vite.config.js, and configure in the plugins key. Why .vite directory? I just want to store the Vite development scripts like plugins there.

export default function() {
  return {
    name: 'vite:copy',
  }
}
import copy from './.vite/copy';

export default defineConfig({
  plugins: [
    copy(),
  ],
});

#github

How to extend a Vite behavior?

Then, we must find a way to extend Vite's behavior with our plugin actions. Hooks will be perfect for this. The concept of hooks should be familiar to you especially if you work with WordPress, but If not, please check out this material. Hooks in simple words allow firing some actions at specific execution points, eg. when the config has been resolved.

We'll use config hook for initializing the plugin behavior, buildStart to verify the target paths and prepare files to copy, writeBundle for copying them, and the closeBundle for adding references to copied files to manifest.json.

export default function() {
  return {
    name: 'vite:copy',

    config(config) {
      console.log('set config');
    },

    buildStart() {
      console.log('resolve targets');
    },

    writeBundle() {
      console.log('copy files');
    },

    closeBundle() {
      console.log('write manifest');
    },
  }
}

If you want to build a plugin that works with different actions, please check out the official docs. They include really valuable resources like diagrams illustrating their firing sequence that might help you a lot.

#github

How to configure a Vite plugin?

While we could hardcode everything - the paths, manifest, etc - I believe that we should put at least minimal effort into making it dynamic by taking the user config. How? The plugin initialized in the plugins key of the vite.config.js can accept arguments that will be passed to the plugin factory function. So we can use it for customizing behavior.

import copy from './.vite/copy';

export default defineConfig({
  plugins: [
    copy({
      targets: [
        {
          src: `resources/images/**/*.{png,jpg,svg}`,
        },
      ],
    }),
  ],
});

In our case, the config accepts an array of targets to copy in the targets key. Each target should have the src property set to path or glob pattern. So, for example, in this case, we want to take all the PNG, JPS, SVG files located within the resources/images, copy them to the output, and add entries to manifest.json.

#github

What approach should we use for creating a plugin?

While a standard procedural approach would be fine enough, I prefer using an object-oriented approach. It just looks nicer to me and allows separating my business needs from the Vite infrastructure itself. What does it mean? What are the benefits?

If we look at the final results later, we'll see that we created a simple Node script that takes the user configuration, analyzes data, and performs operations on the files. Vite or Rollup won't be referenced there even once! So why create a code that is highly coupled to infrastructure detail if it's not needed, especially if it's still simple?

We can create a solution that is decoupled from infrastructure allowing us to use it no matter what tool we'll use for bundling. Rollup, Vite, Webpack, Gulp - we just create a class instance, configure and fire specific functions in specific for the infra time.

So let's create a new Plugin class with a few functions, initialize its instance, and use it in the Vite plugin factory.

class Plugin {
  init() {
    console.log('set config');
  }

  resolve() {
    console.log('resolve targets');
  }

  copy() {
    console.log('copy files');
  }

  write() {
    console.log('write manifest');
  }
}
export default function(params) {
  const plugin = new Plugin();

  return {
    name: 'vite:copy',

    config(config) {
      plugin.init();
    },

    buildStart() {
      plugin.resolve();
    },

    writeBundle() {
      plugin.copy();
    },

    closeBundle() {
      plugin.write();
    },
  }
}

#github

How to implement the plugin behavior?

First, let's implement the init function used for setting the default plugin behavior. This function is fired in the config hook which provides the user config (CLI options merged with config file) as a parameter. We can use it!

Rather than passing the entire Vite config object to init function, I prefer limiting access to data, to reduce complexity. Managing 'messy' checks related to user input outside this class prevents an overflow of information. Speaking of simple words - the class doesn't need to know everything! Also, if we would pass the entire Vite config, we would break the idea of using a class that we discussed earlier.

export default function(params) {
  const plugin = new Plugin();

  return {
    name: 'vite:copy',

    config(config) {
      const { build } = config;
      plugin.init({
        dest: build.outDir || 'dist',
        rename: build.rollupOptions.output.assetFileNames || '[name]-[hash].[ext]',
        targets: params.targets || [],
        manifest: typeof build.manifest === 'string'
          ? build.manifest
          : build.manifest === true
            ? '.vite/manifest.json'
            : '',
      });
    },

    buildStart() {
      plugin.resolve();
    },

    writeBundle() {
      plugin.copy();
    },

    closeBundle() {
      plugin.write();
    },
  }
}

We analyze the config, get specific user options, set the default values when the required ones are not already set, and pass this as a parameter. Then in the init function, we get this config from the parameter and define the plugin behavior.

init(config) {
  this.dest = config.dest;
  this.rename = config.rename;

  if (config.manifest) {
    this.manifest = `${this.dest}/${config.manifest}`;
  }

  if (config.targets) {
    this.targets = config.targets
      .filter(item => item.src)
      .map(item => {
        return {
          src: item.src,
          rename: item.rename || this.rename,
          manifest: item.manifest !== false,
          files: [],
        };
      });
  }
}

The function sets the output directory and manifest paths, filename pattern, iterates through the targets, filters them by checking if the src is set, and builds an array for further usage. The function takes the config and builds something like this.

[
  {
    src: 'resources/images/**/*.{png,jpg,jpeg,svg,webp}'
  }
]
[
  {
    src: 'resources/images/**/*.{png,jpg,jpeg,svg,webp}',
    rename: '[hash].[ext]',
    manifest: true,
    files: []
  }
]

#github

Since the plugin config is set, I can create resolve a function that will analyze defined patterns for the files that should be copied. It iterates through specified targets, searches for the files, and prepares their production names and placement. For analyzing the glob patterns I use a simple glob library installed with yarn add glob --dev command. Unique hashes for the file names can be generated with the crypto module available in Node 21.

import path from 'path';
import crypto from 'crypto';
import{ globSync } from 'glob';

resolve() {
  for (const target of this.targets) {
    for (const file of globSync([target.src])) {
      const info = path.parse(file);
      const name = target.rename
        .replace('[name]', info.name)
        .replace('[hash]', crypto.randomBytes(4).toString('hex'))
        .replace('[ext]', info.ext.substring(1));

      target.files.push({
        src: file,
        dest: `${this.dest}/${name}`,
        name,
      });
    }
  }
}

In this step, we take the targets array prepared earlier and fill the files property.

[
  {
    src: 'resources/images/**/*.{png,jpg,jpeg,svg,webp}',
    rename: '[hash].[ext]',
    manifest: true,
    files: []
  }
]
[
  {
    src: 'resources/images/**/*.{png,jpg,jpeg,svg,webp}',
    rename: '[hash].[ext]',
    manifest: true,
    files: [
      {
        src: 'resources/images/logo.svg',
        dest: 'dist/4777bdb3.svg',
        name: '4777bdb3.svg'
      },
      {
        src: 'resources/images/logo.png',
        dest: 'dist/09938f7a.png',
        name: '09938f7a.png'
      },
      {
        src: 'resources/images/logo-kopia.svg',
        dest: 'dist/6d6a35a1.svg',
        name: '6d6a35a1.svg'
      }
    ]
  }
]

#github

Now, we’ll implement a copy function that will copy the files to the destinations. I wrap the file reading/writing process handled by Node's fs module into the try...catch structure considering potential file operation issues. Once the file has been copied correctly, the function adds a manifest entry to the array for further usage.

import fs from 'fs';

copy() {
  for (const target of this.targets) {
    for (const file of target.files) {
      try {
        fs.copyFile(file.src, file.dest, () => {
          if (target.manifest) {
            this.entries.push({
              source: file.src,
              file: file.name,
            });
          }
        });
      } catch (error) {
        console.error(error);
      }
    }
  }
}
[
  { source: 'resources/images/logo.svg', file: '569246b3.svg' },
  { source: 'resources/images/logo-kopia.svg', file: 'c8adbb18.svg' },
  { source: 'resources/images/logo.png', file: 'e2fd7c7c.png' }
]

#github

Finally, in the write we take the manifest entries to add from the class property and write them it to the file using fs module. Of course, we do this only for the entries that don’t exist there to avoid duplications.

write() {
  if (! this.manifest || ! this.entries.length) {
    return;
  }

  const manifest = JSON.parse(fs.readFileSync(this.manifest, 'utf-8'));

  for (const entry of this.entries) {
    if (! manifest[entry.source]) {
      manifest[entry.source] = entry;
    }
  }

  fs.writeFileSync(this.manifest, JSON.stringify(manifest, null, 2));
}

#github

We can run by yarn build command in the terminal to see that the plugin correctly copies the files and adds entries to manifest.json. We’re ready to use our assets across the whole codebase 👋


Summary

And that's all! I'm glad that there was no ready-to-use solution to my problem because I learned something new, and that's one of the most important aspects for me 🤌 I hope you enjoyed spending time with me and learnt something that will be useful for solving your future problems with your development process too.

Please remember that all the changes we've made are available within the public GitHub repository. So feel free to check it out to understand this topic better, or just to copy/paste the code 😅 And of course, let me know what you think about this topic in the form below. I'll be so grateful for this 🙌