Skip to main content
Start of main content.

Vite and Storybook frontend tooling for Drupal

by jack.taranto /

Share this post on social media

We’ve just completed an extensive overhaul of our frontend tooling, with Vite and Storybook at the centre. Let’s go over it piece by piece.

The goal of the overhaul was to modernise all aspects of the build stack, remove legacy dependencies and optimise development processes.

Tooling is split into four pieces: asset building, styleguide, linting and testing.

Asset building for Drupal with Vite

We have always utilised two separate tools to build CSS and JS assets. Until now, this was PostCSS and Rollup, in the past Sass and Webpack have been in the mix. 

With Vite it’s one tool to build both types of assets. To introduce Vite to anyone not already familiar with it, I would say it’s a super fast version of Rollup without the configuration headaches. 

Moving to Vite sped up our development build times and production build times (in CI), simplified our config files and removed a huge number of NPM dependencies.

Vite library mode

A typical Vite build pipeline is most suitable for single-page apps. It involves an index.html file where Vite dynamically adds CSS and JS assets. However, with Drupal, we do not have an index.html file; we have the Drupal libraries system to load assets, with which Vite has no way of communicating.

Luckily, Vite ships with something called Library mode, which is seemingly tailor-made for Drupal assets! Library mode allows us to output all our frontend assets to a single directory, where we can include them in a libraries.yml file or via a Pinto Theme Object.

To use our config, you’ll first need a few dependencies. 

npm i -D vite postcss-preset-env tinyglobby browserslist-to-esbuild

Our vite.config.js looks like this:

import { defineConfig } from 'vite'
import { resolve } from 'path'
import { globSync } from 'tinyglobby'
import browserslist from 'browserslist-to-esbuild'
import postcssPresetEnv from 'postcss-preset-env'

const entry = globSync(['**/*.entry.js', '**/*.css'], {
 ignore: [
   '**/_*.css',
   'node_modules',
   'vendor',
   'web/sites',
   'web/core',
   'web/libraries',
   '**/contrib',
   'web/storybook',
 ],
})

export default defineConfig(({ mode }) => ({
 build: {
   lib: {
     entry,
     formats: ['es'],
   },
   target: browserslist(),
   cssCodeSplit: true,
   outDir: resolve(import.meta.dirname, './web/libraries/library-name'),
   sourcemap: mode === 'development',
 },
 css: {
   postcss: {
     plugins: [
       postcssPresetEnv(),
     ],
     map: mode === 'development',
   },
 },
}))

We define entry points as any *.css file and any *.entry.js file. We exclude certain directories, so we aren’t building assets that are included with core or contrib. Additionally, we exclude CSS partials, which use an underscore prefix. This allows us to add asset source files anywhere in our project. They could be added in the theme, a module, or (as we have been doing recently) inside a /components directory in the project root.

The Vite config itself enables library mode using build.lib, passing all source assets through using build.lib.entry and building JS assets using the es format.

build.cssCodeSplit is required when passing CSS files through to build.lib.entry.

build.outDir specifies a folder inside the Drupal libraries directory where all built assets will be sent. Drupal libraries.yml definitions are then updated to include files from this directory.

build.sourcemap will output JS sourcemaps in development mode only.

Finally, we pass through any PostCSS plugins with css.postcss.plugins. Vite includes postcss-import by default, so you do not need to add that. It will also handle resolving to custom directories without including resolve options for postcss-import, meaning you’ll only need to add your specific plugins. In this case, we reduced ours to just postcss-preset-env. Add more as needed!

We also enable CSS sourcemaps with css.postcss.map.

This config allowed us to completely remove the PostCSS config file, PostCSS CLI, Rollup, its config and all Rollup plugins.

The config file above is a starting point—a minimum viable setup you’ll need to build assets using Vite’s library mode. Add to it as you need to, and familiarise yourself with Vite’s documentation.

Using Browerslist with Vite

Vite uses ESBuild to determine the output feature set based on the build.target. For many years now, we have used Browserslist to determine feature sets for both PostCSS and Rollup, and it works really well. We weren’t ready to lose this functionality by moving to Vite.

This is where the browserslist-to-esbuild dependency comes in. We added the following .browserlistrc file to our project root:

> 1% in AU

By calling browserslist() in build.target we get our browser feature set provided by Browserslist instead of ESBuild.

NPM scripts for development mode and production builds

We use NPM scripts for consistent usage of non-standard commands both locally and on CI for production builds.

"scripts": {
  "dev-vite": "vite build -w -m development",
  "build-vite": "vite build"
},

