Volto add-ons#

There are several advanced scenarios where we might want to have more control and flexibility beyond using the plain Volto project to build a site.

We can build Volto add-on products and make them available as generic JavaScript packages that can be included in any Volto project. By doing so we can provide code and component reutilization across projects and, of course, benefit from open source collaboration.

Note

By declaring a JavaScript package as a Volto add-on, Volto provides several integration features: language features (so they can be transpiled by Babel), whole-process customization via razzle.extend.js and integration with Volto's configuration registry.

The add-on can be published to an npm registry or directly installed from github by Yarn. By using mrs-develop, it's possible to have a workflow similar to zc.buildout's mr.developer, where you can "checkout" an add-on for development.

An add-on can be almost anything that a Volto project can be. They can:

  • provide additional views and blocks

  • override or extend Volto's builtin views, blocks, settings

  • shadow (customize) Volto's (or another add-on's) modules

  • register custom routes

  • provide custom Redux actions and reducers

  • register custom Express middleware for Volto's server process

  • tweak Volto's Webpack configuration, load custom Razzle and Webpack plugins

  • even provide a custom theme, just like a regular Volto project does.

Configuring a Volto project to use an add-on#

You can install a Volto add-on just like any other JS package:

yarn add name-of-add-on

If the add-on is not published on npm, you can retrieve it directly from Github:

yarn add collective/volto-dropdownmenu

Next, you'll need to add the add-on (identified by its JS package name) to the addons key of your Volto project's package.json. More details in the next section.

Loading add-on configuration#

As a convenience, an add-on can export configuration functions that can mutate, in-place, the overall Volto configuration registry. An add-on can export multiple configurations methods, making it possible to selectively choose which specific add-on functionality you want to load.

In your Volto project's package.json you can allow the add-on to alter the global configuration by adding, in the addons key, a list of Volto add-on package names, like:

{
  "name": "my-nice-volto-project",

  "addons": [
    "acme-volto-foo-add-on",
    "@plone/some-add-on",
    "collective-another-volto-add-on"
  ],

}

Warning

Adding the add-on package to the addons key is mandatory! It allows Volto to treat that package properly and provide it with BabelJS language features. In Plone terminology, it is like including a Python egg to the zcml section of zc.buildout.

Some add-ons might choose to allow the Volto project to selectively load some of their configuration, so they may offer additional configuration functions, which you can load by overloading the add-on name in the addons package.json key, like so:

{
  "name": "my-nice-volto-project",
  "addons": [
    "acme-volto-foo-add-on:loadOptionalBlocks,overrideSomeDefaultBlock",
    "volto-ga"
  ],
}

Note

The additional comma-separated names should be exported from the add-on package's index.js. The main configuration function should be exported as the default. An add-on's default configuration method will always be loaded.

If for some reason, you want to manually load the add-on, you could always do, in your project's config.js module:

import loadExampleAddon, { enableOptionalBlocks } from 'volto-example-add-on';
import * as voltoConfig from '@plone/volto/config';

const config = enableOptionalBlocks(loadExampleAddon(voltoConfig));

export blocks = {
  ...config.blocks,
}

As this is a common operation, Volto provides a helper method for this:

import { applyConfig } from '@plone/volto/helpers';
import * as voltoConfig from '@plone/volto/config';

const config = applyConfig([
    enableOptionalBlocks,
    loadExampleAddon
], voltoConfig);

export blocks = {
  ...config.blocks,
}

The applyConfig helper ensures that each configuration methods returns the config object, avoiding odd and hard to track errors when developing add-ons.

Creating add-ons#

Volto add-on packages are just CommonJS packages. The only requirement is that they point the main key of their package.json to a module that exports, as a default function that acts as a Volto configuration loader.

Although you could simply use npm init to generate an add-on initial code, we now have a nice Yeoman-based generator that you can use:

npm install -g @plone/generator-volto
yo @plone/volto:addon [<addonName>] [options]

Volto will automatically provide aliases for your (unreleased) package, so that once you've released it, you don't need to change import paths, since you can use the final ones from the very beginning. This means that you can use imports such as import { Something } from '@plone/my-volto-add-on' without any extra configuration.

Use mrs-developer to manage the development cycle#

mrs.developer.json#

This is the configuration file that instructs mrs-developer from where it has to pull the packages. So, create mrs.developer.json and add:

