Andrew Welch
Published , updated · 5 min read · RSS Feed
Please consider 🎗 sponsoring me 🎗 to keep writing articles like this.
A Vite Buildchain for Craft CMS Plugins & Modules
A drop-in buildchain you can use in your Craft CMS plugins and modules, giving you Hot Module Replacement for JavaScript, Tailwind CSS, and optimized production builds.
If you’re a Craft CMS plugin or module developer, this article is for you.
We saw how to set up a containerized local development environment in the A Craft CMS Plugin Local Development Environment article, and looked at the benefits of using Vite.js modern frontend tooling in the Vite.js Next Generation Frontend Tooling + Craft CMS article.
Now let’s bring them together for a great plugin development DX inside of the Craft CMS CP.
Here’s a video showing you some of the benefits you’ll get:
This article assumes you have some knowledge of how to build Craft CMS plugins or modules.
If you don’t, you might want to check out the So You Wanna Make a Craft 3 Plugin? and the Enhancing a Craft CMS 3 Website with a Custom Module articles first.
Link What the buildchain gives us
The Vite buildchain described in this article gives us the following features:
- Zero config support for TypeScript
- Hot Module Replacement of CSS, JavaScript, and other content such as Twig templates
- Vue.js 3 support
- Tailwind CSS with the super-fast JIT, including only the CSS you actually use
- PostCSS support
- Optimized production builds via Rollup with content hash versioning
But don’t worry, if you don’t use some of these technologies, you’re not obligated to. They are simply there if you need them.
A good question to ask is “why?”
The reason we want to use a buildchain at all is so that we can leverage modern tooling, workflows, and frameworks for a delicious developer experience, while also generating optimized production builds that will work in any browser.
The DX from using Hot Module Replacement in your plugins or modules 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 buildchain to our Craft CMS plugins and modules.
N.B.: In the steps below, we’ll be using the Transcoder plugin as our example; substitute in any namespace or other changes needed for your plugin.
Link Step 1: Require craft-plugin-vite package
When creating the Vite plugin that bridges Vite.js into your Craft CMS frontend Twig templates, we decided to make it a thin layer, and break out most of the functionality into the nystudio107/craft-plugin-vite Composer package.
This was done so that frontend websites could take advantage of a Vite buildchain via the Vite plugin, but also other Craft CMS plugins or modules could leverage the exact same functionality.
So the first thing we’ll need to do is require the package in our plugin:
composer require nystudio107/craft-plugin-vite
And indeed, you can see that the Vite plugin itself requires this package.
The craft-plugin-vite provides us with two things we need to integrate a Vite.js buildchain into our plugin or module:
- VitePluginService — This Yii2 component (“Service” in Craft 3 parlance) provides the functionality needed to connect to the Vite.js build system from a plugin
- ViteVariableInterface & ViteVariableTrait — these two combined allow us to include Vite.js generated JavaScript & CSS from our Twig templates from a plugin
Link Step 2: Set up the Vite service
In your plugin’s primary class, you’ll need to pull in the VitePluginService:
use nystudio107\pluginvite\services\VitePluginService;
Next we’ll need to add this as a component to our plugin’s class. We can do this in a simple way by leveraging the fact a constructor function named __construct() is called whenever an object is instantiated.
In the case of Yii2 Component objects, the parameters passed in are always an $id, $parent, and crucially for us, a $config array.
We can leverage Yii2’s use of Dependency Injection (DI) Containers to pass in our Vite service component, 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 later, you can instead use the ::config() method to accomplish the same thing, in a slightly cleaner 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 dependency injection just as a fancy way of saying that a component has its configuration passed into it, so that dependencies are only ever passed in, the component never reaches out for configuration.
The configuration array you pass in is then used to mass-set the class properties. Craft CMS actually lets you set plugin config via the config/app.php if you want to.
This allows components to be swapped in and out easily, and it’s in fact this very technique that is used to configure Craft CMS itself.
So we’re adding our components to the $config array, which are the Craft CMS services that are available via Transcoder::$plugin->. We pass in our Transcode service, as well as the VitePluginService we imported from the craft-plugin-vite package, with some configuration:
- class — The class of the component
- assetClass — the class of the AssetBundle that should be used for publishing our assets on the frontend (more on this later)
- useDevServer — whether the Vite plugins should attempt to use the Vite dev server for HMR
- devServerPublic — the URL for the dev server, only Vite-processed assets (such as JavaScript, CSS, etc.) are served from this URL. Note that we’re serving off of port 3001
- serverPublic — the URL for the actual web server that the site is being served from
- errorEntry — the script that should be injected on Twig error pages to allow for HMR through errors
- devServerInternal — optional, the internal network name for the Vite dev server, as access via PHP. This is used only if checkDevServer is true
- checkDevServer — optional, whether the Vite dev server should be checked to see if it is running, and if not, fall back on the production build
In addition to checking useDevServer, the VitePluginService will not attempt to use the Vite dev server unless an environment variable named VITE_PLUGIN_DEVSERVER is present in your .env.
We do this because we want to ensure that the development server is never accessed by people who are using your plugins, but rather only when you are developing them.
# Plugin debug
VITE_PLUGIN_DEVSERVER=1
<?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 variable class here. Interfaces in PHP are essentially contracts, so that if you say a class implements a given interface, it will always implement the methods 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 declarations that your variable class must implement. Fortunately for you, the implementation work has already been done in the ViteVariableTrait.
A trait in PHP is a way for the language to sort of implement multiple inheritance in its object model. A given class can use as many traits as it likes, which allows you to extend classes in a piecemeal fashion.
An easy way to think of it is that when you have this in your class:
use ViteVariableTrait;
…it is essentially including all of the code inside of the trait {} definition in the file.
This will magically add the implementation code for the ViteVariableInterface into our variable class.
Then we just need to register our variable in the primary plugin 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 actually using dependency injection here too, because we’re passing in the VitePluginService we set in our __construct() method either to our variable class.
We have to do this because there may be several instances of plugins installed at the same time that all use this Vite buildchain setup, and each needs their own separate configuration.
Then we’ll be able to use the functions 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 Bundles to group together and publish assets used in the Control Panel. Published asset bundles will appear in the web/cpresources/ in hashed directory 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 dependency, serving, and bundling, we only need to use a skeleton AssetBundle to give us the published 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 actually registering and JavaScript or CSS assets here, we’re just setting the sourcePath to where the Vite-built assets live in our plugin’s directory, and setting a bundle dependency on the Craft CpAsset with depends.
So we’re just using the Asset Bundle to get a published path for our resources.
Link Step 5: Drop in the Vite Buildchain
Now that we have the plugin scaffolding in place, let’s actually drop in the Vite buildchain by cloning the repository down:
git clone https://github.com/nystudio107/craft-plugin-vite-buildchain.git
Then copy the following into your plugin or module’s root:
- buildchain/ — this contains the Vite buildchain configuration & frontend assets (JavaScript, CSS, images, etc) source
- src/web/assets/dist/ — where the built frontend assets end up
The buildchain uses Docker to work; but it is containerized in such a way that you can use it with whatever your local development setup is (Docker or otherwise).
N.B.: If you won’t be using this buildchain with the plugindev local development environment, you’ll need to delete this line from the Makefile:
--network plugindev_default \
…so that it won’t be looking for the plugindev_default Docker network.
Now just go to the buildchain/ directory in Terminal, and type:
make dev
…to fire up the Vite dev server. Note that the Vite dev server runs on port 3001. To stop it from running, just hit Control-C.
To build the production assets, type:
make build
…and it will build the production assets to src/web/assets/dist/ which is the directory that our AssetBundle publishes.
You can also run arbitrary npm commands in the buildchain container via make npm xxx , which runs the npm command passed in, e.g.: make npm install
For more on using make in this manner, check out the Using Make & Makefiles to Automate your Frontend Workflow 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 Generation Frontend Tooling + Craft CMS article or the Vite Documentation for details on that.
Normally, Vite considers the project root to be where everything is served from: your source code, node_modules npm packages, the public/ directory, and the built production assets in dist/
Since we want to integrate the buildchain in our existing plugin or module directory structure, we move things around a bit:
- build.outDir — this is set to ../src/web/assets/dist to build our production assets there
The ViteRestart plugin is used to HMR changes to our plugin or module’s Twig templates… but you could also pass in a glob to your PHP files if you wanted the browser to reload on changes to those, too!
Link Step 6: Add Vite to your Twig templates
Finally, we can add some Twig tags to our plugin’s templates to get our frontend assets to appear.
Normally you’d use {{ craft.transcoder.script('src/js/app.ts') }} to include that file, but that outputs the script tags directly into our templates.
What we really want to do inside of the Craft CMS CP is register the script tags the way we would Asset Bundles, to allow for Asset Bundle 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 value we pass in as the second parameter tells it that the CSS should not be asynchronous.
The third parameter is an array of attributes for the script tag. The depends option ensures that our script won’t be loaded until the assets that depend on the TranscodeAsset are loaded.
This ends up calling Yii2’s registerJsFile() & registerCssFile() instead of outputting the tags directly.
In this way, we’ve integrated with Craft CMS by leveraging its Asset Bundle system, fusing it with the Vite.js buildchain.
N.B.: One caution is not to use dynamic imports (e.g.: import() as a function) in your JavaScript or components that will be used in the CP, because they are resolved a bundle 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 happens with webpack dynamic imports in the CP; so it’s not a limitation of Vite. Stick to regular old imports and you’ll be good as gold.
Link Lazy Lightning
Hopefully this writeup has helped you drop a buildchain into your Craft CMS plugins or modules that will allow you to make some amazing plugins.
Having a good DX can allow you to richer experiences for the end user more quickly.
A little bit of setup can pay off in a huge way as you spend months working on a project.
Happy bundling!