To watch and build source assets whilst developing locally, we use npm run dev-vite. Unlike Vite’s dev command, this still uses Rollup under the hood (instead of ESBuild), so we miss out on the extreme speed of Vite’s dev mode. However, it’s still very fast—faster than default Rollup. It’s a tradeoff that provides what we need, which is building our assets while we are editing them in a way that works with Drupal. We lose hot reloading, but that’s less important when we have Storybook at our disposal.

Production builds happen on CI using npm run build-vite.

Using Storybook with Drupal

Although we had been using Storybook in our projects for some time now, we hadn’t yet standardised on it or provided a default setup. And with Vite now baked into Storybook, it seemed like an excellent time to provide this.

If you have a spare 15 minutes, I would first suggest checking out Lee Rowland’s lightning talk from Drupal South to see just how fluid a frontend development experience Storybook brings to Drupal.

Storybook is easy to setup using its wizard with:

npx storybook@latest init

It will present you with a few choices. Just make sure you choose HTML and Vite for your project type. When using Vite with Storybook, Storybook provides its necessary config to Vite; however, it will still read your projects vite.config.js file for any additional config. This includes the PostCSS config we setup above and any additional functionality you provide.

Now, install Lee’s Twig plugin. This plugin will allow us to write components using Twig that can be imported into our stories.js files. First, install the plugin:

npm i -D vite-plugin-twig-drupal

Then register the plugin by adding the following lines to the vite.config.js default export:

plugins: [
 twig(),
],

See the vite-plugin-twig-drupal documentation for more details, including how to set up Twig namespaces.

Writing stories

To use Twig in Storybook, it’s quite similar to any other framework. Here’s an example story of a card component:

import Component from './card.html.twig'

const meta = {
 component: Component,
 args: {
   title: `<a href="#">Card title</a>`,
   description:
     'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam eu turpis molestie, dictum est a, mattis tellus. Sed dignissim, metus nec fringilla accumsan, risus sem sollicitudin lacus.',
 },
}
export default meta

export const Card = {}

We import the twig file as Component and then add that to the stories meta. We can pass through args, which will show up in the Twig file as variables, and we can use HTML here.

Writing stories is covered in more detail in our front-end nirvana blog post.

NPM scripts for developing with Vite and Storybook at once

Our standard development practice involves building and testing components in Storybook and then integrating them with Drupal using Pinto. To do this, we need to run Storybook and our Vite tooling at once so we have both Storybook dev mode and our built frontend assets available to us.

Running two NPM scripts in parallel can be a pain, so we have implemented concurrently to streamline this approach.

npm i -D concurrently

Then we use the following in our NPM scripts:

{
 "scripts": {
   "dev": "concurrently -k -n \"VITE,STORYBOOK\" -c \"#636cff,#ff4785\" \"npm run dev-vite\" \"npm run dev-storybook\"",
   "build": "concurrently -n \"VITE,STORYBOOK\" -c \"#636cff,#ff4785\" \"npm run build-vite\" \"npm run build-storybook\"",
   "dev-storybook": "storybook dev -p 6006 --no-open",
   "build-storybook": "storybook build -o web/storybook",
   "dev-vite": "vite build -w -m development",
   "build-vite": "vite build"
},

With npm run dev we get coloured output so we can see which tool is running and what it’s doing. npm run build is used on CI.

Linting with Prettier, Stylelint and ESLint

These three tools have been a staple on our projects for a long time, but with ESLint introducing a new flat configuration method, it seemed like a good time to review the tooling.

First, we’ll need some more dependencies.

npm i -D prettier stylelint stylelint-config-standard eslint@8.57.0 @eslint/js@8.57.0 eslint-config-prettier eslint-config-drupal

Formatting source assets with Prettier

We are using Prettier to format both CSS and JS files. With PHPStorm, you can set this to happen on file save. We also have an NPM script to do this on demand and before committing. NPM commands are at the end of this section.

Reducing Stylelint configuration

Past iterations of our Stylelint tooling involved extensive configuration on each project. Using Stylelints latest standard configuration, it sets sensible defaults, which lets us remove most config options. We’re left with the following:

const config = {
 extends: ['stylelint-config-standard'],
 rules: {
   'custom-property-empty-line-before': null,
   'no-descending-specificity': null,
   'import-notation': 'string',
   'selector-class-pattern': [
     '^([a-z])([a-z0-9]+)(-[a-z0-9]+)?(((--)?(__)?)([a-z0-9]+)(-[a-z0-9]+)?)?$',
     {
       message:
         'Expected class selector to be BEM selector matching either .block__element or .block--modifier',
     },
   ],
   'selector-nested-pattern': '^&',
 },
}

export default config

We added a custom rule to ensure project BEM selectors are used.

Like prettier, we also use a .stylelintignore file to exclude core and contrib folders.

Moving to ESLint flat config

The new config format isn’t yet supported by all plugins (there’s a compatibility tool to help with this), but where it is, it’s much simpler.

The following config can be used in conjunction with Prettier.

import js from '@eslint/js'
import globals from 'globals'
import prettier from 'eslint-config-prettier'
import drupal from 'eslint-config-drupal'

export default [
 js.configs.recommended,
 prettier,
 {
   languageOptions: {
     globals: {
       ...globals.browser,
       ...globals.node,
       ...drupal.globals,
       dataLayer: true,
       google: true,
       once: true,
     },
   },
 },
 {
   rules: {
     'no-console': 'error',
     'no-unused-expressions': [
       'error',
       {
         allowShortCircuit: true,
         allowTernary: true,
       },
     ],
     'consistent-return': 'warn',
     'no-unused-vars': 'off',
   },
 },
 {
   ignores: [
     'node_modules',
     'vendor',
     'bin',
     'web/core',
     'web/sites',
     'web/modules/contrib',
     'web/themes/contrib',
     'web/profiles/contrib',
     'web/libraries',
     'web/storybook',
   ],
 },
]

This includes linting for Storybook files and tests as well. Additionally, it ignores core and contrib files.

NPM scripts for linting

We use the following NPM scripts to run our linting commands locally and on CI.

"scripts": {
  "format": "prettier --write \"**/*.{css,ts,tsx,js,jsx,json}\"",
  "lint": "npm run lint-prettier && npm run lint-css && npm run lint-js",
  "lint-prettier": "prettier --check \"**/*.{css,ts,tsx,js,jsx,json}\"",
  "lint-css": "stylelint \"**/*.css\"",
  "lint-js": "eslint ."
},

These commands work so well because we have excluded all Drupal core and contrib folders using ignore files. 

Testing using Storybook test runner

Storybook test runner provides the boilerplate-free ability to run automated snapshot and accessibility tests on each story in Storybook. Our previous test tooling involved using Jest and Axe to handle this, but we needed to manually write tests for each component. With Storybook test runner, this is handled automatically.

To set it up, first, install some dependencies.

npm i -D @storybook/test-runner axe-playwright

Then create the following test-runner.js file inside your .storybook directory.

import { waitForPageReady } from '@storybook/test-runner'
import { injectAxe, checkA11y } from 'axe-playwright'
import { expect } from '@storybook/test';

/*
 * See https://storybook.js.org/docss/writing-tests/test-runner#test-hook-api
 * to learn more about the test-runner hooks API.
 */
const config = {
 async preVisit(page) {
   await injectAxe(page)
 },
 async postVisit(page) {
   await waitForPageReady(page)

   // Automated snapshot testing for each story.
   const elementHandler = await page.$('#storybook-root')
   const innerHTML = await elementHandler.innerHTML()

   expect(innerHTML).toMatchSnapshot()

   // Automated accessibility testing for each story.
   await checkA11y(page, '#storybook-root', {
     detailedReport: true,
     detailedReportOptions: {
       html: true,
     },
   })
 },
}

export default config

This config will loop through all your stories, wait for them to be ready, then snapshot them and run Axe against them. You’ll get great output from the command, so you can see exactly what’s going on.

NPM scripts for testing Storybook locally and on CI

First, install a few more dependencies:

npm i -D http-server wait-on

The following scripts will run the complete Storybook test base and update snapshots as needed.

"scripts": {
  "test-storybook": "test-storybook",
  "test-storybook:update": "test-storybook -u",
  "test-storybook:ci": "concurrently -k -s first -n \"SERVER,TEST\" -c \"magenta,blue\" \"npm run http-server\" \"wait-on tcp:6006 && npm run test-storybook\"",
  "http-server": "http-server web/storybook -p 6006 --silent"
},

To run tests on CI we use http-server to serve the built version of Storybook and wait-on to delay the test run until the server is ready. The concurrently command smooths the output of both these commands.

Wrapping up

See the complete workflow, including all config and ignore files in the pnx-frontend-build-tools-blog repository I've setup for this post.

The repository and this blog post have been designed to provide the necessary pieces so you can implement this workflow on your existing (or new) projects. However, a lot more functionality can be gained, including easily adding support for Typescript, React and Vitest.

Tagged

Storybook, Vite

Related Articles