Andrew Welch · Insights · #plugin #vite #frontend

Published , updated · 5 min read ·


Please consider 🎗 sponsoring me 🎗 to keep writing articles like this.

A Vite Buildchain for Craft CMS Plugins & Modules

A drop-in build­chain you can use in your Craft CMS plu­g­ins and mod­ules, giv­ing you Hot Mod­ule Replace­ment for JavaScript, Tail­wind CSS, and opti­mized pro­duc­tion builds.

This arti­cle will show you how to drop a mod­ern Vite.js build sys­tem into your Craft CMS plu­g­in or mod­ule, and reap all of the ben­e­fits of the super fast Hot Mod­ule Replace­ment (HMR) development.

If you’re a Craft CMS plugin or module developer, this article is for you.

We saw how to set up a con­tainer­ized local devel­op­ment envi­ron­ment in the A Craft CMS Plu­g­in Local Devel­op­ment Envi­ron­ment arti­cle, and looked at the ben­e­fits of using Vite.js mod­ern fron­tend tool­ing in the Vite.js Next Gen­er­a­tion Fron­tend Tool­ing + Craft CMS article.

Now let’s bring them togeth­er for a great plu­g­in devel­op­ment DX inside of the Craft CMS CP.

Here’s a video show­ing you some of the ben­e­fits you’ll get:

This arti­cle assumes you have some knowl­edge of how to build Craft CMS plu­g­ins or modules.

If you don’t, you might want to check out the So You Wan­na Make a Craft 3 Plu­g­in? and the Enhanc­ing a Craft CMS 3 Web­site with a Cus­tom Mod­ule arti­cles first.

Link What the buildchain gives us

The Vite build­chain described in this arti­cle gives us the fol­low­ing features:

But don’t wor­ry, if you don’t use some of these tech­nolo­gies, you’re not oblig­at­ed to. They are sim­ply there if you need them.

A good question to ask is “why?”

The rea­son we want to use a build­chain at all is so that we can lever­age mod­ern tool­ing, work­flows, and frame­works for a deli­cious devel­op­er expe­ri­ence, while also gen­er­at­ing opti­mized pro­duc­tion builds that will work in any browser.

The DX from using Hot Mod­ule Replace­ment in your plu­g­ins or mod­ules alone is worth it, if there is any amount of JavaScript or CSS that goes into them.

So let’s dive right in and see what it takes to add a Vite.js build­chain to our Craft CMS plu­g­ins and modules.

N.B.: In the steps below, we’ll be using the Transcoder plu­g­in as our exam­ple; sub­sti­tute in any namespace or oth­er changes need­ed for your plugin.

Link Step 1: Require craft-plugin-vite package

When cre­at­ing the Vite plu­g­in that bridges Vite.js into your Craft CMS fron­tend Twig tem­plates, we decid­ed to make it a thin lay­er, and break out most of the func­tion­al­i­ty into the nys­tu­dio107/craft-plu­g­in-vite Com­pos­er package.

This was done so that fron­tend web­sites could take advan­tage of a Vite build­chain via the Vite plu­g­in, but also oth­er Craft CMS plu­g­ins or mod­ules could lever­age the exact same functionality.

So the first thing we’ll need to do is require the pack­age in our plugin:

composer require nystudio107/craft-plugin-vite

And indeed, you can see that the Vite plu­g­in itself requires this pack­age.

The craft-plugin-vite pro­vides us with two things we need to inte­grate a Vite.js build­chain into our plu­g­in or module:

  1. VitePluginService — This Yii2 com­po­nent (Ser­vice” in Craft 3 par­lance) pro­vides the func­tion­al­i­ty need­ed to con­nect to the Vite.js build sys­tem from a plugin
  2. ViteVariableInterface & ViteVariableTrait — these two com­bined allow us to include Vite.js gen­er­at­ed JavaScript & CSS from our Twig tem­plates from a plugin

Link Step 2: Set up the Vite service

In your plug­in’s pri­ma­ry class, you’ll need to pull in the VitePluginService:

use nystudio107\pluginvite\services\VitePluginService;

Next we’ll need to add this as a com­po­nent to our plug­in’s class. We can do this in a sim­ple way by lever­ag­ing the fact a con­struc­tor func­tion named __construct() is called when­ev­er an object is instantiated.

In the case of Yii2 Com­po­nent objects, the para­me­ters passed in are always an $id, $parent, and cru­cial­ly for us, a $config array.

