How to Create Vite Plugin?

Article outlines a step-by-step process for developing a Vite plugin, from understanding Vite's plugin architecture to implementing behavior and integrating with project.

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 WordPress code.

This article outlines a step-by-step process for developing a Vite plugin, from understanding Vite's plugin architecture to implementing behavior and integrating with the project.


This guide describes how to create a Vite plugin that copies static assets, such as images, from Vite's resources directory to the dist folder for use in the context of the WordPress backend code. For further insights or rationale refer to the full article here.

How to Use Assets in The Backend 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.


Why to Create Vite Plugin?

Creating a Vite plugin is an excellent way to extend the default Vite behavior, especially when specific project requirements arise. In our WordPress integration, we need to create a flow to copy specific assets to the dist directory during the build process and add entries to manifest.json to use them effectively in the backend code. That's the primary goal.

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.


How to Create a Vite Plugin?

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(),
  ],
});

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. If you plan to use different actions, please check out the official docs. They include resources like diagrams illustrating their firing sequence.

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');
    },
  }
}

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 hooks. 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();
    },
  }
}

I've been working on something I’m really proud of - a new eBook all about code linting and formatting in web development. It’s filled with practical tips to help you set up the project environment so your code stays clean, consistent, and free of those annoying little errors.

The plan is to sell it for $15, but if you're on my newsletter, you’ll get almost 70% discount. Just drop your email below, and you’ll get it for just $5 when it’s ready 🙌


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.


How to Implement 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 as a parameter.

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 versatility.

class Plugin {
  // (...)

  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: [],
          };
        });
    }
  }

  // (...)
}
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.

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: []
  }
]

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';

class Plugin {
  // (...)

  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'
      }
    ]
  }
]

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.

import fs from 'fs';

class Plugin {
  // (...)

  copy() {
    for (const target of this.targets) {
      for (const file of target.files) {
        try {
          fs.copyFileSync(file.src, file.dest);
          if (target.manifest) {
            this.entries.push({
              source: file.src,
              file: file.name,
            });
          }
        } catch (error) {
          console.error(error);
        }
      }
    }
  }

  // (...)
}

Once the file has been copied correctly, the function adds a manifest entry to the array for further usage.

[
  { 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' }
]

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.

class Plugin {
  // (...)

  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));
  }

  // (...)
}

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 code.

Belof is the full version of the plugin 👇

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

class Plugin {
  constructor() {
    this.targets = [];
    this.entries = [];

    this.dest = '';
    this.rename = '';
    this.manifest = '';
  }

  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: [],
          };
        });
    }
  }

  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,
        });
      }
    }
  }

  copy() {
    for (const target of this.targets) {
      for (const file of target.files) {
        try {
          fs.copyFileSync(file.src, file.dest);
          if (target.manifest) {
            this.entries.push({
              source: file.src,
              file: file.name,
            });
          }
        } catch (error) {
          console.error(error);
        }
      }
    }
  }

  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));
  }
}

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();
    },
  }
}

Thank you so much for joining me in today ❤️ Your support means a lot to me, and it keeps me motivated to create more content. If you enjoyed this video, please consider subscribing to the channel and giving it a thumbs-up - it really helps spread the word. And if you need developers who truly care about quality and delivering great results, visit my company’s site: coditive.com or contact me with the form below. Thanks again and see you next time!

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?