How to Use Assets in The Backend Code With Vite?

Learn how to create a Vite plugin and improve WordPress integration by enabling the use of static assets in backend code with Vite.

tl;drGitHub

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.


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!

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,


How to create 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 👋


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 🙌

avatar

Looking for a developer who
truly cares about your business?

My team and I provide expert consultations, top-notch coding, and comprehensive audits to elevate your success.

Feedback

How satisfied you are after reading this article?