{
  "acme-volto-foo-add-on": {
    "package": "@acme/volto-foo-add-on",
    "url": "git@github.com:acme/my-volto-add-on.git",
    "path": "src"
  }
}

Then run:

make develop

Now the add-on is found in src/addons/.

Note

package property is optional, set it up only if your package has a scope. src is required if the content of your add-on is located in the src directory (but, as that is the convention recommended for all Volto add-on packages, you will always include it)

If you want to know more about mrs-developer config options, please refer to its npm page.

tsconfig.json / jsconfig.json#

mrs-developer automatically creates this file for you, but if you choose not to use mrs-developer, you'll have to add something like this to your tsconfig.json or jsconfig.json file in the Volto project root:

{
  "compilerOptions": {
    "paths": {
      "acme-volto-foo-add-on": [
        "addons/acme-volto-foo-add-on/src"
      ]
    },
    "baseUrl": "src"
  }
}

Warning

Please note that both paths and baseUrl are required to match your project layout.

Tip

You should use the src path inside your package and point the main key in package.json to the index.js file in src/index.js.

Customizations#

add-on packages can include customization folders, just like the Volto projects. The customizations are resolved in the order: add-ons (as sorted in the addons key of your project's package.json) then the customizations in the Volto project, last one wins.

Tip

See the Advanced customization scenarios section on how to enhance this pattern and how to include customizations inside add-ons.

Providing add-on configuration#

The default export of your add-on main index.js file should be a function with the signature config => config. That is, it should take the global configuration object and return it, possibly mutated or changed. So your main index.js will look like:

export default function applyConfig(config) {
  config.blocks.blocksConfig.faq_viewer = {
    id: 'faq_viewer',
    title: 'FAQ Viewer',
    edit: FAQBlockEdit,
    view: FAQBlockView,
    icon: chartIcon,
    group: 'common',
    restricted: false,
    mostUsed: true,
    sidebarTab: 1,
  };
  return config;
}

And the package.json file of your add-on:

{
  "main": "src/index.js",
}

Warning

An add-on's default configuration method will always be loaded.

Multiple add-on configurations#

You can export additional configuration functions from your add-on's main index.js.

import applyConfig, {loadOptionalBlocks,overrideSomeDefaultBlock} from './config';

export { loadOptionalBlocks, overrideSomeDefaultBlock };
export default applyConfig;

Add third-party dependencies to your add-on#

If you're developing the add-on and you wish to add an external dependency, you'll have to switch your project to be a Yarn Workspaces root.

So you'll need to add, in your Volto project's package.json:

"private": true,
"workspaces": [],

Then populate the workspaces key with the path to your development add-ons:

"workspaces": [
  "src/addons/my-volto-add-on"
]

You'll have to manage the add-on dependencies via the workspace root (your Volto project). For example, to add a new dependency:

yarn workspace @plone/my-volto-add-on add some-third-party-package

You can run yarn workspaces info to see a list of workspaces defined.

In case you want to add new dependencies to the Volto project, now you'll have to run the yarn add command with the -W switch:

yarn add -W some-dependency

Extending Razzle from an add-on#

Just like you can extend Razzle's configuration from the project, you can do so with an add-on, as well. You should provide a razzle.extend.js file in your add-on root folder. An example of such file where the theme.config alias is changed, to enable a custom Semantic theme inside the add-on:

const plugins = (defaultPlugins) => {
  return defaultPlugins;
};
const modify = (config, { target, dev }, webpack) => {
  const themeConfigPath = `${__dirname}/theme/theme.config`;
  config.resolve.alias['../../theme.config$'] = themeConfigPath;

  return config;
};

module.exports = {
  plugins,
  modify,
};

Extending Eslint configuration from an add-on#

Starting with Volto v16.4.0, you can also customize the Eslint configuration from an add-on. You should provide a eslint.extend.js file in your add-on root folder, which exports a modify(defaultConfig) function. For example, to host some code outside the regular src/ folder of your add-on, this eslint.extend.js file is needed:

const path = require('path');

module.exports = {
  modify(defaultConfig) {
    const aliasMap = defaultConfig.settings['import/resolver'].alias.map;
    const addonPath = aliasMap.find(
      ([name]) => name === '@plone-collective/some-volto-add-on',
    )[1];

    const extraPath = path.resolve(`${addonPath}/../extra`);
    aliasMap.push(['@plone-collective/extra', extraPath]);

    return defaultConfig;
  },
};

This would allow the @plone-collective/some-volto-add-on to host some code outside of its normal src/ folder, let's say in the extra folder, and that code would be available under the @plone-collective/extra name. Note: this is taking care only of the Eslint integration. For proper language support, you'll still need to do it in the razzle.extend.js of your add-on.

add-on dependencies#

Sometimes your add-on depends on another add-on. You can declare add-on dependency in your add-on's addons key, just like you do in your project. By doing so, that other add-on's configuration loader is executed first, so you can depend on the configuration being already applied. Another benefit is that you'll have to declare only the "top level" add-on in your project, the dependencies will be discovered and automatically treated as Volto add-ons. For example, volto-slate depends on volto-object-widget's configuration being already applied, so volto-slate can declare in its package.json:

{
  "name": "volto-slate",

  "addons": ["@eeacms/volto-object-widget"]
}

And of course, the dependency add-on can depend, on its turn, on other add-ons which will be loaded as well. Circular dependencies should be avoided.

Problems with untranspiled add-on dependencies#

When using external add-ons in your project, sometimes you will run into add-ons that are not securely transpiled or haven't been transpiled at all. In that case you might see an error like the following:

Module parse failed: Unexpected token (10:41) in @react-leaflet/core/esm/path.js
...
const options = props.pathOptions ?? {};
...

Babel automatically transpiles the code in your add-on, but node_modules are excluded from this process, so we need to include the add-on path in the list of modules to be transpiled. This can be accomplished by customizing the webpack configuration in the razzle.config.js file in your add-on. For example, suppose that we want to use react-leaflet, which has a known transpilation issue:

const path = require('path');
const makeLoaderFinder = require('razzle-dev-utils/makeLoaderFinder');

const babelLoaderFinder = makeLoaderFinder('babel-loader');

const jsConfig = require('./jsconfig').compilerOptions;

const pathsConfig = jsConfig.paths;
let voltoPath = './node_modules/@plone/volto';
Object.keys(pathsConfig).forEach((pkg) => {
  if (pkg === '@plone/volto') {
    voltoPath = `./${jsConfig.baseUrl}/${pathsConfig[pkg][0]}`;
  }
});

const { modifyWebpackConfig, plugins } = require(`${voltoPath}/razzle.config`);

const customModifyWebpackConfig = ({ env, webpackConfig, webpackObject, options }) => {
  const config = modifyWebpackConfig({
    env,
    webpackConfig,
    webpackObject,
    options,
  });
  const babelLoader = config.module.rules.find(babelLoaderFinder);
  const { include } = babelLoader;
  const corePath = path.join(
    path.dirname(require.resolve('@react-leaflet/core')),
    '..',
  );
  const esmPath = path.join(
    path.dirname(require.resolve('react-leaflet')),
    '..',
  );

  include.push(corePath);
  include.push(esmPath);
  return config;
};

module.exports = { modifyWebpackConfig: customModifyWebpackConfig, plugins };

First we need some setup to get the webpack configuration from Volto's configuration. Once we have that, we need to resolve the path to the desired add-ons and push it into the Babel loader include list. After this, the add-ons will load correctly.

Testing add-ons#

We should let jest know about our aliases and make them available to it to resolve them, so in package.json:

"jest": {
  "moduleNameMapper": {
    "@plone/volto/(.*)$": "<rootDir>/node_modules/@plone/volto/src/$1",
    "@package/(.*)$": "<rootDir>/src/$1",
    "@plone/some-volto-add-on/(.*)$": "<rootDir>/src/addons/@plone/some-volto-add-on/src/$1",
    "my-volto-add-on/(.*)$": "<rootDir>/src/addons/my-volto-add-on/src/$1",
    "~/(.*)$": "<rootDir>/src/$1"
  },

Tip

We're in the process of moving the default scaffolding generators to provide a jest.config.js file in Volto, making this step unneeded.

You can use yarn test src/addons/add-on-name to run tests.

Code linting#

If you have generated your Volto project recently (after the summer of 2020), you don't have to do anything to have automatic integration with ESLint, otherwise make sure to upgrade your project's .eslintrc to the .eslintrc.js version, according to the Upgrade Guide.