We can lever­age Yii2’s use of Depen­den­cy Injec­tion (DI) Con­tain­ers to pass in our Vite ser­vice com­po­nent, and its configuration:

    /**
     * @inheritdoc
     */
    public function __construct($id, $parent = null, array $config = [])
    {
        // Merge in the passed config, so it our config can be overridden by Plugins::pluginConfigs['vite']
        // ref: https://github.com/craftcms/cms/issues/1989
        $config = ArrayHelper::merge([
            'components' => [
                'events' => Events::class,
                'redirects' => Redirects::class,
                'statistics' => Statistics::class,
                // Register the vite service
                'vite' => [
                    'class' => VitePluginService::class,
                    'assetClass' => RetourAsset::class,
                    'useDevServer' => true,
                    'devServerPublic' => 'http://localhost:3001',
                    'serverPublic' => 'http://localhost:8000',
                    'errorEntry' => 'src/js/Retour.js',
                    'devServerInternal' => 'http://craft-retour-buildchain:3001',
                    'checkDevServer' => true,
                ],
            ]
        ], $config);

        parent::__construct($id, $parent, $config);
    }

If you’re using Craft CMS 4 or lat­er, you can instead use the ::config() method to accom­plish the same thing, in a slight­ly clean­er way:

    /**
     * @inheritdoc
     */
    public static function config(): array
    {
        return [
            'components' => [
                'events' => Events::class,
                'redirects' => Redirects::class,
                'statistics' => Statistics::class,
                // Register the vite service
                'vite' => [
                    'class' => VitePluginService::class,
                    'assetClass' => RetourAsset::class,
                    'useDevServer' => true,
                    'devServerPublic' => 'http://localhost:3001',
                    'serverPublic' => 'http://localhost:8000',
                    'errorEntry' => 'src/js/Retour.js',
                    'devServerInternal' => 'http://craft-retour-buildchain:3001',
                    'checkDevServer' => true,
                ],
            ]
        ];
    }

You can think of depen­den­cy injec­tion just as a fan­cy way of say­ing that a com­po­nent has its con­fig­u­ra­tion passed into it, so that depen­den­cies are only ever passed in, the com­po­nent nev­er reach­es out for configuration.

The con­fig­u­ra­tion array you pass in is then used to mass-set the class prop­er­ties. Craft CMS actu­al­ly lets you set plu­g­in con­fig via the config/app.php if you want to.

This allows com­po­nents to be swapped in and out eas­i­ly, and it’s in fact this very tech­nique that is used to con­fig­ure Craft CMS itself.

So we’re adding our components to the $config array, which are the Craft CMS ser­vices that are avail­able via Transcoder::$plugin->. We pass in our Transcode ser­vice, as well as the VitePluginService we import­ed from the craft-plugin-vite pack­age, with some configuration:

  • class — The class of the component
  • assetClass — the class of the Asset­Bun­dle that should be used for pub­lish­ing our assets on the fron­tend (more on this later)
  • useDevServer — whether the Vite plu­g­ins should attempt to use the Vite dev serv­er for HMR
  • devServerPublic — the URL for the dev serv­er, only Vite-processed assets (such as JavaScript, CSS, etc.) are served from this URL. Note that we’re serv­ing off of port 3001
  • serverPublic — the URL for the actu­al web serv­er that the site is being served from
  • errorEntry — the script that should be inject­ed on Twig error pages to allow for HMR through errors
  • devServerInternal — option­al, the inter­nal net­work name for the Vite dev serv­er, as access via PHP. This is used only if checkDevServer is true
  • checkDevServer — option­al, whether the Vite dev serv­er should be checked to see if it is run­ning, and if not, fall back on the pro­duc­tion build

In addi­tion to check­ing useDevServer, the VitePluginService will not attempt to use the Vite dev serv­er unless an envi­ron­ment vari­able named VITE_PLUGIN_DEVSERVER is present in your .env.

We do this because we want to ensure that the devel­op­ment serv­er is nev­er accessed by peo­ple who are using your plu­g­ins, but rather only when you are devel­op­ing them.

# Plugin debug
VITE_PLUGIN_DEVSERVER=1

Link Step 3: Step up the Vite Variable

Next we need a way to access the VitePluginService from Twig. To do that, we’ll set up our plug­in’s vari­able like this:

<?php
namespace nystudio107\transcoder\variables;

use nystudio107\pluginvite\variables\ViteVariableInterface;
use nystudio107\pluginvite\variables\ViteVariableTrait;

class TranscoderVariable implements ViteVariableInterface
{
    use ViteVariableTrait;

You’ll notice that we have implements ViteVariableInterface next to our vari­able class here. Inter­faces in PHP are essen­tial­ly con­tracts, so that if you say a class imple­ments a giv­en inter­face, it will always imple­ment the meth­ods in the interface.

Here’s what ViteVariableInterface looks like, for example:

<?php
/**
 * Vite plugin for Craft CMS 3.x
 *
 * Allows the use of the Vite.js next generation frontend tooling with Craft CMS
 *
 * @link      https://nystudio107.com
 * @copyright Copyright (c) 2021 nystudio107
 */

namespace nystudio107\pluginvite\variables;

use yii\base\InvalidConfigException;

use Twig\Markup;

/**
 * @author    nystudio107
 * @package   Vite
 * @since     1.0.4
 */
interface ViteVariableInterface
{
    // Public Methods
    // =========================================================================

    /**
     * Return the appropriate tags to load the Vite script, either via the dev server or
     * extracting it from the manifest.json file
     *
     * @param string $path
     * @param bool $asyncCss
     * @param array $scriptTagAttrs
     * @param array $cssTagAttrs
     *
     * @return Markup
     */
    public function script(string $path, bool $asyncCss = true, array $scriptTagAttrs = [], array $cssTagAttrs = []): Markup;

    /**
     * Register the appropriate tags to the Craft View to load the Vite script, either via the dev server or
     * extracting it from the manifest.json file
     *
     * @param string $path
     * @param bool $asyncCss
     * @param array $scriptTagAttrs
     * @param array $cssTagAttrs
     *
     * @return Markup
     * @throws InvalidConfigException
     */
    public function register(string $path, bool $asyncCss = true, array $scriptTagAttrs = [], array $cssTagAttrs = []): Markup;

    /**
     * Inline the contents of a local file (via path) or remote file (via URL) in your templates.
     * Yii2 aliases and/or environment variables may be used
     *
     * @param string $pathOrUrl
     *
     * @return string|null
     */
    public function inline(string $pathOrUrl): Markup;
}

These are the method dec­la­ra­tions that your vari­able class must imple­ment. For­tu­nate­ly for you, the imple­men­ta­tion work has already been done in the ViteVariableTrait.

A trait in PHP is a way for the lan­guage to sort of imple­ment mul­ti­ple inher­i­tance in its object mod­el. A giv­en class can use as many traits as it likes, which allows you to extend class­es in a piece­meal fashion.

An easy way to think of it is that when you have this in your class:

    use ViteVariableTrait;

…it is essen­tial­ly includ­ing all of the code inside of the trait {} def­i­n­i­tion in the file.

This will mag­i­cal­ly add the imple­men­ta­tion code for the ViteVariableInterface into our vari­able class.

Then we just need to reg­is­ter our vari­able in the pri­ma­ry plu­g­in class:

// Register our variables
Event::on(
    CraftVariable::class,
    CraftVariable::EVENT_INIT,
    function (Event $event) {
        /** @var CraftVariable $variable */
        $variable = $event->sender;
        $variable->set('transcoder', [
            'class' => TranscoderVariable::class,
            'viteService' => $this->vite,
        ]);
    }
);

Note that we’re actu­al­ly using depen­den­cy injec­tion here too, because we’re pass­ing in the VitePluginService we set in our __construct() method either to our vari­able class.

We have to do this because there may be sev­er­al instances of plu­g­ins installed at the same time that all use this Vite build­chain set­up, and each needs their own sep­a­rate configuration.

Then we’ll be able to use the func­tions declared in the ViteVariableInterface in our Twig templates:

{{ craft.transcoder.script('src/js/app.ts') }}
{{ craft.transcoder.register('src/js/app.ts') }}
{{ craft.transcoder.inline('@webroot/icon.png') }}

Link Step 4: Set up the AssetBundle

Craft CMS uses Asset Bun­dles to group togeth­er and pub­lish assets used in the Con­trol Pan­el. Pub­lished asset bun­dles will appear in the web/cpresources/ in hashed direc­to­ry names, e.g.:

web
├── cpresources
│   ├── 2df31814
│   │   ├── jquery.js
│   │   └── jquery.js.map
│   ├── 2e32674b
│   │   ├── selectize.css
│   │   ├── selectize.js
│   │   └── selectize.js.map
│   ├── 3292780b
│   │   ├── velocity.js
│   │   └── velocity.js.map
│   ├── 3330409c
│   │   ├── velocity.js
│   │   └── velocity.js.map
│   ├── 3350fc86
│   │   ├── garnish.js
│   │   └── garnish.js.map
│   └─── a9b1b7a4
│       ├── jquery.mobile-events.js
│       └── jquery.mobile-events.js.map
└── index.php

Since we’re using Vite to do the asset depen­den­cy, serv­ing, and bundling, we only need to use a skele­ton Asset­Bun­dle to give us the pub­lished URL to our built assets:

<?php
/**
 * Transcoder plugin for Craft CMS 3.x
 *
 * Transcode videos to various formats, and provide thumbnails of the video
 *
 * @link      https://nystudio107.com
 * @copyright Copyright (c) 2017 nystudio107
 */

namespace nystudio107\transcoder\assetbundles\transcoder;

use craft\web\AssetBundle;
use craft\web\assets\cp\CpAsset;

/**
 * @author    nystudio107
 * @package   Transcode
 * @since     1.0.0
 */
class TranscoderAsset extends AssetBundle
{
    // Public Methods
    // =========================================================================

    /**
     * @inheritdoc
     */
    public function init()
    {
       parent::init();
       $this->sourcePath = '@nystudio107/transcoder/web/assets/dist';

        $this->depends = [
            CpAsset::class,
        ];
    }
}

As you can see, we’re not actu­al­ly reg­is­ter­ing and JavaScript or CSS assets here, we’re just set­ting the sourcePath to where the Vite-built assets live in our plug­in’s direc­to­ry, and set­ting a bun­dle depen­den­cy on the Craft CpAs­set with depends.

So we’re just using the Asset Bun­dle to get a pub­lished path for our resources.

Link Step 5: Drop in the Vite Buildchain

Now that we have the plu­g­in scaf­fold­ing in place, let’s actu­al­ly drop in the Vite build­chain by cloning the repos­i­to­ry down:

git clone https://github.com/nystudio107/craft-plugin-vite-buildchain.git

Then copy the fol­low­ing into your plu­g­in or mod­ule’s root:

  • buildchain/ — this con­tains the Vite build­chain con­fig­u­ra­tion & fron­tend assets (JavaScript, CSS, images, etc) source
  • src/web/assets/dist/ — where the built fron­tend assets end up

The build­chain uses Dock­er to work; but it is con­tainer­ized in such a way that you can use it with what­ev­er your local devel­op­ment set­up is (Dock­er or otherwise).

N.B.: If you won’t be using this build­chain with the plug­in­dev local devel­op­ment envi­ron­ment, you’ll need to delete this line from the Makefile:

--network plugindev_default \

…so that it won’t be look­ing for the plugindev_default Dock­er network.

Now just go to the buildchain/ direc­to­ry in Ter­mi­nal, and type:

make dev

…to fire up the Vite dev serv­er. Note that the Vite dev serv­er runs on port 3001. To stop it from run­ning, just hit Control-C.

To build the pro­duc­tion assets, type:

make build

…and it will build the pro­duc­tion assets to src/web/assets/dist/ which is the direc­to­ry that our Asset­Bun­dle publishes.

You can also run arbi­trary npm com­mands in the build­chain con­tain­er via make npm xxx , which runs the npm com­mand passed in, e.g.: make npm install

For more on using make in this man­ner, check out the Using Make & Make­files to Auto­mate your Fron­tend Work­flow article.

Now let’s have a look at the vite.config.js file:

import {defineConfig} from 'vite';
import {visualizer} from 'rollup-plugin-visualizer';
import viteEslintPlugin from 'vite-plugin-eslint';
import viteCompressionPlugin from 'vite-plugin-compression';
import viteRestartPlugin from 'vite-plugin-restart';
import viteStylelintPlugin from 'vite-plugin-stylelint';
import viteVuePlugin from '@vitejs/plugin-vue'
import * as path from 'path';

// https://vitejs.dev/config/
export default defineConfig(({command}) => ({
  base: command === 'serve' ? '' : '/dist/',
  build: {
    emptyOutDir: true,
    manifest: true,
    outDir: '../src/web/assets/dist',
    rollupOptions: {
      input: {
        app: 'src/js/app.ts',
        welcome: 'src/js/welcome.ts',
      },
      output: {
        sourcemap: true
      },
    }
  },
  plugins: [
    viteRestartPlugin({
      reload: [
        '../src/templates/**/*',
      ],
    }),
    viteVuePlugin(),
    viteCompressionPlugin({
      filter: /\.(js|mjs|json|css|map)$/i
    }),
    visualizer({
      filename: '../src/web/assets/dist/stats.html',
      template: 'treemap',
      sourcemap: true,
    }),
    viteEslintPlugin({
      cache: false,
      fix: true,
    }),
    viteStylelintPlugin({
      fix: true,
      lintInWorker: true
    })
  ],
  resolve: {
    alias: [
      {find: '@', replacement: path.resolve(__dirname, './src')},
    ],
    preserveSymlinks: true,
  },
  server: {
    fs: {
      strict: false
    },
    host: '0.0.0.0',
    origin: 'http://localhost:' + process.env.DEV_PORT,
    port: parseInt(process.env.DEV_PORT),
    strictPort: true,
  }
}));

We won’t go over the vite.config.js file in detail here; check out the Vite.js Next Gen­er­a­tion Fron­tend Tool­ing + Craft CMS arti­cle or the Vite Doc­u­men­ta­tion for details on that.

Nor­mal­ly, Vite con­sid­ers the project root to be where every­thing is served from: your source code, node_modules npm pack­ages, the public/ direc­to­ry, and the built pro­duc­tion assets in dist/

Since we want to inte­grate the build­chain in our exist­ing plu­g­in or mod­ule direc­to­ry struc­ture, we move things around a bit:

  1. build.outDir — this is set to ../src/web/assets/dist to build our pro­duc­tion assets there

The ViteR­estart plu­g­in is used to HMR changes to our plu­g­in or mod­ule’s Twig tem­plates… but you could also pass in a glob to your PHP files if you want­ed the brows­er to reload on changes to those, too!

Link Step 6: Add Vite to your Twig templates

Final­ly, we can add some Twig tags to our plug­in’s tem­plates to get our fron­tend assets to appear.

Nor­mal­ly you’d use {{ craft.transcoder.script('src/js/app.ts') }} to include that file, but that out­puts the script tags direct­ly into our templates.

What we real­ly want to do inside of the Craft CMS CP is reg­is­ter the script tags the way we would Asset Bun­dles, to allow for Asset Bun­dle dependencies:

    {% set scriptTagOptions = {
        'depends': [
            'nystudio107\\transcoder\\assetbundles\\transcoder\\TranscoderAsset'
        ],
    } %}
    {{ craft.transcoder.register('src/js/app.ts', false, scriptTagOptions) }}
    {{ craft.transcoder.register('src/js/welcome.ts', false, scriptTagOptions) }}

The false val­ue we pass in as the sec­ond para­me­ter tells it that the CSS should not be asynchronous.

The third para­me­ter is an array of attrib­ut­es for the script tag. The depends option ensures that our script won’t be loaded until the assets that depend on the Transcode­As­set are loaded.

This ends up call­ing Yii2’s registerJsFile() & registerCssFile() instead of out­putting the tags directly.

In this way, we’ve inte­grat­ed with Craft CMS by lever­ag­ing its Asset Bun­dle sys­tem, fus­ing it with the Vite.js buildchain.

N.B.: One cau­tion is not to use dynam­ic imports (e.g.: import() as a func­tion) in your JavaScript or com­po­nents that will be used in the CP, because they are resolved a bun­dle time and will fail with an error like:

7:44:44 PM [vite] Internal server error: ENOENT: no such file or directory, open '/app/buildchain/src/vue/Test.vue'

The same thing hap­pens with web­pack dynam­ic imports in the CP; so it’s not a lim­i­ta­tion of Vite. Stick to reg­u­lar old imports and you’ll be good as gold.

Link Lazy Lightning

Hope­ful­ly this write­up has helped you drop a build­chain into your Craft CMS plu­g­ins or mod­ules that will allow you to make some amaz­ing plugins.

Hav­ing a good DX can allow you to rich­er expe­ri­ences for the end user more quickly.

A lit­tle bit of set­up can pay off in a huge way as you spend months work­ing on a project.

Hap­py bundling!